resulgit 1.0.18 → 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -61
- package/package.json +1 -1
- package/resulgit.js +451 -1307
package/resulgit.js
CHANGED
|
@@ -3,32 +3,9 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const crypto = require('crypto')
|
|
6
|
-
const ora = require('ora')
|
|
7
|
-
const validation = require('./lib/validation')
|
|
8
|
-
const errors = require('./lib/errors')
|
|
9
|
-
const { parseBlame, formatBlameOutput, formatBlameJson } = require('./lib/blame')
|
|
10
|
-
const { generateLogGraph, formatCompactLog, generateCommitStats } = require('./lib/log-viz')
|
|
11
|
-
const hooks = require('./lib/hooks')
|
|
12
6
|
const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
|
|
13
7
|
function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
|
|
14
8
|
|
|
15
|
-
function createSpinner(text, jsonMode) {
|
|
16
|
-
if (jsonMode === 'true' || !process.stdout.isTTY) return null
|
|
17
|
-
return ora(text).start()
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function spinnerSuccess(spinner, text) {
|
|
21
|
-
if (spinner) spinner.succeed(text)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function spinnerFail(spinner, text) {
|
|
25
|
-
if (spinner) spinner.fail(text)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function spinnerUpdate(spinner, text) {
|
|
29
|
-
if (spinner) spinner.text = text
|
|
30
|
-
}
|
|
31
|
-
|
|
32
9
|
function parseArgs(argv) {
|
|
33
10
|
const tokens = argv.slice(2)
|
|
34
11
|
const cmd = []
|
|
@@ -37,22 +14,12 @@ function parseArgs(argv) {
|
|
|
37
14
|
const t = tokens[i]
|
|
38
15
|
if (t.startsWith('--')) {
|
|
39
16
|
const key = t.slice(2)
|
|
40
|
-
const val = tokens[i + 1] && !tokens[i + 1].startsWith('
|
|
41
|
-
opts[key] = val
|
|
42
|
-
} else if (t.startsWith('-') && t.length === 2) {
|
|
43
|
-
// Short flag like -m, -a
|
|
44
|
-
const key = t.slice(1)
|
|
45
|
-
const val = tokens[i + 1] && !tokens[i + 1].startsWith('-') ? tokens[++i] : 'true'
|
|
17
|
+
const val = tokens[i + 1] && !tokens[i + 1].startsWith('--') ? tokens[++i] : 'true'
|
|
46
18
|
opts[key] = val
|
|
47
|
-
} else if (cmd.length <
|
|
48
|
-
// Allow up to 3 positional args: e.g. 'branch create DevBranch'
|
|
19
|
+
} else if (cmd.length < 2) {
|
|
49
20
|
cmd.push(t)
|
|
50
21
|
}
|
|
51
22
|
}
|
|
52
|
-
// Map short flags to long flags
|
|
53
|
-
if (opts.m && !opts.message) { opts.message = opts.m; delete opts.m }
|
|
54
|
-
if (opts.a && !opts.all) { opts.all = opts.a; delete opts.a }
|
|
55
|
-
if (opts.b && !opts.branch) { opts.branch = opts.b; delete opts.b }
|
|
56
23
|
return { cmd, opts }
|
|
57
24
|
}
|
|
58
25
|
|
|
@@ -102,43 +69,6 @@ async function request(method, url, body, token) {
|
|
|
102
69
|
return res.text()
|
|
103
70
|
}
|
|
104
71
|
|
|
105
|
-
/**
|
|
106
|
-
* Upload files as blobs via multipart form data
|
|
107
|
-
* @param {string} server - Server URL
|
|
108
|
-
* @param {string} repoId - Repository ID
|
|
109
|
-
* @param {Record<string, string>} files - Map of file paths to content
|
|
110
|
-
* @param {string} token - Auth token
|
|
111
|
-
* @returns {Promise<Record<string, string>>} Map of file paths to blob IDs
|
|
112
|
-
*/
|
|
113
|
-
async function uploadBlobs(server, repoId, files, token) {
|
|
114
|
-
const FormData = (await import('node:buffer')).File ? globalThis.FormData : (await import('undici')).FormData
|
|
115
|
-
const formData = new FormData()
|
|
116
|
-
|
|
117
|
-
for (const [filePath, content] of Object.entries(files)) {
|
|
118
|
-
// Create a Blob/File from the content
|
|
119
|
-
const blob = new Blob([content], { type: 'text/plain' })
|
|
120
|
-
formData.append('files', blob, filePath)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const url = new URL(`/api/repositories/${repoId}/blobs`, server).toString()
|
|
124
|
-
const headers = {}
|
|
125
|
-
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
126
|
-
|
|
127
|
-
const res = await fetch(url, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers,
|
|
130
|
-
body: formData
|
|
131
|
-
})
|
|
132
|
-
|
|
133
|
-
if (!res.ok) {
|
|
134
|
-
const text = await res.text()
|
|
135
|
-
throw new Error(`Blob upload failed: ${res.status} ${res.statusText} ${text}`)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const data = await res.json()
|
|
139
|
-
return data.blobs || {}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
72
|
function print(obj, json) {
|
|
143
73
|
if (json) {
|
|
144
74
|
process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
|
|
@@ -176,17 +106,11 @@ async function cmdAuth(sub, opts) {
|
|
|
176
106
|
const password = opts.password
|
|
177
107
|
if (!email || !password) throw new Error('Missing --email and --password')
|
|
178
108
|
const url = new URL('/api/auth/login', server).toString()
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
spinnerSuccess(spinner, `Logged in as ${email}`)
|
|
185
|
-
print(res, opts.json === 'true')
|
|
186
|
-
} catch (err) {
|
|
187
|
-
spinnerFail(spinner, 'Login failed')
|
|
188
|
-
throw err
|
|
189
|
-
}
|
|
109
|
+
print({ server, email, password, url }, opts.json === 'true')
|
|
110
|
+
const res = await request('POST', url, { email, password }, '')
|
|
111
|
+
const token = res.token || ''
|
|
112
|
+
if (token) saveConfig({ token })
|
|
113
|
+
print(res, opts.json === 'true')
|
|
190
114
|
return
|
|
191
115
|
}
|
|
192
116
|
if (sub === 'register') {
|
|
@@ -197,17 +121,10 @@ async function cmdAuth(sub, opts) {
|
|
|
197
121
|
const displayName = opts.displayName || username
|
|
198
122
|
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
199
123
|
const url = new URL('/api/auth/register', server).toString()
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if (token) saveConfig({ token })
|
|
205
|
-
spinnerSuccess(spinner, `Registered as ${username}`)
|
|
206
|
-
print(res, opts.json === 'true')
|
|
207
|
-
} catch (err) {
|
|
208
|
-
spinnerFail(spinner, 'Registration failed')
|
|
209
|
-
throw err
|
|
210
|
-
}
|
|
124
|
+
const res = await request('POST', url, { username, email, password, displayName }, '')
|
|
125
|
+
const token = res.token || ''
|
|
126
|
+
if (token) saveConfig({ token })
|
|
127
|
+
print(res, opts.json === 'true')
|
|
211
128
|
return
|
|
212
129
|
}
|
|
213
130
|
throw new Error('Unknown auth subcommand')
|
|
@@ -217,17 +134,10 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
217
134
|
const server = getServer(opts, cfg)
|
|
218
135
|
const token = getToken(opts, cfg)
|
|
219
136
|
if (sub === 'list') {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
|
|
225
|
-
print(data, opts.json === 'true')
|
|
226
|
-
await tuiSelectRepo(data || [], cfg, opts)
|
|
227
|
-
} catch (err) {
|
|
228
|
-
spinnerFail(spinner, 'Failed to fetch repositories')
|
|
229
|
-
throw err
|
|
230
|
-
}
|
|
137
|
+
const url = new URL('/api/repositories', server).toString()
|
|
138
|
+
const data = await request('GET', url, null, token)
|
|
139
|
+
print(data, opts.json === 'true')
|
|
140
|
+
await tuiSelectRepo(data || [], cfg, opts)
|
|
231
141
|
return
|
|
232
142
|
}
|
|
233
143
|
if (sub === 'create') {
|
|
@@ -238,21 +148,14 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
238
148
|
initializeWithReadme: opts.init === 'true'
|
|
239
149
|
}
|
|
240
150
|
if (!body.name) throw new Error('Missing --name')
|
|
241
|
-
const
|
|
151
|
+
const url = new URL('/api/repositories', server).toString()
|
|
152
|
+
const data = await request('POST', url, body, token)
|
|
153
|
+
print(data, opts.json === 'true')
|
|
242
154
|
try {
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
try {
|
|
248
|
-
const repoId = String(data.id || '')
|
|
249
|
-
const branch = String(data.defaultBranch || 'main')
|
|
250
|
-
if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
|
|
251
|
-
} catch { }
|
|
252
|
-
} catch (err) {
|
|
253
|
-
spinnerFail(spinner, `Failed to create repository '${body.name}'`)
|
|
254
|
-
throw err
|
|
255
|
-
}
|
|
155
|
+
const repoId = String(data.id || '')
|
|
156
|
+
const branch = String(data.defaultBranch || 'main')
|
|
157
|
+
if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
|
|
158
|
+
} catch { }
|
|
256
159
|
return
|
|
257
160
|
}
|
|
258
161
|
if (sub === 'log') {
|
|
@@ -318,108 +221,94 @@ async function cmdClone(opts, cfg) {
|
|
|
318
221
|
const server = getServer(opts, cfg)
|
|
319
222
|
const token = getToken(opts, cfg)
|
|
320
223
|
const repo = opts.repo
|
|
321
|
-
const branch = opts.branch
|
|
224
|
+
const branch = opts.branch
|
|
322
225
|
let dest = opts.dest
|
|
323
226
|
if (!repo || !branch) throw new Error('Missing --repo and --branch')
|
|
324
|
-
const spinner = createSpinner('Initializing clone...', opts.json)
|
|
325
227
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
dest = name
|
|
336
|
-
} else {
|
|
337
|
-
dest = String(repo)
|
|
338
|
-
}
|
|
339
|
-
} catch {
|
|
228
|
+
if (!dest) {
|
|
229
|
+
try {
|
|
230
|
+
const infoUrl = new URL(`/api/repositories/${repo}`, server)
|
|
231
|
+
const infoRes = await fetch(infoUrl.toString(), { headers })
|
|
232
|
+
if (infoRes.ok) {
|
|
233
|
+
const info = await infoRes.json()
|
|
234
|
+
const name = info.name || String(repo)
|
|
235
|
+
dest = name
|
|
236
|
+
} else {
|
|
340
237
|
dest = String(repo)
|
|
341
238
|
}
|
|
239
|
+
} catch {
|
|
240
|
+
dest = String(repo)
|
|
342
241
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
242
|
+
}
|
|
243
|
+
dest = path.resolve(dest)
|
|
244
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
245
|
+
url.searchParams.set('branch', branch)
|
|
246
|
+
const res = await fetch(url.toString(), { headers })
|
|
247
|
+
if (!res.ok) {
|
|
248
|
+
const text = await res.text()
|
|
249
|
+
throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
|
|
250
|
+
}
|
|
251
|
+
const data = await res.json()
|
|
252
|
+
const files = data.files || {}
|
|
253
|
+
const root = dest
|
|
254
|
+
for (const [p, content] of Object.entries(files)) {
|
|
255
|
+
const fullPath = path.join(root, p)
|
|
256
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
257
|
+
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
258
|
+
}
|
|
259
|
+
const metaDir = path.join(root, '.vcs-next')
|
|
260
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
261
|
+
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
262
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
263
|
+
const gitDir = path.join(root, '.git')
|
|
264
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
265
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
266
|
+
const headContent = `ref: refs/heads/${branch}\n`
|
|
267
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
268
|
+
const commitId = data.commitId || ''
|
|
269
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
270
|
+
const gitConfig = [
|
|
271
|
+
'[core]',
|
|
272
|
+
'\trepositoryformatversion = 0',
|
|
273
|
+
'\tfilemode = true',
|
|
274
|
+
'\tbare = false',
|
|
275
|
+
'\tlogallrefupdates = true',
|
|
276
|
+
'',
|
|
277
|
+
'[vcs-next]',
|
|
278
|
+
`\tserver = ${server}`,
|
|
279
|
+
`\trepoId = ${repo}`,
|
|
280
|
+
`\tbranch = ${branch}`,
|
|
281
|
+
`\ttoken = ${token || ''}`
|
|
282
|
+
].join('\n')
|
|
283
|
+
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
284
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
|
|
285
|
+
try {
|
|
286
|
+
const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
|
|
287
|
+
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
288
|
+
if (branchesRes.ok) {
|
|
289
|
+
const branchesData = await branchesRes.json()
|
|
290
|
+
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
291
|
+
const allRefs = {}
|
|
292
|
+
for (const b of branches) {
|
|
293
|
+
const name = b.name || ''
|
|
294
|
+
const id = b.commitId || ''
|
|
295
|
+
if (!name) continue
|
|
296
|
+
allRefs[name] = id
|
|
297
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
298
|
+
}
|
|
299
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
351
300
|
}
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
const
|
|
359
|
-
await fs.promises.
|
|
360
|
-
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
301
|
+
} catch { }
|
|
302
|
+
try {
|
|
303
|
+
const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
|
|
304
|
+
commitsUrl.searchParams.set('branch', branch)
|
|
305
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
306
|
+
if (commitsRes.ok) {
|
|
307
|
+
const commitsList = await commitsRes.json()
|
|
308
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
361
309
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
365
|
-
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
366
|
-
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
367
|
-
const gitDir = path.join(root, '.git')
|
|
368
|
-
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
369
|
-
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
370
|
-
const headContent = `ref: refs/heads/${branch}\\n`
|
|
371
|
-
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
372
|
-
const commitId = data.commitId || ''
|
|
373
|
-
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
374
|
-
const gitConfig = [
|
|
375
|
-
'[core]',
|
|
376
|
-
'\\trepositoryformatversion = 0',
|
|
377
|
-
'\\tfilemode = true',
|
|
378
|
-
'\\tbare = false',
|
|
379
|
-
'\\tlogallrefupdates = true',
|
|
380
|
-
'',
|
|
381
|
-
'[vcs-next]',
|
|
382
|
-
`\\tserver = ${server}`,
|
|
383
|
-
`\\trepoId = ${repo}`,
|
|
384
|
-
`\\tbranch = ${branch}`,
|
|
385
|
-
`\\ttoken = ${token || ''}`
|
|
386
|
-
].join('\\n')
|
|
387
|
-
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
388
|
-
await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
|
|
389
|
-
spinnerUpdate(spinner, 'Fetching branch information...')
|
|
390
|
-
try {
|
|
391
|
-
const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
|
|
392
|
-
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
393
|
-
if (branchesRes.ok) {
|
|
394
|
-
const branchesData = await branchesRes.json()
|
|
395
|
-
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
396
|
-
const allRefs = {}
|
|
397
|
-
for (const b of branches) {
|
|
398
|
-
const name = b.name || ''
|
|
399
|
-
const id = b.commitId || ''
|
|
400
|
-
if (!name) continue
|
|
401
|
-
allRefs[name] = id
|
|
402
|
-
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
403
|
-
}
|
|
404
|
-
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
405
|
-
}
|
|
406
|
-
} catch { }
|
|
407
|
-
spinnerUpdate(spinner, 'Fetching commit history...')
|
|
408
|
-
try {
|
|
409
|
-
const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
|
|
410
|
-
commitsUrl.searchParams.set('branch', branch)
|
|
411
|
-
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
412
|
-
if (commitsRes.ok) {
|
|
413
|
-
const commitsList = await commitsRes.json()
|
|
414
|
-
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
415
|
-
}
|
|
416
|
-
} catch { }
|
|
417
|
-
spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
|
|
418
|
-
print('Clone complete', opts.json === 'true')
|
|
419
|
-
} catch (err) {
|
|
420
|
-
spinnerFail(spinner, 'Clone failed')
|
|
421
|
-
throw err
|
|
422
|
-
}
|
|
310
|
+
} catch { }
|
|
311
|
+
print('Clone complete', opts.json === 'true')
|
|
423
312
|
}
|
|
424
313
|
|
|
425
314
|
function readRemoteMeta(dir) {
|
|
@@ -450,15 +339,134 @@ function hashContent(buf) {
|
|
|
450
339
|
return crypto.createHash('sha1').update(buf).digest('hex')
|
|
451
340
|
}
|
|
452
341
|
|
|
342
|
+
// LCS-based diff: computes edit script (list of equal/insert/delete operations)
|
|
343
|
+
function computeDiff(oldLines, newLines) {
|
|
344
|
+
const m = oldLines.length
|
|
345
|
+
const n = newLines.length
|
|
346
|
+
// Build LCS table
|
|
347
|
+
const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1))
|
|
348
|
+
for (let i = 1; i <= m; i++) {
|
|
349
|
+
for (let j = 1; j <= n; j++) {
|
|
350
|
+
if (oldLines[i - 1] === newLines[j - 1]) {
|
|
351
|
+
dp[i][j] = dp[i - 1][j - 1] + 1
|
|
352
|
+
} else {
|
|
353
|
+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Backtrack to produce edit operations
|
|
358
|
+
const ops = [] // { type: 'equal'|'delete'|'insert', oldIdx, newIdx, line }
|
|
359
|
+
let i = m, j = n
|
|
360
|
+
while (i > 0 || j > 0) {
|
|
361
|
+
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
|
|
362
|
+
ops.push({ type: 'equal', oldIdx: i - 1, newIdx: j - 1, line: oldLines[i - 1] })
|
|
363
|
+
i--; j--
|
|
364
|
+
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
|
|
365
|
+
ops.push({ type: 'insert', newIdx: j - 1, line: newLines[j - 1] })
|
|
366
|
+
j--
|
|
367
|
+
} else {
|
|
368
|
+
ops.push({ type: 'delete', oldIdx: i - 1, line: oldLines[i - 1] })
|
|
369
|
+
i--
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
ops.reverse()
|
|
373
|
+
return ops
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Print unified diff with context lines and @@ hunk headers
|
|
377
|
+
function printUnifiedDiff(oldLines, newLines, contextLines) {
|
|
378
|
+
const ctx = contextLines !== undefined ? contextLines : 3
|
|
379
|
+
const ops = computeDiff(oldLines, newLines)
|
|
380
|
+
// Group ops into hunks (runs of changes with context)
|
|
381
|
+
const hunks = []
|
|
382
|
+
let hunk = null
|
|
383
|
+
let lastChangeEnd = -1
|
|
384
|
+
for (let k = 0; k < ops.length; k++) {
|
|
385
|
+
const op = ops[k]
|
|
386
|
+
if (op.type !== 'equal') {
|
|
387
|
+
// Start or extend a hunk
|
|
388
|
+
const contextStart = Math.max(0, k - ctx)
|
|
389
|
+
if (hunk && contextStart <= lastChangeEnd + ctx) {
|
|
390
|
+
// Extend current hunk
|
|
391
|
+
} else {
|
|
392
|
+
// Save previous hunk and start new
|
|
393
|
+
if (hunk) hunks.push(hunk)
|
|
394
|
+
hunk = { startIdx: Math.max(0, k - ctx), endIdx: k }
|
|
395
|
+
}
|
|
396
|
+
hunk.endIdx = k
|
|
397
|
+
lastChangeEnd = k
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (hunk) {
|
|
401
|
+
hunk.endIdx = Math.min(ops.length - 1, hunk.endIdx + ctx)
|
|
402
|
+
hunks.push(hunk)
|
|
403
|
+
}
|
|
404
|
+
// Print each hunk
|
|
405
|
+
for (const h of hunks) {
|
|
406
|
+
const start = h.startIdx
|
|
407
|
+
const end = Math.min(ops.length - 1, h.endIdx)
|
|
408
|
+
// Compute line numbers for header
|
|
409
|
+
let oldStart = 1, newStart = 1
|
|
410
|
+
for (let k = 0; k < start; k++) {
|
|
411
|
+
if (ops[k].type === 'equal' || ops[k].type === 'delete') oldStart++
|
|
412
|
+
if (ops[k].type === 'equal' || ops[k].type === 'insert') newStart++
|
|
413
|
+
}
|
|
414
|
+
let oldCount = 0, newCount = 0
|
|
415
|
+
for (let k = start; k <= end; k++) {
|
|
416
|
+
if (ops[k].type === 'equal' || ops[k].type === 'delete') oldCount++
|
|
417
|
+
if (ops[k].type === 'equal' || ops[k].type === 'insert') newCount++
|
|
418
|
+
}
|
|
419
|
+
process.stdout.write(color(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n`, 'cyan'))
|
|
420
|
+
for (let k = start; k <= end; k++) {
|
|
421
|
+
const op = ops[k]
|
|
422
|
+
if (op.type === 'equal') {
|
|
423
|
+
process.stdout.write(` ${op.line}\n`)
|
|
424
|
+
} else if (op.type === 'delete') {
|
|
425
|
+
process.stdout.write(color(`-${op.line}\n`, 'red'))
|
|
426
|
+
} else if (op.type === 'insert') {
|
|
427
|
+
process.stdout.write(color(`+${op.line}\n`, 'green'))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function loadIgnorePatterns(dir) {
|
|
434
|
+
const patterns = ['.git', '.vcs-next', 'node_modules', '.DS_Store', 'dist', 'build']
|
|
435
|
+
const tryFiles = ['.vcs-ignore', '.gitignore']
|
|
436
|
+
for (const f of tryFiles) {
|
|
437
|
+
try {
|
|
438
|
+
const content = fs.readFileSync(path.join(dir, f), 'utf8')
|
|
439
|
+
const lines = content.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'))
|
|
440
|
+
patterns.push(...lines)
|
|
441
|
+
} catch {}
|
|
442
|
+
}
|
|
443
|
+
return [...new Set(patterns)]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function shouldIgnore(p, patterns) {
|
|
447
|
+
const segments = p.split('/')
|
|
448
|
+
for (const pat of patterns) {
|
|
449
|
+
if (segments.includes(pat)) return true
|
|
450
|
+
if (p === pat || p.startsWith(pat + '/')) return true
|
|
451
|
+
// Basic glob-like support for simple cases
|
|
452
|
+
if (pat.startsWith('**/')) {
|
|
453
|
+
const sub = pat.slice(3)
|
|
454
|
+
if (p.endsWith('/' + sub) || p === sub) return true
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return false
|
|
458
|
+
}
|
|
459
|
+
|
|
453
460
|
async function collectLocal(dir) {
|
|
454
461
|
const out = {}
|
|
455
462
|
const base = path.resolve(dir)
|
|
463
|
+
const patterns = loadIgnorePatterns(base)
|
|
456
464
|
async function walk(cur, rel) {
|
|
457
465
|
const entries = await fs.promises.readdir(cur, { withFileTypes: true })
|
|
458
466
|
for (const e of entries) {
|
|
459
|
-
if (e.name === '.git' || e.name === '.vcs-next') continue
|
|
460
|
-
const abs = path.join(cur, e.name)
|
|
461
467
|
const rp = rel ? rel + '/' + e.name : e.name
|
|
468
|
+
if (shouldIgnore(rp, patterns)) continue
|
|
469
|
+
const abs = path.join(cur, e.name)
|
|
462
470
|
if (e.isDirectory()) {
|
|
463
471
|
await walk(abs, rp)
|
|
464
472
|
} else if (e.isFile()) {
|
|
@@ -503,25 +511,98 @@ async function fetchRemoteFilesMap(server, repo, branch, token) {
|
|
|
503
511
|
async function cmdStatus(opts) {
|
|
504
512
|
const dir = path.resolve(opts.dir || '.')
|
|
505
513
|
const meta = readRemoteMeta(dir)
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
|
|
514
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
515
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
516
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
517
|
+
try {
|
|
518
|
+
const s = fs.readFileSync(localPath, 'utf8')
|
|
519
|
+
localMeta = JSON.parse(s)
|
|
520
|
+
} catch {}
|
|
521
|
+
|
|
509
522
|
const local = await collectLocal(dir)
|
|
510
|
-
const
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
const
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
523
|
+
const baseFiles = localMeta.baseFiles || {}
|
|
524
|
+
const pendingFiles = localMeta.pendingCommit ? localMeta.pendingCommit.files : null
|
|
525
|
+
|
|
526
|
+
const untracked = []
|
|
527
|
+
const modifiedUnstaged = []
|
|
528
|
+
const deletedUnstaged = []
|
|
529
|
+
const modifiedStaged = []
|
|
530
|
+
const deletedStaged = []
|
|
531
|
+
const newStaged = []
|
|
532
|
+
|
|
533
|
+
// If there's a pending commit, that's our "staged" area
|
|
534
|
+
if (pendingFiles) {
|
|
535
|
+
// staged changes: pendingFiles vs baseFiles
|
|
536
|
+
const allStagedPaths = new Set([...Object.keys(baseFiles), ...Object.keys(pendingFiles)])
|
|
537
|
+
for (const p of allStagedPaths) {
|
|
538
|
+
const b = baseFiles[p]
|
|
539
|
+
const s = pendingFiles[p]
|
|
540
|
+
if (b === undefined && s !== undefined) newStaged.push(p)
|
|
541
|
+
else if (b !== undefined && s === undefined) deletedStaged.push(p)
|
|
542
|
+
else if (b !== s) modifiedStaged.push(p)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// unstaged changes: local vs pendingFiles
|
|
546
|
+
const localPaths = new Set(Object.keys(local))
|
|
547
|
+
const stagedPaths = new Set(Object.keys(pendingFiles))
|
|
548
|
+
for (const p of localPaths) {
|
|
549
|
+
if (!stagedPaths.has(p)) untracked.push(p)
|
|
550
|
+
else if (pendingFiles[p] !== local[p].content) modifiedUnstaged.push(p)
|
|
551
|
+
}
|
|
552
|
+
for (const p of stagedPaths) {
|
|
553
|
+
if (!localPaths.has(p)) deletedUnstaged.push(p)
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
// No pending commit: just local vs baseFiles
|
|
557
|
+
const localPaths = new Set(Object.keys(local))
|
|
558
|
+
const basePaths = new Set(Object.keys(baseFiles))
|
|
559
|
+
for (const p of localPaths) {
|
|
560
|
+
if (!basePaths.has(p)) untracked.push(p)
|
|
561
|
+
else if (baseFiles[p] !== local[p].content) modifiedUnstaged.push(p)
|
|
562
|
+
}
|
|
563
|
+
for (const p of basePaths) {
|
|
564
|
+
if (!localPaths.has(p)) deletedUnstaged.push(p)
|
|
565
|
+
}
|
|
519
566
|
}
|
|
520
|
-
|
|
521
|
-
|
|
567
|
+
|
|
568
|
+
if (opts.json === 'true') {
|
|
569
|
+
print({
|
|
570
|
+
branch: meta.branch,
|
|
571
|
+
ahead: localMeta.pendingCommit ? 1 : 0,
|
|
572
|
+
staged: { modified: modifiedStaged, deleted: deletedStaged, new: newStaged },
|
|
573
|
+
unstaged: { modified: modifiedUnstaged, deleted: deletedUnstaged },
|
|
574
|
+
untracked
|
|
575
|
+
}, true)
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
process.stdout.write(`On branch ${color(meta.branch, 'cyan')}\n`)
|
|
580
|
+
if (localMeta.pendingCommit) {
|
|
581
|
+
process.stdout.write(`Your branch is ahead of 'origin/${meta.branch}' by 1 commit.\n`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (modifiedStaged.length > 0 || deletedStaged.length > 0 || newStaged.length > 0) {
|
|
585
|
+
process.stdout.write('\nChanges to be committed:\n')
|
|
586
|
+
for (const p of newStaged) process.stdout.write(color(` new file: ${p}\n`, 'green'))
|
|
587
|
+
for (const p of modifiedStaged) process.stdout.write(color(` modified: ${p}\n`, 'green'))
|
|
588
|
+
for (const p of deletedStaged) process.stdout.write(color(` deleted: ${p}\n`, 'green'))
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (modifiedUnstaged.length > 0 || deletedUnstaged.length > 0) {
|
|
592
|
+
process.stdout.write('\nChanges not staged for commit:\n')
|
|
593
|
+
for (const p of modifiedUnstaged) process.stdout.write(color(` modified: ${p}\n`, 'red'))
|
|
594
|
+
for (const p of deletedUnstaged) process.stdout.write(color(` deleted: ${p}\n`, 'red'))
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (untracked.length > 0) {
|
|
598
|
+
process.stdout.write('\nUntracked files:\n')
|
|
599
|
+
for (const p of untracked) process.stdout.write(color(` ${p}\n`, 'red'))
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (modifiedStaged.length === 0 && deletedStaged.length === 0 && newStaged.length === 0 &&
|
|
603
|
+
modifiedUnstaged.length === 0 && deletedUnstaged.length === 0 && untracked.length === 0) {
|
|
604
|
+
process.stdout.write('nothing to commit, working tree clean\n')
|
|
522
605
|
}
|
|
523
|
-
const out = { branch: meta.branch, head: remote.headCommitId, added, modified, deleted }
|
|
524
|
-
print(out, opts.json === 'true')
|
|
525
606
|
}
|
|
526
607
|
|
|
527
608
|
async function cmdRestore(opts) {
|
|
@@ -578,73 +659,6 @@ async function cmdDiff(opts) {
|
|
|
578
659
|
|
|
579
660
|
const filePath = opts.path
|
|
580
661
|
const commitId = opts.commit
|
|
581
|
-
const commit1 = opts.commit1
|
|
582
|
-
const commit2 = opts.commit2
|
|
583
|
-
const showStat = opts.stat === 'true'
|
|
584
|
-
|
|
585
|
-
// Handle diff between two commits: git diff <commit1> <commit2>
|
|
586
|
-
if (commit1 && commit2) {
|
|
587
|
-
const snap1 = await fetchSnapshotByCommit(server, meta.repoId, commit1, token)
|
|
588
|
-
const snap2 = await fetchSnapshotByCommit(server, meta.repoId, commit2, token)
|
|
589
|
-
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(snap1.files), ...Object.keys(snap2.files)]))
|
|
590
|
-
|
|
591
|
-
let added = 0, deleted = 0, modified = 0
|
|
592
|
-
const stats = []
|
|
593
|
-
|
|
594
|
-
for (const p of files) {
|
|
595
|
-
const content1 = snap1.files[p] !== undefined ? String(snap1.files[p]) : null
|
|
596
|
-
const content2 = snap2.files[p] !== undefined ? String(snap2.files[p]) : null
|
|
597
|
-
if (content1 !== content2) {
|
|
598
|
-
if (content1 === null) {
|
|
599
|
-
added++
|
|
600
|
-
if (showStat) stats.push({ path: p, added: content2.split(/\r?\n/).length, deleted: 0 })
|
|
601
|
-
} else if (content2 === null) {
|
|
602
|
-
deleted++
|
|
603
|
-
if (showStat) stats.push({ path: p, added: 0, deleted: content1.split(/\r?\n/).length })
|
|
604
|
-
} else {
|
|
605
|
-
modified++
|
|
606
|
-
const lines1 = content1.split(/\r?\n/)
|
|
607
|
-
const lines2 = content2.split(/\r?\n/)
|
|
608
|
-
const diff = Math.abs(lines2.length - lines1.length)
|
|
609
|
-
if (showStat) stats.push({ path: p, added: lines2.length > lines1.length ? diff : 0, deleted: lines1.length > lines2.length ? diff : 0 })
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
if (!showStat) {
|
|
613
|
-
if (opts.json === 'true') {
|
|
614
|
-
print({ path: p, old: content1, new: content2 }, true)
|
|
615
|
-
} else {
|
|
616
|
-
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
|
|
617
|
-
// Show full diff (same as before)
|
|
618
|
-
const oldLines = content1 ? content1.split(/\r?\n/) : []
|
|
619
|
-
const newLines = content2 ? content2.split(/\r?\n/) : []
|
|
620
|
-
const maxLen = Math.max(oldLines.length, newLines.length)
|
|
621
|
-
for (let i = 0; i < maxLen; i++) {
|
|
622
|
-
const oldLine = oldLines[i]
|
|
623
|
-
const newLine = newLines[i]
|
|
624
|
-
if (oldLine !== newLine) {
|
|
625
|
-
if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
|
|
626
|
-
if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
|
|
627
|
-
} else if (oldLine !== undefined) {
|
|
628
|
-
process.stdout.write(` ${oldLine}\n`)
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (showStat) {
|
|
637
|
-
if (opts.json === 'true') {
|
|
638
|
-
print({ added, deleted, modified, files: stats }, true)
|
|
639
|
-
} else {
|
|
640
|
-
for (const stat of stats) {
|
|
641
|
-
process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
|
|
642
|
-
}
|
|
643
|
-
process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
return
|
|
647
|
-
}
|
|
648
662
|
|
|
649
663
|
if (commitId) {
|
|
650
664
|
// Show diff for specific commit
|
|
@@ -653,41 +667,7 @@ async function cmdDiff(opts) {
|
|
|
653
667
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
654
668
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
655
669
|
|
|
656
|
-
const files = filePath ? [filePath] :
|
|
657
|
-
|
|
658
|
-
if (showStat) {
|
|
659
|
-
let added = 0, deleted = 0, modified = 0
|
|
660
|
-
const stats = []
|
|
661
|
-
for (const p of files) {
|
|
662
|
-
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
663
|
-
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
664
|
-
if (oldContent !== newContent) {
|
|
665
|
-
if (oldContent === null) {
|
|
666
|
-
added++
|
|
667
|
-
if (showStat) stats.push({ path: p, added: newContent.split(/\r?\n/).length, deleted: 0 })
|
|
668
|
-
} else if (newContent === null) {
|
|
669
|
-
deleted++
|
|
670
|
-
if (showStat) stats.push({ path: p, added: 0, deleted: oldContent.split(/\r?\n/).length })
|
|
671
|
-
} else {
|
|
672
|
-
modified++
|
|
673
|
-
const oldLines = oldContent.split(/\r?\n/)
|
|
674
|
-
const newLines = newContent.split(/\r?\n/)
|
|
675
|
-
const diff = Math.abs(newLines.length - oldLines.length)
|
|
676
|
-
if (showStat) stats.push({ path: p, added: newLines.length > oldLines.length ? diff : 0, deleted: oldLines.length > newLines.length ? diff : 0 })
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
if (opts.json === 'true') {
|
|
681
|
-
print({ added, deleted, modified, files: stats }, true)
|
|
682
|
-
} else {
|
|
683
|
-
for (const stat of stats) {
|
|
684
|
-
process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
|
|
685
|
-
}
|
|
686
|
-
process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
|
|
687
|
-
}
|
|
688
|
-
return
|
|
689
|
-
}
|
|
690
|
-
|
|
670
|
+
const files = filePath ? [filePath] : [...new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])]
|
|
691
671
|
for (const p of files) {
|
|
692
672
|
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
693
673
|
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
@@ -695,27 +675,17 @@ async function cmdDiff(opts) {
|
|
|
695
675
|
if (opts.json === 'true') {
|
|
696
676
|
print({ path: p, old: oldContent, new: newContent }, true)
|
|
697
677
|
} else {
|
|
698
|
-
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, '
|
|
678
|
+
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'bold'))
|
|
699
679
|
if (oldContent === null) {
|
|
700
680
|
process.stdout.write(color(`+++ b/${p}\n`, 'green'))
|
|
701
|
-
|
|
681
|
+
const lines = newContent.split(/\r?\n/)
|
|
682
|
+
for (const line of lines) process.stdout.write(color(`+${line}\n`, 'green'))
|
|
702
683
|
} else if (newContent === null) {
|
|
703
684
|
process.stdout.write(color(`--- a/${p}\n`, 'red'))
|
|
704
|
-
|
|
685
|
+
const lines = oldContent.split(/\r?\n/)
|
|
686
|
+
for (const line of lines) process.stdout.write(color(`-${line}\n`, 'red'))
|
|
705
687
|
} else {
|
|
706
|
-
|
|
707
|
-
const newLines = newContent.split(/\r?\n/)
|
|
708
|
-
const maxLen = Math.max(oldLines.length, newLines.length)
|
|
709
|
-
for (let i = 0; i < maxLen; i++) {
|
|
710
|
-
const oldLine = oldLines[i]
|
|
711
|
-
const newLine = newLines[i]
|
|
712
|
-
if (oldLine !== newLine) {
|
|
713
|
-
if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
|
|
714
|
-
if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
|
|
715
|
-
} else if (oldLine !== undefined) {
|
|
716
|
-
process.stdout.write(` ${oldLine}\n`)
|
|
717
|
-
}
|
|
718
|
-
}
|
|
688
|
+
printUnifiedDiff(oldContent.split(/\r?\n/), newContent.split(/\r?\n/))
|
|
719
689
|
}
|
|
720
690
|
}
|
|
721
691
|
}
|
|
@@ -724,41 +694,37 @@ async function cmdDiff(opts) {
|
|
|
724
694
|
}
|
|
725
695
|
|
|
726
696
|
// Show diff for working directory
|
|
727
|
-
const
|
|
697
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
698
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
699
|
+
let localMeta = { baseFiles: {} }
|
|
700
|
+
try {
|
|
701
|
+
const s = fs.readFileSync(localPath, 'utf8')
|
|
702
|
+
localMeta = JSON.parse(s)
|
|
703
|
+
} catch {}
|
|
704
|
+
|
|
705
|
+
const baseFiles = localMeta.pendingCommit ? localMeta.pendingCommit.files : localMeta.baseFiles
|
|
728
706
|
const local = await collectLocal(dir)
|
|
729
|
-
const files = filePath ? [filePath] :
|
|
707
|
+
const files = filePath ? [filePath] : [...new Set([...Object.keys(baseFiles), ...Object.keys(local)])]
|
|
730
708
|
|
|
731
709
|
for (const p of files) {
|
|
732
|
-
const
|
|
710
|
+
const baseContent = baseFiles[p] !== undefined ? String(baseFiles[p]) : null
|
|
733
711
|
const localContent = local[p]?.content || null
|
|
734
|
-
const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
|
|
735
|
-
const localId = local[p]?.id
|
|
736
712
|
|
|
737
|
-
if (
|
|
713
|
+
if (baseContent !== localContent) {
|
|
738
714
|
if (opts.json === 'true') {
|
|
739
|
-
print({ path: p,
|
|
715
|
+
print({ path: p, base: baseContent, local: localContent }, true)
|
|
740
716
|
} else {
|
|
741
|
-
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, '
|
|
717
|
+
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'bold'))
|
|
742
718
|
if (localContent === null) {
|
|
743
719
|
process.stdout.write(color(`--- a/${p}\n`, 'red'))
|
|
744
|
-
|
|
745
|
-
|
|
720
|
+
const lines = (baseContent || '').split(/\r?\n/)
|
|
721
|
+
for (const line of lines) process.stdout.write(color(`-${line}\n`, 'red'))
|
|
722
|
+
} else if (baseContent === null) {
|
|
746
723
|
process.stdout.write(color(`+++ b/${p}\n`, 'green'))
|
|
747
|
-
|
|
724
|
+
const lines = localContent.split(/\r?\n/)
|
|
725
|
+
for (const line of lines) process.stdout.write(color(`+${line}\n`, 'green'))
|
|
748
726
|
} else {
|
|
749
|
-
|
|
750
|
-
const localLines = String(localContent).split(/\r?\n/)
|
|
751
|
-
const maxLen = Math.max(remoteLines.length, localLines.length)
|
|
752
|
-
for (let i = 0; i < maxLen; i++) {
|
|
753
|
-
const remoteLine = remoteLines[i]
|
|
754
|
-
const localLine = localLines[i]
|
|
755
|
-
if (remoteLine !== localLine) {
|
|
756
|
-
if (remoteLine !== undefined) process.stdout.write(color(`-${remoteLine}\n`, 'red'))
|
|
757
|
-
if (localLine !== undefined) process.stdout.write(color(`+${localLine}\n`, 'green'))
|
|
758
|
-
} else if (remoteLine !== undefined) {
|
|
759
|
-
process.stdout.write(` ${remoteLine}\n`)
|
|
760
|
-
}
|
|
761
|
-
}
|
|
727
|
+
printUnifiedDiff(baseContent.split(/\r?\n/), localContent.split(/\r?\n/))
|
|
762
728
|
}
|
|
763
729
|
}
|
|
764
730
|
}
|
|
@@ -773,31 +739,6 @@ async function cmdRm(opts) {
|
|
|
773
739
|
const cfg = loadConfig()
|
|
774
740
|
const server = getServer(opts, cfg) || meta.server
|
|
775
741
|
const token = getToken(opts, cfg) || meta.token
|
|
776
|
-
|
|
777
|
-
// Handle --cached flag (remove from index but keep file)
|
|
778
|
-
if (opts.cached === 'true') {
|
|
779
|
-
// Mark file for deletion in next commit but don't delete from filesystem
|
|
780
|
-
const metaDir = path.join(dir, '.vcs-next')
|
|
781
|
-
const localPath = path.join(metaDir, 'local.json')
|
|
782
|
-
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null, removedFiles: [] }
|
|
783
|
-
try {
|
|
784
|
-
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
785
|
-
localMeta = JSON.parse(s)
|
|
786
|
-
} catch { }
|
|
787
|
-
if (!localMeta.removedFiles) localMeta.removedFiles = []
|
|
788
|
-
if (!localMeta.removedFiles.includes(pathArg)) {
|
|
789
|
-
localMeta.removedFiles.push(pathArg)
|
|
790
|
-
}
|
|
791
|
-
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
792
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
793
|
-
if (opts.json === 'true') {
|
|
794
|
-
print({ removed: pathArg, cached: true }, true)
|
|
795
|
-
} else {
|
|
796
|
-
process.stdout.write(color(`Removed '${pathArg}' from index (file kept in working directory)\n`, 'green'))
|
|
797
|
-
}
|
|
798
|
-
return
|
|
799
|
-
}
|
|
800
|
-
|
|
801
742
|
const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
|
|
802
743
|
u.searchParams.set('branch', meta.branch)
|
|
803
744
|
u.searchParams.set('path', pathArg)
|
|
@@ -816,93 +757,40 @@ async function cmdCommit(opts) {
|
|
|
816
757
|
const message = opts.message || ''
|
|
817
758
|
if (!message) throw new Error('Missing --message')
|
|
818
759
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
if (opts.
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
spinnerUpdate(spinner, 'Amending last commit...')
|
|
830
|
-
// Get the last commit and use its message if no new message provided
|
|
831
|
-
const meta = readRemoteMeta(dir)
|
|
832
|
-
const cfg = loadConfig()
|
|
833
|
-
const server = getServer(opts, cfg) || meta.server
|
|
834
|
-
const token = getToken(opts, cfg) || meta.token
|
|
835
|
-
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
836
|
-
url.searchParams.set('branch', meta.branch)
|
|
837
|
-
url.searchParams.set('limit', '1')
|
|
838
|
-
const commits = await request('GET', url.toString(), null, token)
|
|
839
|
-
if (Array.isArray(commits) && commits.length > 0 && !message) {
|
|
840
|
-
opts.message = commits[0].message || 'Amended commit'
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// Check for unresolved conflicts
|
|
845
|
-
spinnerUpdate(spinner, 'Checking for conflicts...')
|
|
846
|
-
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
847
|
-
if (unresolvedConflicts.length > 0) {
|
|
848
|
-
spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
|
|
849
|
-
if (opts.json === 'true') {
|
|
850
|
-
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
851
|
-
} else {
|
|
852
|
-
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
853
|
-
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
854
|
-
for (const p of unresolvedConflicts) {
|
|
855
|
-
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
856
|
-
}
|
|
857
|
-
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
858
|
-
}
|
|
859
|
-
return
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
spinnerUpdate(spinner, 'Collecting changes...')
|
|
863
|
-
const metaDir = path.join(dir, '.vcs-next')
|
|
864
|
-
const localPath = path.join(metaDir, 'local.json')
|
|
865
|
-
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
866
|
-
try {
|
|
867
|
-
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
868
|
-
localMeta = JSON.parse(s)
|
|
869
|
-
} catch { }
|
|
870
|
-
const local = await collectLocal(dir)
|
|
871
|
-
const files = {}
|
|
872
|
-
for (const [p, v] of Object.entries(local)) files[p] = v.content
|
|
873
|
-
|
|
874
|
-
// Execute pre-commit hook
|
|
875
|
-
spinnerUpdate(spinner, 'Running pre-commit hook...')
|
|
876
|
-
try {
|
|
877
|
-
const hookResult = await hooks.executeHook(dir, 'pre-commit', { message, files: Object.keys(files) })
|
|
878
|
-
if (hookResult.executed && hookResult.exitCode !== 0) {
|
|
879
|
-
spinnerFail(spinner, 'Pre-commit hook failed')
|
|
880
|
-
throw new Error('pre-commit hook failed')
|
|
760
|
+
// Check for unresolved conflicts
|
|
761
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
762
|
+
if (unresolvedConflicts.length > 0) {
|
|
763
|
+
if (opts.json === 'true') {
|
|
764
|
+
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
765
|
+
} else {
|
|
766
|
+
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
767
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
768
|
+
for (const p of unresolvedConflicts) {
|
|
769
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
881
770
|
}
|
|
882
|
-
|
|
883
|
-
if (err.message === 'pre-commit hook failed') throw err
|
|
884
|
-
// Hook doesn't exist or other error, continue
|
|
771
|
+
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
885
772
|
}
|
|
773
|
+
return
|
|
774
|
+
}
|
|
886
775
|
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
print({ pendingCommit: message }, opts.json === 'true')
|
|
902
|
-
} catch (err) {
|
|
903
|
-
spinnerFail(spinner, 'Commit failed')
|
|
904
|
-
throw err
|
|
776
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
777
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
778
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
779
|
+
try {
|
|
780
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
781
|
+
localMeta = JSON.parse(s)
|
|
782
|
+
} catch { }
|
|
783
|
+
const local = await collectLocal(dir)
|
|
784
|
+
const files = {}
|
|
785
|
+
for (const [p, v] of Object.entries(local)) files[p] = v.content
|
|
786
|
+
localMeta.pendingCommit = { message, files, createdAt: Date.now() }
|
|
787
|
+
// Clear conflicts if they were resolved
|
|
788
|
+
if (localMeta.conflicts) {
|
|
789
|
+
delete localMeta.conflicts
|
|
905
790
|
}
|
|
791
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
792
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
793
|
+
print({ pendingCommit: message }, opts.json === 'true')
|
|
906
794
|
}
|
|
907
795
|
|
|
908
796
|
async function pullToDir(repo, branch, dir, server, token) {
|
|
@@ -966,15 +854,8 @@ async function cmdPull(opts) {
|
|
|
966
854
|
const cfg = loadConfig()
|
|
967
855
|
const server = getServer(opts, cfg) || meta.server
|
|
968
856
|
const token = getToken(opts, cfg) || meta.token
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
972
|
-
spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
|
|
973
|
-
print('Pull complete', opts.json === 'true')
|
|
974
|
-
} catch (err) {
|
|
975
|
-
spinnerFail(spinner, 'Pull failed')
|
|
976
|
-
throw err
|
|
977
|
-
}
|
|
857
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
858
|
+
print('Pull complete', opts.json === 'true')
|
|
978
859
|
}
|
|
979
860
|
|
|
980
861
|
async function cmdFetch(opts) {
|
|
@@ -1030,14 +911,6 @@ async function fetchRemoteSnapshot(server, repo, branch, token) {
|
|
|
1030
911
|
if (branch) url.searchParams.set('branch', branch)
|
|
1031
912
|
const res = await fetch(url.toString(), { headers })
|
|
1032
913
|
if (!res.ok) {
|
|
1033
|
-
// For new/empty repos, return empty snapshot instead of throwing
|
|
1034
|
-
if (res.status === 404 || res.status === 500) {
|
|
1035
|
-
// Check if this is a "new repo" scenario
|
|
1036
|
-
const text = await res.text()
|
|
1037
|
-
if (text.includes('not found') || text.includes('empty') || text.includes('no commits') || res.status === 500) {
|
|
1038
|
-
return { files: {}, commitId: '' }
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
914
|
const text = await res.text()
|
|
1042
915
|
throw new Error(text || 'snapshot failed')
|
|
1043
916
|
}
|
|
@@ -1371,190 +1244,73 @@ async function cmdPush(opts) {
|
|
|
1371
1244
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1372
1245
|
const localPath = path.join(metaDir, 'local.json')
|
|
1373
1246
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1374
|
-
const spinner = createSpinner('Preparing to push...', opts.json)
|
|
1375
1247
|
try {
|
|
1376
1248
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1377
1249
|
localMeta = JSON.parse(s)
|
|
1378
1250
|
} catch { }
|
|
1379
1251
|
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
process.stderr.write(color(
|
|
1390
|
-
process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
|
|
1391
|
-
for (const p of unresolvedConflicts) {
|
|
1392
|
-
process.stderr.write(color(` ${p}\\n`, 'red'))
|
|
1393
|
-
}
|
|
1394
|
-
process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
|
|
1252
|
+
// Check for unresolved conflicts in files
|
|
1253
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1254
|
+
if (unresolvedConflicts.length > 0) {
|
|
1255
|
+
if (opts.json === 'true') {
|
|
1256
|
+
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1257
|
+
} else {
|
|
1258
|
+
process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
|
|
1259
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
1260
|
+
for (const p of unresolvedConflicts) {
|
|
1261
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
1395
1262
|
}
|
|
1396
|
-
|
|
1263
|
+
process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
|
|
1397
1264
|
}
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1398
1267
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
const
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
const changedRemote = String(r) !== String(b)
|
|
1424
|
-
|
|
1425
|
-
// Check if local file has conflict markers (unresolved)
|
|
1426
|
-
if (l && hasConflictMarkers(l)) {
|
|
1427
|
-
const line = firstDiffLine(l || '', r || '')
|
|
1428
|
-
conflicts.push({ path: p, line, reason: 'Unresolved conflict markers in file' })
|
|
1429
|
-
} else if (changedLocal && changedRemote && String(l) !== String(r)) {
|
|
1430
|
-
// Both changed and different - but if local is the "resolved" version, use it
|
|
1431
|
-
// This happens when user resolved a conflict and is pushing the resolution
|
|
1432
|
-
// The local version wins if it doesn't have conflict markers
|
|
1433
|
-
if (l !== null) merged[p] = l
|
|
1434
|
-
} else if (changedLocal && !changedRemote) {
|
|
1435
|
-
// Local changed, use local version
|
|
1436
|
-
if (l !== null) merged[p] = l
|
|
1437
|
-
} else if (!changedLocal && changedRemote) {
|
|
1438
|
-
// Remote changed, use remote version
|
|
1439
|
-
if (r !== null) merged[p] = r
|
|
1440
|
-
} else {
|
|
1441
|
-
// No changes - include the file from whatever source has it
|
|
1442
|
-
if (l !== null) merged[p] = l
|
|
1443
|
-
else if (r !== null) merged[p] = r
|
|
1444
|
-
else if (b !== null) merged[p] = b
|
|
1445
|
-
}
|
|
1268
|
+
const cfg = loadConfig()
|
|
1269
|
+
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1270
|
+
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1271
|
+
const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
|
|
1272
|
+
const base = localMeta.baseFiles || {}
|
|
1273
|
+
const local = await collectLocal(dir)
|
|
1274
|
+
const conflicts = []
|
|
1275
|
+
const merged = {}
|
|
1276
|
+
const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
|
|
1277
|
+
for (const p of paths) {
|
|
1278
|
+
const b = p in base ? base[p] : null
|
|
1279
|
+
const r = p in remote.files ? remote.files[p] : null
|
|
1280
|
+
const l = p in local ? local[p].content : null
|
|
1281
|
+
const changedLocal = String(l) !== String(b)
|
|
1282
|
+
const changedRemote = String(r) !== String(b)
|
|
1283
|
+
if (changedLocal && changedRemote && String(l) !== String(r)) {
|
|
1284
|
+
const line = firstDiffLine(l || '', r || '')
|
|
1285
|
+
conflicts.push({ path: p, line })
|
|
1286
|
+
} else if (changedLocal && !changedRemote) {
|
|
1287
|
+
if (l !== null) merged[p] = l
|
|
1288
|
+
} else if (!changedLocal && changedRemote) {
|
|
1289
|
+
if (r !== null) merged[p] = r
|
|
1290
|
+
} else {
|
|
1291
|
+
if (b !== null) merged[p] = b
|
|
1446
1292
|
}
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1293
|
+
}
|
|
1294
|
+
if (conflicts.length > 0) {
|
|
1295
|
+
if (opts.json === 'true') {
|
|
1296
|
+
print({ conflicts }, true)
|
|
1297
|
+
} else {
|
|
1298
|
+
process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
|
|
1299
|
+
process.stderr.write(color('Conflicts detected:\n', 'yellow'))
|
|
1451
1300
|
for (const c of conflicts) {
|
|
1452
|
-
|
|
1453
|
-
const remoteContent = remote.files[c.path] || ''
|
|
1454
|
-
|
|
1455
|
-
// Create conflict-marked content
|
|
1456
|
-
const conflictContent = [
|
|
1457
|
-
'<<<<<<< LOCAL (your changes)',
|
|
1458
|
-
localContent,
|
|
1459
|
-
'=======',
|
|
1460
|
-
remoteContent,
|
|
1461
|
-
'>>>>>>> REMOTE (server changes)'
|
|
1462
|
-
].join('\n')
|
|
1463
|
-
|
|
1464
|
-
// Write to local file
|
|
1465
|
-
const conflictPath = path.join(dir, c.path)
|
|
1466
|
-
await fs.promises.mkdir(path.dirname(conflictPath), { recursive: true })
|
|
1467
|
-
await fs.promises.writeFile(conflictPath, conflictContent, 'utf8')
|
|
1301
|
+
process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
|
|
1468
1302
|
}
|
|
1469
|
-
|
|
1470
|
-
if (opts.json === 'true') {
|
|
1471
|
-
print({ conflicts, message: 'Conflict markers written to files. Resolve them and try again.' }, true)
|
|
1472
|
-
} else {
|
|
1473
|
-
process.stderr.write(color('\nConflicts detected! The following files have been updated with conflict markers:\n', 'red'))
|
|
1474
|
-
for (const c of conflicts) {
|
|
1475
|
-
process.stderr.write(color(` ${c.path}\n`, 'yellow'))
|
|
1476
|
-
}
|
|
1477
|
-
process.stderr.write(color('\nTo resolve:\n', 'cyan'))
|
|
1478
|
-
process.stderr.write(color(' 1. Open the files above and look for conflict markers:\n', 'dim'))
|
|
1479
|
-
process.stderr.write(color(' <<<<<<< LOCAL (your changes)\n', 'dim'))
|
|
1480
|
-
process.stderr.write(color(' ... your version ...\n', 'dim'))
|
|
1481
|
-
process.stderr.write(color(' =======\n', 'dim'))
|
|
1482
|
-
process.stderr.write(color(' ... server version ...\n', 'dim'))
|
|
1483
|
-
process.stderr.write(color(' >>>>>>> REMOTE (server changes)\n', 'dim'))
|
|
1484
|
-
process.stderr.write(color(' 2. Edit the files to keep the changes you want\n', 'dim'))
|
|
1485
|
-
process.stderr.write(color(' 3. Remove the conflict markers\n', 'dim'))
|
|
1486
|
-
process.stderr.write(color(' 4. Run: resulgit add . && resulgit commit -m "Resolved conflicts" && resulgit push\n\n', 'dim'))
|
|
1487
|
-
}
|
|
1488
|
-
return
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
// Check if there are any files to push
|
|
1492
|
-
if (Object.keys(merged).length === 0) {
|
|
1493
|
-
spinnerFail(spinner, 'Nothing to push')
|
|
1494
|
-
if (opts.json === 'true') {
|
|
1495
|
-
print({ error: 'No files to push' }, true)
|
|
1496
|
-
} else {
|
|
1497
|
-
process.stdout.write('No files to push. Add some files first.\n')
|
|
1498
|
-
}
|
|
1499
|
-
return
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
// Execute pre-push hook
|
|
1503
|
-
spinnerUpdate(spinner, 'Running pre-push hook...')
|
|
1504
|
-
try {
|
|
1505
|
-
const hookResult = await hooks.executeHook(dir, 'pre-push', { branch: remoteMeta.branch, files: Object.keys(merged) })
|
|
1506
|
-
if (hookResult.executed && hookResult.exitCode !== 0) {
|
|
1507
|
-
spinnerFail(spinner, 'Pre-push hook failed')
|
|
1508
|
-
throw new Error('pre-push hook failed')
|
|
1509
|
-
}
|
|
1510
|
-
} catch (err) {
|
|
1511
|
-
if (err.message === 'pre-push hook failed') throw err
|
|
1512
|
-
// Hook doesn't exist or other error, continue
|
|
1513
|
-
}
|
|
1514
|
-
|
|
1515
|
-
// Step 1: Upload files as blobs
|
|
1516
|
-
spinnerUpdate(spinner, `Uploading ${Object.keys(merged).length} file(s)...`)
|
|
1517
|
-
let blobMap = {}
|
|
1518
|
-
try {
|
|
1519
|
-
blobMap = await uploadBlobs(server, remoteMeta.repoId, merged, token)
|
|
1520
|
-
} catch (err) {
|
|
1521
|
-
// If blob upload fails, fall back to sending content directly
|
|
1522
|
-
// (This handles servers that accept content in commits endpoint)
|
|
1523
|
-
spinnerUpdate(spinner, 'Blob upload not available, sending files directly...')
|
|
1524
|
-
blobMap = merged // Use content directly
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
// Step 2: Create commit with blob IDs (or content if blob upload failed)
|
|
1528
|
-
const commitFiles = {}
|
|
1529
|
-
for (const [filePath, content] of Object.entries(merged)) {
|
|
1530
|
-
// Use blob ID if available, otherwise use content
|
|
1531
|
-
commitFiles[filePath] = blobMap[filePath] || content
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
const body = {
|
|
1535
|
-
message: localMeta.pendingCommit?.message || (opts.message || 'Push'),
|
|
1536
|
-
files: commitFiles,
|
|
1537
|
-
branchName: remoteMeta.branch
|
|
1538
1303
|
}
|
|
1539
|
-
|
|
1540
|
-
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1541
|
-
const data = await request('POST', url, body, token)
|
|
1542
|
-
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1543
|
-
localMeta.baseFiles = merged
|
|
1544
|
-
localMeta.pendingCommit = null
|
|
1545
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1546
|
-
|
|
1547
|
-
// Execute post-push hook
|
|
1548
|
-
try {
|
|
1549
|
-
await hooks.executeHook(dir, 'post-push', { branch: remoteMeta.branch, commitId: localMeta.baseCommitId })
|
|
1550
|
-
} catch { }
|
|
1551
|
-
|
|
1552
|
-
spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
|
|
1553
|
-
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1554
|
-
} catch (err) {
|
|
1555
|
-
spinnerFail(spinner, 'Push failed')
|
|
1556
|
-
throw err
|
|
1304
|
+
return
|
|
1557
1305
|
}
|
|
1306
|
+
const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
|
|
1307
|
+
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1308
|
+
const data = await request('POST', url, body, token)
|
|
1309
|
+
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1310
|
+
localMeta.baseFiles = merged
|
|
1311
|
+
localMeta.pendingCommit = null
|
|
1312
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1313
|
+
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1558
1314
|
}
|
|
1559
1315
|
|
|
1560
1316
|
async function cmdMerge(opts) {
|
|
@@ -1896,20 +1652,7 @@ async function cmdBranch(sub, opts) {
|
|
|
1896
1652
|
if (m) current = m[1]
|
|
1897
1653
|
} catch { }
|
|
1898
1654
|
process.stdout.write(color('Branches:\n', 'bold'))
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
// Handle --sort option
|
|
1902
|
-
if (opts.sort) {
|
|
1903
|
-
const sortBy = opts.sort.replace(/^-/, '') // Remove leading dash
|
|
1904
|
-
if (sortBy === 'committerdate' || sortBy === '-committerdate') {
|
|
1905
|
-
list.sort((a, b) => {
|
|
1906
|
-
const dateA = new Date(a.date || 0).getTime()
|
|
1907
|
-
const dateB = new Date(b.date || 0).getTime()
|
|
1908
|
-
return sortBy.startsWith('-') ? dateB - dateA : dateA - dateB
|
|
1909
|
-
})
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1655
|
+
const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
|
|
1913
1656
|
for (const b of list) {
|
|
1914
1657
|
const isCur = b.name === current
|
|
1915
1658
|
const mark = isCur ? color('*', 'green') : ' '
|
|
@@ -1933,17 +1676,12 @@ async function cmdBranch(sub, opts) {
|
|
|
1933
1676
|
if (sub === 'delete') {
|
|
1934
1677
|
const name = opts.name
|
|
1935
1678
|
if (!name) throw new Error('Missing --name')
|
|
1936
|
-
const force = opts.force === 'true' || opts.D === 'true'
|
|
1937
1679
|
const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1938
1680
|
u.searchParams.set('name', name)
|
|
1939
|
-
u.searchParams.set('force', force ? 'true' : 'false')
|
|
1940
1681
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1941
1682
|
const res = await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1942
1683
|
if (!res.ok) {
|
|
1943
1684
|
const body = await res.text().catch(() => '')
|
|
1944
|
-
if (force) {
|
|
1945
|
-
throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
|
|
1946
|
-
}
|
|
1947
1685
|
throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
|
|
1948
1686
|
}
|
|
1949
1687
|
const data = await res.json()
|
|
@@ -1972,24 +1710,10 @@ async function cmdSwitch(opts) {
|
|
|
1972
1710
|
const dir = path.resolve(opts.dir || '.')
|
|
1973
1711
|
const meta = readRemoteMeta(dir)
|
|
1974
1712
|
const branch = opts.branch
|
|
1975
|
-
const create = opts.create === 'true' || opts.c === 'true'
|
|
1976
1713
|
if (!branch) throw new Error('Missing --branch')
|
|
1977
1714
|
const cfg = loadConfig()
|
|
1978
1715
|
const server = getServer(opts, cfg) || meta.server
|
|
1979
1716
|
const token = getToken(opts, cfg) || meta.token
|
|
1980
|
-
|
|
1981
|
-
// Handle -c flag (create and switch)
|
|
1982
|
-
if (create) {
|
|
1983
|
-
// Check if branch exists
|
|
1984
|
-
const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1985
|
-
const branchesData = await request('GET', branchesUrl.toString(), null, token)
|
|
1986
|
-
const exists = (branchesData.branches || []).some(b => b.name === branch)
|
|
1987
|
-
if (!exists) {
|
|
1988
|
-
// Create the branch
|
|
1989
|
-
await cmdBranch('create', { dir, name: branch, base: meta.branch, repo: meta.repoId, server, token })
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
1717
|
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1994
1718
|
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1995
1719
|
}
|
|
@@ -2344,28 +2068,10 @@ async function cmdReset(opts) {
|
|
|
2344
2068
|
const server = getServer(opts, cfg) || meta.server
|
|
2345
2069
|
const token = getToken(opts, cfg) || meta.token
|
|
2346
2070
|
|
|
2347
|
-
|
|
2071
|
+
const commitId = opts.commit || 'HEAD'
|
|
2348
2072
|
const mode = opts.mode || 'mixed' // soft, mixed, hard
|
|
2349
2073
|
const filePath = opts.path
|
|
2350
2074
|
|
|
2351
|
-
// Handle HEAD^ syntax (parent commit)
|
|
2352
|
-
if (commitId === 'HEAD^' || commitId.endsWith('^')) {
|
|
2353
|
-
const baseCommit = commitId.replace(/\^+$/, '')
|
|
2354
|
-
const targetCommit = baseCommit === 'HEAD' ? 'HEAD' : baseCommit
|
|
2355
|
-
let targetSnap
|
|
2356
|
-
if (targetCommit === 'HEAD') {
|
|
2357
|
-
targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
2358
|
-
} else {
|
|
2359
|
-
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, targetCommit, token)
|
|
2360
|
-
}
|
|
2361
|
-
const commit = await fetchCommitMeta(server, meta.repoId, targetSnap.commitId, token)
|
|
2362
|
-
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
2363
|
-
if (!parentId) {
|
|
2364
|
-
throw new Error('No parent commit found')
|
|
2365
|
-
}
|
|
2366
|
-
commitId = parentId
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
2075
|
if (filePath) {
|
|
2370
2076
|
// Reset specific file (unstage)
|
|
2371
2077
|
const metaDir = path.join(dir, '.vcs-next')
|
|
@@ -2436,78 +2142,14 @@ async function cmdReset(opts) {
|
|
|
2436
2142
|
}
|
|
2437
2143
|
|
|
2438
2144
|
async function cmdInit(opts) {
|
|
2145
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2439
2146
|
const cfg = loadConfig()
|
|
2440
2147
|
const server = getServer(opts, cfg)
|
|
2441
2148
|
const token = getToken(opts, cfg)
|
|
2442
|
-
|
|
2443
|
-
const
|
|
2444
|
-
|
|
2445
|
-
const
|
|
2446
|
-
|
|
2447
|
-
// Determine target directory:
|
|
2448
|
-
// - If --dir is specified, use that
|
|
2449
|
-
// - Otherwise, if repo name is provided, create folder with that name
|
|
2450
|
-
// - Fallback to current directory
|
|
2451
|
-
let targetDir
|
|
2452
|
-
if (opts.dir) {
|
|
2453
|
-
targetDir = path.resolve(opts.dir)
|
|
2454
|
-
} else if (repo) {
|
|
2455
|
-
targetDir = path.resolve(repo)
|
|
2456
|
-
} else {
|
|
2457
|
-
targetDir = path.resolve('.')
|
|
2458
|
-
}
|
|
2459
|
-
|
|
2460
|
-
// Create the target directory if it doesn't exist
|
|
2461
|
-
await fs.promises.mkdir(targetDir, { recursive: true })
|
|
2462
|
-
|
|
2463
|
-
const spinner = createSpinner(`Initializing repository${repo ? ` '${repo}'` : ''}...`, opts.json)
|
|
2464
|
-
|
|
2465
|
-
let repoId = repo
|
|
2466
|
-
let remoteCreated = false
|
|
2467
|
-
|
|
2468
|
-
// If a repo name is provided and we have server, try to create remote repo
|
|
2469
|
-
if (repo && server) {
|
|
2470
|
-
if (!token) {
|
|
2471
|
-
spinnerUpdate(spinner, 'No auth token set. Run "resulgit auth login" first for remote repo creation.')
|
|
2472
|
-
}
|
|
2473
|
-
try {
|
|
2474
|
-
spinnerUpdate(spinner, 'Creating remote repository...')
|
|
2475
|
-
const createUrl = new URL('/api/repositories', server).toString()
|
|
2476
|
-
const createRes = await request('POST', createUrl, {
|
|
2477
|
-
name: repo,
|
|
2478
|
-
description: opts.description || '',
|
|
2479
|
-
visibility: opts.visibility || 'private',
|
|
2480
|
-
initializeWithReadme: true
|
|
2481
|
-
}, token)
|
|
2482
|
-
// Use the ID returned by the server (could be numeric or string)
|
|
2483
|
-
repoId = String(createRes.id || repo)
|
|
2484
|
-
remoteCreated = true
|
|
2485
|
-
spinnerUpdate(spinner, `Remote repository '${repo}' created (ID: ${repoId})`)
|
|
2486
|
-
} catch (err) {
|
|
2487
|
-
// If repo already exists (409), try to fetch its ID
|
|
2488
|
-
if (err.message && (err.message.includes('409') || err.message.includes('already exists'))) {
|
|
2489
|
-
spinnerUpdate(spinner, `Remote repository '${repo}' already exists, linking...`)
|
|
2490
|
-
try {
|
|
2491
|
-
const listUrl = new URL('/api/repositories', server).toString()
|
|
2492
|
-
const repos = await request('GET', listUrl, null, token)
|
|
2493
|
-
const found = (repos || []).find(r => r.name === repo)
|
|
2494
|
-
if (found) {
|
|
2495
|
-
repoId = String(found.id)
|
|
2496
|
-
remoteCreated = true
|
|
2497
|
-
}
|
|
2498
|
-
} catch { /* ignore */ }
|
|
2499
|
-
} else if (err.message && err.message.includes('401')) {
|
|
2500
|
-
spinnerUpdate(spinner, 'Authentication required. Run "resulgit auth login" to set up credentials.')
|
|
2501
|
-
} else {
|
|
2502
|
-
// Other error - continue with local init only
|
|
2503
|
-
spinnerUpdate(spinner, `Could not create remote repo: ${err.message}`)
|
|
2504
|
-
}
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2507
|
-
|
|
2508
|
-
spinnerUpdate(spinner, 'Setting up local repository...')
|
|
2509
|
-
const metaDir = path.join(targetDir, '.vcs-next')
|
|
2510
|
-
const gitDir = path.join(targetDir, '.git')
|
|
2149
|
+
const repo = opts.repo || ''
|
|
2150
|
+
const branch = opts.branch || 'main'
|
|
2151
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2152
|
+
const gitDir = path.join(dir, '.git')
|
|
2511
2153
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2512
2154
|
await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
|
|
2513
2155
|
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
|
|
@@ -2521,47 +2163,16 @@ async function cmdInit(opts) {
|
|
|
2521
2163
|
'',
|
|
2522
2164
|
'[vcs-next]',
|
|
2523
2165
|
`\tserver = ${opts.server || server || ''}`,
|
|
2524
|
-
`\trepoId = ${
|
|
2166
|
+
`\trepoId = ${repo}`,
|
|
2525
2167
|
`\tbranch = ${branch}`,
|
|
2526
2168
|
`\ttoken = ${opts.token || token || ''}`
|
|
2527
2169
|
].join('\n')
|
|
2528
2170
|
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
2529
|
-
|
|
2530
|
-
let remoteCommitId = ''
|
|
2531
|
-
let baseFiles = {}
|
|
2532
|
-
|
|
2533
|
-
// If remote was created, fetch the initial snapshot to sync baseFiles
|
|
2534
|
-
if (remoteCreated && repoId && server) {
|
|
2535
|
-
try {
|
|
2536
|
-
spinnerUpdate(spinner, 'Syncing with remote...')
|
|
2537
|
-
const snapshot = await fetchRemoteSnapshot(server, repoId, branch, token)
|
|
2538
|
-
remoteCommitId = snapshot.commitId || ''
|
|
2539
|
-
baseFiles = snapshot.files || {}
|
|
2540
|
-
|
|
2541
|
-
// Write the remote files to the local directory (like a clone)
|
|
2542
|
-
for (const [filePath, content] of Object.entries(baseFiles)) {
|
|
2543
|
-
const fullPath = path.join(targetDir, filePath)
|
|
2544
|
-
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
2545
|
-
await fs.promises.writeFile(fullPath, String(content), 'utf8')
|
|
2546
|
-
}
|
|
2547
|
-
} catch (err) {
|
|
2548
|
-
// Ignore sync errors - the repo might be truly empty
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2551
|
-
|
|
2552
|
-
const remoteMeta = { repoId: repoId, branch, commitId: remoteCommitId, server: opts.server || server || '', token: opts.token || token || '' }
|
|
2171
|
+
const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
|
|
2553
2172
|
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
|
|
2554
|
-
const localMeta = { baseCommitId:
|
|
2173
|
+
const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
2555
2174
|
await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
|
|
2556
|
-
|
|
2557
|
-
if (remoteCreated) {
|
|
2558
|
-
spinnerSuccess(spinner, `Initialized repository '${repo}' in ${targetDir} (remote linked)`)
|
|
2559
|
-
} else if (repo) {
|
|
2560
|
-
spinnerSuccess(spinner, `Initialized local repository in ${targetDir} (remote not created - check auth)`)
|
|
2561
|
-
} else {
|
|
2562
|
-
spinnerSuccess(spinner, `Initialized repository in ${targetDir}`)
|
|
2563
|
-
}
|
|
2564
|
-
print({ initialized: targetDir, branch, repoId: repoId, remoteCreated }, opts.json === 'true')
|
|
2175
|
+
print({ initialized: dir, branch }, opts.json === 'true')
|
|
2565
2176
|
}
|
|
2566
2177
|
|
|
2567
2178
|
async function cmdMv(opts) {
|
|
@@ -2831,400 +2442,6 @@ async function cmdRebase(opts) {
|
|
|
2831
2442
|
print({ rebased: sourceBranch, onto }, opts.json === 'true')
|
|
2832
2443
|
}
|
|
2833
2444
|
}
|
|
2834
|
-
|
|
2835
|
-
async function cmdBlame(opts) {
|
|
2836
|
-
const dir = path.resolve(opts.dir || '.')
|
|
2837
|
-
const filePath = opts.path
|
|
2838
|
-
if (!filePath) throw new errors.ValidationError('Missing --path', 'path')
|
|
2839
|
-
|
|
2840
|
-
const validPath = validation.validateFilePath(filePath)
|
|
2841
|
-
const meta = readRemoteMeta(dir)
|
|
2842
|
-
const cfg = loadConfig()
|
|
2843
|
-
const server = getServer(opts, cfg) || meta.server
|
|
2844
|
-
const token = getToken(opts, cfg) || meta.token
|
|
2845
|
-
|
|
2846
|
-
const spinner = createSpinner(`Getting blame for ${validPath}...`, opts.json)
|
|
2847
|
-
|
|
2848
|
-
try {
|
|
2849
|
-
// Get file content
|
|
2850
|
-
const local = await collectLocal(dir)
|
|
2851
|
-
if (!local[validPath]) {
|
|
2852
|
-
throw new errors.FileSystemError(`File not found: ${validPath}`, validPath, 'read')
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
// Get commits
|
|
2856
|
-
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2857
|
-
commitsUrl.searchParams.set('branch', meta.branch)
|
|
2858
|
-
const commitsRes = await fetch(commitsUrl.toString(), {
|
|
2859
|
-
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
2860
|
-
})
|
|
2861
|
-
|
|
2862
|
-
if (!commitsRes.ok) {
|
|
2863
|
-
throw new errors.NetworkError('Failed to fetch commits', commitsRes.status, commitsUrl.toString())
|
|
2864
|
-
}
|
|
2865
|
-
|
|
2866
|
-
const commits = await commitsRes.json()
|
|
2867
|
-
const blameData = parseBlame(local[validPath].content, commits, validPath)
|
|
2868
|
-
|
|
2869
|
-
spinnerSuccess(spinner, `Blame for ${validPath}`)
|
|
2870
|
-
|
|
2871
|
-
if (opts.json === 'true') {
|
|
2872
|
-
print(formatBlameJson(blameData), false)
|
|
2873
|
-
} else {
|
|
2874
|
-
process.stdout.write(formatBlameOutput(blameData) + '\n')
|
|
2875
|
-
}
|
|
2876
|
-
} catch (err) {
|
|
2877
|
-
spinnerFail(spinner, 'Blame failed')
|
|
2878
|
-
throw err
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
async function cmdLog(opts) {
|
|
2883
|
-
const dir = path.resolve(opts.dir || '.')
|
|
2884
|
-
const meta = readRemoteMeta(dir)
|
|
2885
|
-
const cfg = loadConfig()
|
|
2886
|
-
const server = getServer(opts, cfg) || meta.server
|
|
2887
|
-
const token = getToken(opts, cfg) || meta.token
|
|
2888
|
-
|
|
2889
|
-
const spinner = createSpinner('Fetching commit history...', opts.json)
|
|
2890
|
-
|
|
2891
|
-
try {
|
|
2892
|
-
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2893
|
-
if (opts.branch) url.searchParams.set('branch', opts.branch)
|
|
2894
|
-
else url.searchParams.set('branch', meta.branch)
|
|
2895
|
-
|
|
2896
|
-
const data = await request('GET', url.toString(), null, token)
|
|
2897
|
-
let commits = Array.isArray(data) ? data : []
|
|
2898
|
-
|
|
2899
|
-
// Filter by file path if provided
|
|
2900
|
-
const filePath = opts.path
|
|
2901
|
-
if (filePath) {
|
|
2902
|
-
const filteredCommits = []
|
|
2903
|
-
for (const commit of commits) {
|
|
2904
|
-
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
|
|
2905
|
-
if (commitSnap.files[filePath] !== undefined) {
|
|
2906
|
-
filteredCommits.push(commit)
|
|
2907
|
-
}
|
|
2908
|
-
}
|
|
2909
|
-
commits = filteredCommits
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
// Filter by content pattern (-G flag)
|
|
2913
|
-
const pattern = opts.G || opts.pattern
|
|
2914
|
-
if (pattern) {
|
|
2915
|
-
const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
|
|
2916
|
-
const filteredCommits = []
|
|
2917
|
-
for (const commit of commits) {
|
|
2918
|
-
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
|
|
2919
|
-
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
2920
|
-
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {} }
|
|
2921
|
-
|
|
2922
|
-
// Check if pattern matches in any file changed in this commit
|
|
2923
|
-
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
2924
|
-
let matches = false
|
|
2925
|
-
for (const p of allPaths) {
|
|
2926
|
-
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : ''
|
|
2927
|
-
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : ''
|
|
2928
|
-
if (regex.test(oldContent) || regex.test(newContent)) {
|
|
2929
|
-
matches = true
|
|
2930
|
-
break
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
if (matches) {
|
|
2934
|
-
filteredCommits.push(commit)
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
2937
|
-
commits = filteredCommits
|
|
2938
|
-
}
|
|
2939
|
-
|
|
2940
|
-
spinnerSuccess(spinner, `Found ${commits.length} commits`)
|
|
2941
|
-
|
|
2942
|
-
if (opts.json === 'true') {
|
|
2943
|
-
print(commits, true)
|
|
2944
|
-
} else if (opts.oneline === 'true') {
|
|
2945
|
-
process.stdout.write(formatCompactLog(commits) + '\n')
|
|
2946
|
-
} else if (opts.stats === 'true') {
|
|
2947
|
-
const stats = generateCommitStats(commits)
|
|
2948
|
-
process.stdout.write(color('Commit Statistics\n', 'bold'))
|
|
2949
|
-
process.stdout.write(color('━'.repeat(50) + '\n', 'dim'))
|
|
2950
|
-
process.stdout.write(`Total commits: ${stats.totalCommits}\n`)
|
|
2951
|
-
process.stdout.write(`\nTop authors:\n`)
|
|
2952
|
-
for (const author of stats.topAuthors) {
|
|
2953
|
-
process.stdout.write(` ${author.name.padEnd(30)} ${color(author.commits + ' commits', 'cyan')}\n`)
|
|
2954
|
-
}
|
|
2955
|
-
if (stats.datesRange.earliest && stats.datesRange.latest) {
|
|
2956
|
-
process.stdout.write(`\nDate range: ${stats.datesRange.earliest.toISOString().split('T')[0]} to ${stats.datesRange.latest.toISOString().split('T')[0]}\n`)
|
|
2957
|
-
}
|
|
2958
|
-
} else {
|
|
2959
|
-
const maxCommits = parseInt(opts.max || '50', 10)
|
|
2960
|
-
process.stdout.write(generateLogGraph(commits, { maxCommits }) + '\n')
|
|
2961
|
-
}
|
|
2962
|
-
} catch (err) {
|
|
2963
|
-
spinnerFail(spinner, 'Log fetch failed')
|
|
2964
|
-
throw err
|
|
2965
|
-
}
|
|
2966
|
-
}
|
|
2967
|
-
|
|
2968
|
-
async function cmdHook(sub, opts) {
|
|
2969
|
-
const dir = path.resolve(opts.dir || '.')
|
|
2970
|
-
|
|
2971
|
-
if (sub === 'list') {
|
|
2972
|
-
const hooksList = await hooks.listHooks(dir)
|
|
2973
|
-
if (opts.json === 'true') {
|
|
2974
|
-
print(hooksList, true)
|
|
2975
|
-
} else {
|
|
2976
|
-
if (hooksList.length === 0) {
|
|
2977
|
-
process.stdout.write('No hooks installed.\n')
|
|
2978
|
-
} else {
|
|
2979
|
-
process.stdout.write(color('Installed Hooks:\n', 'bold'))
|
|
2980
|
-
for (const hook of hooksList) {
|
|
2981
|
-
const exe = hook.executable ? color('✓', 'green') : color('✗', 'red')
|
|
2982
|
-
process.stdout.write(` ${exe} ${color(hook.name, 'cyan')}\n`)
|
|
2983
|
-
}
|
|
2984
|
-
}
|
|
2985
|
-
}
|
|
2986
|
-
return
|
|
2987
|
-
}
|
|
2988
|
-
|
|
2989
|
-
if (sub === 'install') {
|
|
2990
|
-
const hookName = opts.name
|
|
2991
|
-
if (!hookName) throw new Error('Missing --name')
|
|
2992
|
-
|
|
2993
|
-
let script = opts.script
|
|
2994
|
-
if (!script && opts.sample === 'true') {
|
|
2995
|
-
script = hooks.SAMPLE_HOOKS[hookName]
|
|
2996
|
-
if (!script) throw new Error(`No sample available for ${hookName}`)
|
|
2997
|
-
}
|
|
2998
|
-
if (!script) throw new Error('Missing --script or use --sample')
|
|
2999
|
-
|
|
3000
|
-
const result = await hooks.installHook(dir, hookName, script)
|
|
3001
|
-
print(result, opts.json === 'true')
|
|
3002
|
-
if (opts.json !== 'true') {
|
|
3003
|
-
process.stdout.write(color(`✓ Hook '${hookName}' installed\n`, 'green'))
|
|
3004
|
-
}
|
|
3005
|
-
return
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
if (sub === 'remove') {
|
|
3009
|
-
const hookName = opts.name
|
|
3010
|
-
if (!hookName) throw new Error('Missing --name')
|
|
3011
|
-
|
|
3012
|
-
const result = await hooks.removeHook(dir, hookName)
|
|
3013
|
-
print(result, opts.json === 'true')
|
|
3014
|
-
return
|
|
3015
|
-
}
|
|
3016
|
-
|
|
3017
|
-
if (sub === 'show') {
|
|
3018
|
-
const hookName = opts.name
|
|
3019
|
-
if (!hookName) throw new Error('Missing --name')
|
|
3020
|
-
|
|
3021
|
-
const result = await hooks.readHook(dir, hookName)
|
|
3022
|
-
if (result.content) {
|
|
3023
|
-
process.stdout.write(result.content + '\n')
|
|
3024
|
-
} else {
|
|
3025
|
-
process.stdout.write(`Hook '${hookName}' not found.\n`)
|
|
3026
|
-
}
|
|
3027
|
-
return
|
|
3028
|
-
}
|
|
3029
|
-
|
|
3030
|
-
throw new Error('Unknown hook subcommand. Use: list, install, remove, show')
|
|
3031
|
-
}
|
|
3032
|
-
|
|
3033
|
-
async function cmdGrep(opts) {
|
|
3034
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3035
|
-
const pattern = opts.pattern || opts.p || ''
|
|
3036
|
-
if (!pattern) throw new Error('Missing --pattern or -p')
|
|
3037
|
-
|
|
3038
|
-
const meta = readRemoteMeta(dir)
|
|
3039
|
-
const local = await collectLocal(dir)
|
|
3040
|
-
const results = []
|
|
3041
|
-
|
|
3042
|
-
const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
|
|
3043
|
-
|
|
3044
|
-
for (const [filePath, fileData] of Object.entries(local)) {
|
|
3045
|
-
const lines = fileData.content.split(/\r?\n/)
|
|
3046
|
-
for (let i = 0; i < lines.length; i++) {
|
|
3047
|
-
if (regex.test(lines[i])) {
|
|
3048
|
-
results.push({
|
|
3049
|
-
path: filePath,
|
|
3050
|
-
line: i + 1,
|
|
3051
|
-
content: lines[i].trim()
|
|
3052
|
-
})
|
|
3053
|
-
}
|
|
3054
|
-
}
|
|
3055
|
-
}
|
|
3056
|
-
|
|
3057
|
-
if (opts.json === 'true') {
|
|
3058
|
-
print(results, true)
|
|
3059
|
-
} else {
|
|
3060
|
-
for (const result of results) {
|
|
3061
|
-
process.stdout.write(color(`${result.path}:${result.line}`, 'cyan'))
|
|
3062
|
-
process.stdout.write(`: ${result.content}\n`)
|
|
3063
|
-
}
|
|
3064
|
-
}
|
|
3065
|
-
}
|
|
3066
|
-
|
|
3067
|
-
async function cmdLsFiles(opts) {
|
|
3068
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3069
|
-
const meta = readRemoteMeta(dir)
|
|
3070
|
-
const cfg = loadConfig()
|
|
3071
|
-
const server = getServer(opts, cfg) || meta.server
|
|
3072
|
-
const token = getToken(opts, cfg) || meta.token
|
|
3073
|
-
|
|
3074
|
-
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
3075
|
-
const files = Object.keys(remote.map)
|
|
3076
|
-
|
|
3077
|
-
if (opts.json === 'true') {
|
|
3078
|
-
print(files.map(f => ({ path: f })), true)
|
|
3079
|
-
} else {
|
|
3080
|
-
for (const file of files) {
|
|
3081
|
-
process.stdout.write(`${file}\n`)
|
|
3082
|
-
}
|
|
3083
|
-
}
|
|
3084
|
-
}
|
|
3085
|
-
|
|
3086
|
-
async function cmdReflog(opts) {
|
|
3087
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3088
|
-
const metaDir = path.join(dir, '.vcs-next')
|
|
3089
|
-
const reflogPath = path.join(metaDir, 'reflog.json')
|
|
3090
|
-
|
|
3091
|
-
let reflog = []
|
|
3092
|
-
try {
|
|
3093
|
-
const content = await fs.promises.readFile(reflogPath, 'utf8')
|
|
3094
|
-
reflog = JSON.parse(content)
|
|
3095
|
-
} catch { }
|
|
3096
|
-
|
|
3097
|
-
if (opts.json === 'true') {
|
|
3098
|
-
print(reflog, true)
|
|
3099
|
-
} else {
|
|
3100
|
-
if (reflog.length === 0) {
|
|
3101
|
-
process.stdout.write('No reflog entries.\n')
|
|
3102
|
-
} else {
|
|
3103
|
-
for (const entry of reflog) {
|
|
3104
|
-
const date = new Date(entry.timestamp).toLocaleString()
|
|
3105
|
-
process.stdout.write(`${color(entry.commitId.slice(0, 7), 'yellow')} ${entry.action} ${date} ${entry.message || ''}\n`)
|
|
3106
|
-
}
|
|
3107
|
-
}
|
|
3108
|
-
}
|
|
3109
|
-
}
|
|
3110
|
-
|
|
3111
|
-
async function cmdCatFile(opts) {
|
|
3112
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3113
|
-
const type = opts.type || ''
|
|
3114
|
-
const object = opts.object || ''
|
|
3115
|
-
|
|
3116
|
-
if (!type || !object) throw new Error('Missing --type and --object')
|
|
3117
|
-
|
|
3118
|
-
const meta = readRemoteMeta(dir)
|
|
3119
|
-
const cfg = loadConfig()
|
|
3120
|
-
const server = getServer(opts, cfg) || meta.server
|
|
3121
|
-
const token = getToken(opts, cfg) || meta.token
|
|
3122
|
-
|
|
3123
|
-
if (type === 'blob') {
|
|
3124
|
-
const snap = await fetchSnapshotByCommit(server, meta.repoId, object, token)
|
|
3125
|
-
const filePath = opts.path || ''
|
|
3126
|
-
if (filePath && snap.files[filePath]) {
|
|
3127
|
-
process.stdout.write(String(snap.files[filePath]))
|
|
3128
|
-
} else {
|
|
3129
|
-
throw new Error('File not found in commit')
|
|
3130
|
-
}
|
|
3131
|
-
} else if (type === 'commit') {
|
|
3132
|
-
const commit = await fetchCommitMeta(server, meta.repoId, object, token)
|
|
3133
|
-
if (opts.json === 'true') {
|
|
3134
|
-
print(commit, true)
|
|
3135
|
-
} else {
|
|
3136
|
-
process.stdout.write(`commit ${commit.id || commit._id}\n`)
|
|
3137
|
-
process.stdout.write(`Author: ${commit.author?.name || ''} <${commit.author?.email || ''}>\n`)
|
|
3138
|
-
process.stdout.write(`Date: ${new Date(commit.createdAt || commit.committer?.date || '').toLocaleString()}\n\n`)
|
|
3139
|
-
process.stdout.write(`${commit.message || ''}\n`)
|
|
3140
|
-
}
|
|
3141
|
-
} else {
|
|
3142
|
-
throw new Error(`Unsupported type: ${type}`)
|
|
3143
|
-
}
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
async function cmdRevParse(opts) {
|
|
3147
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3148
|
-
const rev = opts.rev || 'HEAD'
|
|
3149
|
-
|
|
3150
|
-
const meta = readRemoteMeta(dir)
|
|
3151
|
-
const cfg = loadConfig()
|
|
3152
|
-
const server = getServer(opts, cfg) || meta.server
|
|
3153
|
-
const token = getToken(opts, cfg) || meta.token
|
|
3154
|
-
|
|
3155
|
-
if (rev === 'HEAD') {
|
|
3156
|
-
const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
3157
|
-
const found = (info.branches || []).find(b => b.name === meta.branch)
|
|
3158
|
-
const commitId = found ? (found.commitId || '') : ''
|
|
3159
|
-
print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
|
|
3160
|
-
} else if (rev.startsWith('refs/heads/')) {
|
|
3161
|
-
const branchName = rev.replace('refs/heads/', '')
|
|
3162
|
-
const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
3163
|
-
const found = (info.branches || []).find(b => b.name === branchName)
|
|
3164
|
-
const commitId = found ? (found.commitId || '') : ''
|
|
3165
|
-
print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
|
|
3166
|
-
} else {
|
|
3167
|
-
// Assume it's a commit ID
|
|
3168
|
-
print(opts.json === 'true' ? { rev, commitId: rev } : rev, opts.json === 'true')
|
|
3169
|
-
}
|
|
3170
|
-
}
|
|
3171
|
-
|
|
3172
|
-
async function cmdDescribe(opts) {
|
|
3173
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3174
|
-
const commitId = opts.commit || 'HEAD'
|
|
3175
|
-
|
|
3176
|
-
const meta = readRemoteMeta(dir)
|
|
3177
|
-
const cfg = loadConfig()
|
|
3178
|
-
const server = getServer(opts, cfg) || meta.server
|
|
3179
|
-
const token = getToken(opts, cfg) || meta.token
|
|
3180
|
-
|
|
3181
|
-
// Get tags
|
|
3182
|
-
const tagsUrl = new URL(`/api/repositories/${meta.repoId}/tags`, server)
|
|
3183
|
-
const tags = await request('GET', tagsUrl.toString(), null, token)
|
|
3184
|
-
const tagsList = Array.isArray(tags) ? tags : []
|
|
3185
|
-
|
|
3186
|
-
// Find nearest tag (simplified - just find any tag)
|
|
3187
|
-
const nearestTag = tagsList[0]
|
|
3188
|
-
|
|
3189
|
-
if (nearestTag) {
|
|
3190
|
-
const desc = `${nearestTag.name}-0-g${commitId.slice(0, 7)}`
|
|
3191
|
-
print(opts.json === 'true' ? { tag: nearestTag.name, commitId, describe: desc } : desc, opts.json === 'true')
|
|
3192
|
-
} else {
|
|
3193
|
-
print(opts.json === 'true' ? { commitId, describe: commitId.slice(0, 7) } : commitId.slice(0, 7), opts.json === 'true')
|
|
3194
|
-
}
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
async function cmdShortlog(opts) {
|
|
3198
|
-
const dir = path.resolve(opts.dir || '.')
|
|
3199
|
-
const meta = readRemoteMeta(dir)
|
|
3200
|
-
const cfg = loadConfig()
|
|
3201
|
-
const server = getServer(opts, cfg) || meta.server
|
|
3202
|
-
const token = getToken(opts, cfg) || meta.token
|
|
3203
|
-
|
|
3204
|
-
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
3205
|
-
if (opts.branch) url.searchParams.set('branch', opts.branch)
|
|
3206
|
-
else url.searchParams.set('branch', meta.branch)
|
|
3207
|
-
|
|
3208
|
-
const commits = await request('GET', url.toString(), null, token)
|
|
3209
|
-
const commitsList = Array.isArray(commits) ? commits : []
|
|
3210
|
-
|
|
3211
|
-
const stats = generateCommitStats(commitsList)
|
|
3212
|
-
|
|
3213
|
-
if (opts.json === 'true') {
|
|
3214
|
-
print(stats, true)
|
|
3215
|
-
} else {
|
|
3216
|
-
for (const author of stats.topAuthors) {
|
|
3217
|
-
process.stdout.write(`\n${color(author.name, 'bold')} (${author.commits} commit${author.commits !== 1 ? 's' : ''})\n`)
|
|
3218
|
-
// Show commit messages for this author
|
|
3219
|
-
const authorCommits = commitsList.filter(c => (c.author?.name || 'Unknown') === author.name)
|
|
3220
|
-
for (const commit of authorCommits.slice(0, opts.max ? parseInt(opts.max, 10) : 10)) {
|
|
3221
|
-
const msg = (commit.message || 'No message').split('\n')[0].slice(0, 60)
|
|
3222
|
-
process.stdout.write(` ${color((commit.id || commit._id || '').slice(0, 7), 'yellow')} ${msg}\n`)
|
|
3223
|
-
}
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3226
|
-
}
|
|
3227
|
-
|
|
3228
2445
|
function help() {
|
|
3229
2446
|
const h = [
|
|
3230
2447
|
'Usage: resulgit <group> <command> [options]',
|
|
@@ -3239,37 +2456,26 @@ function help() {
|
|
|
3239
2456
|
' repo log --repo <id> [--branch <name>] [--json]',
|
|
3240
2457
|
' repo head --repo <id> [--branch <name>] [--json]',
|
|
3241
2458
|
' repo select [--workspace] (interactive select and clone/open)',
|
|
3242
|
-
' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]
|
|
3243
|
-
' switch --branch <name> [--
|
|
3244
|
-
' checkout --branch <name> [--create|-b] [--commit <id>] [--dir <path>]',
|
|
2459
|
+
' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
|
|
2460
|
+
' switch --branch <name> [--dir <path>]',
|
|
3245
2461
|
' current [--dir <path>] (show active repo/branch)',
|
|
3246
2462
|
' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
|
|
3247
2463
|
' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
|
|
3248
2464
|
' status [--dir <path>] [--json]',
|
|
3249
|
-
' diff [--dir <path>] [--path <file>] [--commit <id>] [--
|
|
3250
|
-
' commit --message <text> [--
|
|
2465
|
+
' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
|
|
2466
|
+
' commit --message <text> [--dir <path>] [--json]',
|
|
3251
2467
|
' push [--dir <path>] [--json]',
|
|
3252
2468
|
' head [--dir <path>] [--json]',
|
|
3253
|
-
' rm --path <file> [--
|
|
2469
|
+
' rm --path <file> [--dir <path>] [--json]',
|
|
3254
2470
|
' pull [--dir <path>]',
|
|
3255
2471
|
' fetch [--dir <path>]',
|
|
3256
2472
|
' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
|
|
3257
2473
|
' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
|
|
3258
2474
|
' restore --path <file> [--source <commit>] [--dir <path>]',
|
|
3259
2475
|
' revert --commit <id> [--no-push] [--dir <path>]',
|
|
3260
|
-
' reset [--commit <id
|
|
2476
|
+
' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
|
|
3261
2477
|
' show --commit <id> [--dir <path>] [--json]',
|
|
3262
2478
|
' mv --from <old> --to <new> [--dir <path>]',
|
|
3263
|
-
' blame --path <file> [--dir <path>] [--json] - Show line-by-line authorship',
|
|
3264
|
-
' log [--branch <name>] [--max <N>] [--oneline] [--stats] [--path <file>] [-G <pattern>] [--json] - Show commit history',
|
|
3265
|
-
' hook list|install|remove|show [--name <hook>] [--script <code>] [--sample] - Manage Git hooks',
|
|
3266
|
-
' grep --pattern <pattern> [--ignore-case] [--dir <path>] [--json] - Search in repository',
|
|
3267
|
-
' ls-files [--dir <path>] [--json] - List tracked files',
|
|
3268
|
-
' reflog [--dir <path>] [--json] - Show reference log',
|
|
3269
|
-
' cat-file --type <type> --object <id> [--path <file>] [--dir <path>] [--json] - Display file contents',
|
|
3270
|
-
' rev-parse --rev <ref> [--dir <path>] [--json] - Parse revision names',
|
|
3271
|
-
' describe [--commit <id>] [--dir <path>] [--json] - Describe a commit',
|
|
3272
|
-
' shortlog [--branch <name>] [--max <N>] [--dir <path>] [--json] - Summarize commit log',
|
|
3273
2479
|
'',
|
|
3274
2480
|
'Conflict Resolution:',
|
|
3275
2481
|
' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
|
|
@@ -3278,8 +2484,8 @@ function help() {
|
|
|
3278
2484
|
' >>>>>>> incoming (incoming changes)',
|
|
3279
2485
|
' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
|
|
3280
2486
|
' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
|
|
3281
|
-
' clone <url> [--dest <dir>] | --repo <
|
|
3282
|
-
' init [--dir <path>] [--repo <
|
|
2487
|
+
' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
|
|
2488
|
+
' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
|
|
3283
2489
|
' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
|
|
3284
2490
|
' config list|get|set [--key <k>] [--value <v>]',
|
|
3285
2491
|
' clean [--dir <path>] [--force]',
|
|
@@ -3312,20 +2518,13 @@ async function main() {
|
|
|
3312
2518
|
return
|
|
3313
2519
|
}
|
|
3314
2520
|
if (cmd[0] === 'clone') {
|
|
3315
|
-
|
|
3316
|
-
if (arg && !arg.startsWith('--')) {
|
|
3317
|
-
if (arg.includes('://')) {
|
|
3318
|
-
opts.url = arg;
|
|
3319
|
-
} else {
|
|
3320
|
-
opts.repo = arg;
|
|
3321
|
-
}
|
|
3322
|
-
}
|
|
2521
|
+
if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
|
|
3323
2522
|
if (opts.url) {
|
|
3324
|
-
await cmdCloneFromUrl(opts, cfg)
|
|
2523
|
+
await cmdCloneFromUrl(opts, cfg)
|
|
3325
2524
|
} else {
|
|
3326
|
-
await cmdClone(opts, cfg)
|
|
2525
|
+
await cmdClone(opts, cfg)
|
|
3327
2526
|
}
|
|
3328
|
-
return
|
|
2527
|
+
return
|
|
3329
2528
|
}
|
|
3330
2529
|
if (cmd[0] === 'status') {
|
|
3331
2530
|
await cmdStatus(opts)
|
|
@@ -3356,18 +2555,10 @@ async function main() {
|
|
|
3356
2555
|
return
|
|
3357
2556
|
}
|
|
3358
2557
|
if (cmd[0] === 'branch') {
|
|
3359
|
-
// Support positional: 'branch create DevBranch' or 'branch delete DevBranch'
|
|
3360
|
-
if ((cmd[1] === 'create' || cmd[1] === 'delete') && cmd[2] && !opts.name) {
|
|
3361
|
-
opts.name = cmd[2]
|
|
3362
|
-
}
|
|
3363
2558
|
await cmdBranch(cmd[1], opts)
|
|
3364
2559
|
return
|
|
3365
2560
|
}
|
|
3366
2561
|
if (cmd[0] === 'switch') {
|
|
3367
|
-
// Support positional: 'switch main' instead of 'switch --branch main'
|
|
3368
|
-
if (cmd[1] && !cmd[1].startsWith('-') && !opts.branch) {
|
|
3369
|
-
opts.branch = cmd[1]
|
|
3370
|
-
}
|
|
3371
2562
|
await cmdSwitch(opts)
|
|
3372
2563
|
return
|
|
3373
2564
|
}
|
|
@@ -3380,10 +2571,6 @@ async function main() {
|
|
|
3380
2571
|
return
|
|
3381
2572
|
}
|
|
3382
2573
|
if (cmd[0] === 'merge') {
|
|
3383
|
-
// Support positional: 'merge DevBranch' instead of 'merge --branch DevBranch'
|
|
3384
|
-
if (cmd[1] && !cmd[1].startsWith('-') && !opts.branch) {
|
|
3385
|
-
opts.branch = cmd[1]
|
|
3386
|
-
}
|
|
3387
2574
|
await cmdMerge(opts)
|
|
3388
2575
|
return
|
|
3389
2576
|
}
|
|
@@ -3412,12 +2599,8 @@ async function main() {
|
|
|
3412
2599
|
return
|
|
3413
2600
|
}
|
|
3414
2601
|
if (cmd[0] === 'init') {
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
opts.repo = cmd[1];
|
|
3418
|
-
}
|
|
3419
|
-
await cmdInit(opts);
|
|
3420
|
-
return;
|
|
2602
|
+
await cmdInit(opts)
|
|
2603
|
+
return
|
|
3421
2604
|
}
|
|
3422
2605
|
if (cmd[0] === 'remote') {
|
|
3423
2606
|
await cmdRemote(cmd[1], opts)
|
|
@@ -3467,46 +2650,6 @@ async function main() {
|
|
|
3467
2650
|
await cmdAdd(opts)
|
|
3468
2651
|
return
|
|
3469
2652
|
}
|
|
3470
|
-
if (cmd[0] === 'blame') {
|
|
3471
|
-
await cmdBlame(opts)
|
|
3472
|
-
return
|
|
3473
|
-
}
|
|
3474
|
-
if (cmd[0] === 'log') {
|
|
3475
|
-
await cmdLog(opts)
|
|
3476
|
-
return
|
|
3477
|
-
}
|
|
3478
|
-
if (cmd[0] === 'hook') {
|
|
3479
|
-
await cmdHook(cmd[1], opts)
|
|
3480
|
-
return
|
|
3481
|
-
}
|
|
3482
|
-
if (cmd[0] === 'grep') {
|
|
3483
|
-
await cmdGrep(opts)
|
|
3484
|
-
return
|
|
3485
|
-
}
|
|
3486
|
-
if (cmd[0] === 'ls-files') {
|
|
3487
|
-
await cmdLsFiles(opts)
|
|
3488
|
-
return
|
|
3489
|
-
}
|
|
3490
|
-
if (cmd[0] === 'reflog') {
|
|
3491
|
-
await cmdReflog(opts)
|
|
3492
|
-
return
|
|
3493
|
-
}
|
|
3494
|
-
if (cmd[0] === 'cat-file') {
|
|
3495
|
-
await cmdCatFile(opts)
|
|
3496
|
-
return
|
|
3497
|
-
}
|
|
3498
|
-
if (cmd[0] === 'rev-parse') {
|
|
3499
|
-
await cmdRevParse(opts)
|
|
3500
|
-
return
|
|
3501
|
-
}
|
|
3502
|
-
if (cmd[0] === 'describe') {
|
|
3503
|
-
await cmdDescribe(opts)
|
|
3504
|
-
return
|
|
3505
|
-
}
|
|
3506
|
-
if (cmd[0] === 'shortlog') {
|
|
3507
|
-
await cmdShortlog(opts)
|
|
3508
|
-
return
|
|
3509
|
-
}
|
|
3510
2653
|
throw new Error('Unknown command')
|
|
3511
2654
|
}
|
|
3512
2655
|
|
|
@@ -3621,6 +2764,7 @@ async function copyDir(src, dest) {
|
|
|
3621
2764
|
}
|
|
3622
2765
|
}
|
|
3623
2766
|
|
|
2767
|
+
|
|
3624
2768
|
async function tuiSelectBranch(branches, current) {
|
|
3625
2769
|
const items = (branches || []).map((b) => ({ name: b.name || '', commitId: b.commitId || '' })).filter((b) => b.name)
|
|
3626
2770
|
if (items.length === 0) throw new Error('No branches available')
|