resulgit 1.0.1 → 1.0.3

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.
Files changed (3) hide show
  1. package/README.md +40 -1
  2. package/package.json +14 -7
  3. package/resulgit.js +1440 -370
package/resulgit.js CHANGED
@@ -3,9 +3,32 @@ 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')
6
12
  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' }
7
13
  function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
8
14
 
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
+
9
32
  function parseArgs(argv) {
10
33
  const tokens = argv.slice(2)
11
34
  const cmd = []
@@ -106,11 +129,17 @@ async function cmdAuth(sub, opts) {
106
129
  const password = opts.password
107
130
  if (!email || !password) throw new Error('Missing --email and --password')
108
131
  const url = new URL('/api/auth/login', server).toString()
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')
132
+ const spinner = createSpinner('Logging in...', opts.json)
133
+ try {
134
+ const res = await request('POST', url, { email, password }, '')
135
+ const token = res.token || ''
136
+ if (token) saveConfig({ token })
137
+ spinnerSuccess(spinner, `Logged in as ${email}`)
138
+ print(res, opts.json === 'true')
139
+ } catch (err) {
140
+ spinnerFail(spinner, 'Login failed')
141
+ throw err
142
+ }
114
143
  return
115
144
  }
116
145
  if (sub === 'register') {
@@ -121,10 +150,17 @@ async function cmdAuth(sub, opts) {
121
150
  const displayName = opts.displayName || username
122
151
  if (!username || !email || !password) throw new Error('Missing --username --email --password')
123
152
  const url = new URL('/api/auth/register', server).toString()
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')
153
+ const spinner = createSpinner('Registering account...', opts.json)
154
+ try {
155
+ const res = await request('POST', url, { username, email, password, displayName }, '')
156
+ const token = res.token || ''
157
+ if (token) saveConfig({ token })
158
+ spinnerSuccess(spinner, `Registered as ${username}`)
159
+ print(res, opts.json === 'true')
160
+ } catch (err) {
161
+ spinnerFail(spinner, 'Registration failed')
162
+ throw err
163
+ }
128
164
  return
129
165
  }
130
166
  throw new Error('Unknown auth subcommand')
@@ -134,10 +170,17 @@ async function cmdRepo(sub, opts, cfg) {
134
170
  const server = getServer(opts, cfg)
135
171
  const token = getToken(opts, cfg)
136
172
  if (sub === 'list') {
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)
173
+ const spinner = createSpinner('Fetching repositories...', opts.json)
174
+ try {
175
+ const url = new URL('/api/repositories', server).toString()
176
+ const data = await request('GET', url, null, token)
177
+ spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
178
+ print(data, opts.json === 'true')
179
+ await tuiSelectRepo(data || [], cfg, opts)
180
+ } catch (err) {
181
+ spinnerFail(spinner, 'Failed to fetch repositories')
182
+ throw err
183
+ }
141
184
  return
142
185
  }
143
186
  if (sub === 'create') {
@@ -148,14 +191,21 @@ async function cmdRepo(sub, opts, cfg) {
148
191
  initializeWithReadme: opts.init === 'true'
149
192
  }
150
193
  if (!body.name) throw new Error('Missing --name')
151
- const url = new URL('/api/repositories', server).toString()
152
- const data = await request('POST', url, body, token)
153
- print(data, opts.json === 'true')
194
+ const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
154
195
  try {
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 {}
196
+ const url = new URL('/api/repositories', server).toString()
197
+ const data = await request('POST', url, body, token)
198
+ spinnerSuccess(spinner, `Repository '${body.name}' created`)
199
+ print(data, opts.json === 'true')
200
+ try {
201
+ const repoId = String(data.id || '')
202
+ const branch = String(data.defaultBranch || 'main')
203
+ if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
204
+ } catch { }
205
+ } catch (err) {
206
+ spinnerFail(spinner, `Failed to create repository '${body.name}'`)
207
+ throw err
208
+ }
159
209
  return
160
210
  }
161
211
  if (sub === 'log') {
@@ -224,91 +274,105 @@ async function cmdClone(opts, cfg) {
224
274
  const branch = opts.branch
225
275
  let dest = opts.dest
226
276
  if (!repo || !branch) throw new Error('Missing --repo and --branch')
277
+ const spinner = createSpinner('Initializing clone...', opts.json)
227
278
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
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 {
279
+ try {
280
+ if (!dest) {
281
+ spinnerUpdate(spinner, 'Fetching repository info...')
282
+ try {
283
+ const infoUrl = new URL(`/api/repositories/${repo}`, server)
284
+ const infoRes = await fetch(infoUrl.toString(), { headers })
285
+ if (infoRes.ok) {
286
+ const info = await infoRes.json()
287
+ const name = info.name || String(repo)
288
+ dest = name
289
+ } else {
290
+ dest = String(repo)
291
+ }
292
+ } catch {
237
293
  dest = String(repo)
238
294
  }
239
- } catch {
240
- dest = String(repo)
241
295
  }
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))
296
+ dest = path.resolve(dest)
297
+ spinnerUpdate(spinner, `Downloading snapshot from branch '${branch}'...`)
298
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
299
+ url.searchParams.set('branch', branch)
300
+ const res = await fetch(url.toString(), { headers })
301
+ if (!res.ok) {
302
+ const text = await res.text()
303
+ throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
300
304
  }
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))
305
+ const data = await res.json()
306
+ const files = data.files || {}
307
+ const fileCount = Object.keys(files).length
308
+ spinnerUpdate(spinner, `Writing ${fileCount} files to ${dest}...`)
309
+ const root = dest
310
+ for (const [p, content] of Object.entries(files)) {
311
+ const fullPath = path.join(root, p)
312
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
313
+ await fs.promises.writeFile(fullPath, content, 'utf8')
309
314
  }
310
- } catch {}
311
- print('Clone complete', opts.json === 'true')
315
+ spinnerUpdate(spinner, 'Setting up repository metadata...')
316
+ const metaDir = path.join(root, '.vcs-next')
317
+ await fs.promises.mkdir(metaDir, { recursive: true })
318
+ const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
319
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
320
+ const gitDir = path.join(root, '.git')
321
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
322
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
323
+ const headContent = `ref: refs/heads/${branch}\\n`
324
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
325
+ const commitId = data.commitId || ''
326
+ await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
327
+ const gitConfig = [
328
+ '[core]',
329
+ '\\trepositoryformatversion = 0',
330
+ '\\tfilemode = true',
331
+ '\\tbare = false',
332
+ '\\tlogallrefupdates = true',
333
+ '',
334
+ '[vcs-next]',
335
+ `\\tserver = ${server}`,
336
+ `\\trepoId = ${repo}`,
337
+ `\\tbranch = ${branch}`,
338
+ `\\ttoken = ${token || ''}`
339
+ ].join('\\n')
340
+ await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
341
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
342
+ spinnerUpdate(spinner, 'Fetching branch information...')
343
+ try {
344
+ const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
345
+ const branchesRes = await fetch(branchesUrl.toString(), { headers })
346
+ if (branchesRes.ok) {
347
+ const branchesData = await branchesRes.json()
348
+ const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
349
+ const allRefs = {}
350
+ for (const b of branches) {
351
+ const name = b.name || ''
352
+ const id = b.commitId || ''
353
+ if (!name) continue
354
+ allRefs[name] = id
355
+ await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
356
+ }
357
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
358
+ }
359
+ } catch { }
360
+ spinnerUpdate(spinner, 'Fetching commit history...')
361
+ try {
362
+ const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
363
+ commitsUrl.searchParams.set('branch', branch)
364
+ const commitsRes = await fetch(commitsUrl.toString(), { headers })
365
+ if (commitsRes.ok) {
366
+ const commitsList = await commitsRes.json()
367
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
368
+ }
369
+ } catch { }
370
+ spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
371
+ print('Clone complete', opts.json === 'true')
372
+ } catch (err) {
373
+ spinnerFail(spinner, 'Clone failed')
374
+ throw err
375
+ }
312
376
  }
313
377
 
314
378
  function readRemoteMeta(dir) {
@@ -324,7 +388,7 @@ function readRemoteMeta(dir) {
324
388
  const s = fs.readFileSync(fp, 'utf8')
325
389
  const meta = JSON.parse(s)
326
390
  if (meta.repoId && meta.branch) return meta
327
- } catch {}
391
+ } catch { }
328
392
  }
329
393
  const parent = path.dirname(cur)
330
394
  if (parent === cur) break
@@ -421,7 +485,7 @@ async function cmdRestore(opts) {
421
485
  const cfg = loadConfig()
422
486
  const server = getServer(opts, cfg) || meta.server
423
487
  const token = getToken(opts, cfg) || meta.token
424
-
488
+
425
489
  const sourceCommit = opts.source || 'HEAD'
426
490
  let sourceSnap
427
491
  if (sourceCommit === 'HEAD') {
@@ -429,7 +493,7 @@ async function cmdRestore(opts) {
429
493
  } else {
430
494
  sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
431
495
  }
432
-
496
+
433
497
  const sourceContent = sourceSnap.files[filePath]
434
498
  if (sourceContent === undefined) {
435
499
  // File doesn't exist in source - delete it
@@ -464,18 +528,119 @@ async function cmdDiff(opts) {
464
528
  const cfg = loadConfig()
465
529
  const server = getServer(opts, cfg) || meta.server
466
530
  const token = getToken(opts, cfg) || meta.token
467
-
531
+
468
532
  const filePath = opts.path
469
533
  const commitId = opts.commit
470
-
534
+ const commit1 = opts.commit1
535
+ const commit2 = opts.commit2
536
+ const showStat = opts.stat === 'true'
537
+
538
+ // Handle diff between two commits: git diff <commit1> <commit2>
539
+ if (commit1 && commit2) {
540
+ const snap1 = await fetchSnapshotByCommit(server, meta.repoId, commit1, token)
541
+ const snap2 = await fetchSnapshotByCommit(server, meta.repoId, commit2, token)
542
+ const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(snap1.files), ...Object.keys(snap2.files)]))
543
+
544
+ let added = 0, deleted = 0, modified = 0
545
+ const stats = []
546
+
547
+ for (const p of files) {
548
+ const content1 = snap1.files[p] !== undefined ? String(snap1.files[p]) : null
549
+ const content2 = snap2.files[p] !== undefined ? String(snap2.files[p]) : null
550
+ if (content1 !== content2) {
551
+ if (content1 === null) {
552
+ added++
553
+ if (showStat) stats.push({ path: p, added: content2.split(/\r?\n/).length, deleted: 0 })
554
+ } else if (content2 === null) {
555
+ deleted++
556
+ if (showStat) stats.push({ path: p, added: 0, deleted: content1.split(/\r?\n/).length })
557
+ } else {
558
+ modified++
559
+ const lines1 = content1.split(/\r?\n/)
560
+ const lines2 = content2.split(/\r?\n/)
561
+ const diff = Math.abs(lines2.length - lines1.length)
562
+ if (showStat) stats.push({ path: p, added: lines2.length > lines1.length ? diff : 0, deleted: lines1.length > lines2.length ? diff : 0 })
563
+ }
564
+
565
+ if (!showStat) {
566
+ if (opts.json === 'true') {
567
+ print({ path: p, old: content1, new: content2 }, true)
568
+ } else {
569
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
570
+ // Show full diff (same as before)
571
+ const oldLines = content1 ? content1.split(/\r?\n/) : []
572
+ const newLines = content2 ? content2.split(/\r?\n/) : []
573
+ const maxLen = Math.max(oldLines.length, newLines.length)
574
+ for (let i = 0; i < maxLen; i++) {
575
+ const oldLine = oldLines[i]
576
+ const newLine = newLines[i]
577
+ if (oldLine !== newLine) {
578
+ if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
579
+ if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
580
+ } else if (oldLine !== undefined) {
581
+ process.stdout.write(` ${oldLine}\n`)
582
+ }
583
+ }
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ if (showStat) {
590
+ if (opts.json === 'true') {
591
+ print({ added, deleted, modified, files: stats }, true)
592
+ } else {
593
+ for (const stat of stats) {
594
+ process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
595
+ }
596
+ process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
597
+ }
598
+ }
599
+ return
600
+ }
601
+
471
602
  if (commitId) {
472
603
  // Show diff for specific commit
473
604
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
474
605
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
475
606
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
476
607
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
477
-
608
+
478
609
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
610
+
611
+ if (showStat) {
612
+ let added = 0, deleted = 0, modified = 0
613
+ const stats = []
614
+ for (const p of files) {
615
+ const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
616
+ const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
617
+ if (oldContent !== newContent) {
618
+ if (oldContent === null) {
619
+ added++
620
+ if (showStat) stats.push({ path: p, added: newContent.split(/\r?\n/).length, deleted: 0 })
621
+ } else if (newContent === null) {
622
+ deleted++
623
+ if (showStat) stats.push({ path: p, added: 0, deleted: oldContent.split(/\r?\n/).length })
624
+ } else {
625
+ modified++
626
+ const oldLines = oldContent.split(/\r?\n/)
627
+ const newLines = newContent.split(/\r?\n/)
628
+ const diff = Math.abs(newLines.length - oldLines.length)
629
+ if (showStat) stats.push({ path: p, added: newLines.length > oldLines.length ? diff : 0, deleted: oldLines.length > newLines.length ? diff : 0 })
630
+ }
631
+ }
632
+ }
633
+ if (opts.json === 'true') {
634
+ print({ added, deleted, modified, files: stats }, true)
635
+ } else {
636
+ for (const stat of stats) {
637
+ process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
638
+ }
639
+ process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
640
+ }
641
+ return
642
+ }
643
+
479
644
  for (const p of files) {
480
645
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
481
646
  const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
@@ -510,18 +675,18 @@ async function cmdDiff(opts) {
510
675
  }
511
676
  return
512
677
  }
513
-
678
+
514
679
  // Show diff for working directory
515
680
  const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
516
681
  const local = await collectLocal(dir)
517
682
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
518
-
683
+
519
684
  for (const p of files) {
520
685
  const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
521
686
  const localContent = local[p]?.content || null
522
687
  const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
523
688
  const localId = local[p]?.id
524
-
689
+
525
690
  if (remoteId !== localId) {
526
691
  if (opts.json === 'true') {
527
692
  print({ path: p, remote: remoteContent, local: localContent }, true)
@@ -561,6 +726,31 @@ async function cmdRm(opts) {
561
726
  const cfg = loadConfig()
562
727
  const server = getServer(opts, cfg) || meta.server
563
728
  const token = getToken(opts, cfg) || meta.token
729
+
730
+ // Handle --cached flag (remove from index but keep file)
731
+ if (opts.cached === 'true') {
732
+ // Mark file for deletion in next commit but don't delete from filesystem
733
+ const metaDir = path.join(dir, '.vcs-next')
734
+ const localPath = path.join(metaDir, 'local.json')
735
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null, removedFiles: [] }
736
+ try {
737
+ const s = await fs.promises.readFile(localPath, 'utf8')
738
+ localMeta = JSON.parse(s)
739
+ } catch {}
740
+ if (!localMeta.removedFiles) localMeta.removedFiles = []
741
+ if (!localMeta.removedFiles.includes(pathArg)) {
742
+ localMeta.removedFiles.push(pathArg)
743
+ }
744
+ await fs.promises.mkdir(metaDir, { recursive: true })
745
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
746
+ if (opts.json === 'true') {
747
+ print({ removed: pathArg, cached: true }, true)
748
+ } else {
749
+ process.stdout.write(color(`Removed '${pathArg}' from index (file kept in working directory)\n`, 'green'))
750
+ }
751
+ return
752
+ }
753
+
564
754
  const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
565
755
  u.searchParams.set('branch', meta.branch)
566
756
  u.searchParams.set('path', pathArg)
@@ -578,41 +768,94 @@ async function cmdCommit(opts) {
578
768
  const dir = path.resolve(opts.dir || '.')
579
769
  const message = opts.message || ''
580
770
  if (!message) throw new Error('Missing --message')
581
-
582
- // Check for unresolved conflicts
583
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
584
- if (unresolvedConflicts.length > 0) {
585
- if (opts.json === 'true') {
586
- print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
587
- } else {
588
- process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
589
- process.stderr.write(color('Conflicts in files:\n', 'yellow'))
590
- for (const p of unresolvedConflicts) {
591
- process.stderr.write(color(` ${p}\n`, 'red'))
771
+
772
+ const spinner = createSpinner('Preparing commit...', opts.json)
773
+ try {
774
+ // Handle -am flag (add all and commit)
775
+ if (opts.all === 'true' || opts.a === 'true') {
776
+ spinnerUpdate(spinner, 'Staging all changes...')
777
+ await cmdAdd({ dir, all: 'true', json: opts.json })
778
+ }
779
+
780
+ // Handle --amend flag
781
+ if (opts.amend === 'true') {
782
+ spinnerUpdate(spinner, 'Amending last commit...')
783
+ // Get the last commit and use its message if no new message provided
784
+ const meta = readRemoteMeta(dir)
785
+ const cfg = loadConfig()
786
+ const server = getServer(opts, cfg) || meta.server
787
+ const token = getToken(opts, cfg) || meta.token
788
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
789
+ url.searchParams.set('branch', meta.branch)
790
+ url.searchParams.set('limit', '1')
791
+ const commits = await request('GET', url.toString(), null, token)
792
+ if (Array.isArray(commits) && commits.length > 0 && !message) {
793
+ opts.message = commits[0].message || 'Amended commit'
592
794
  }
593
- process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
594
795
  }
595
- return
596
- }
597
-
598
- const metaDir = path.join(dir, '.vcs-next')
599
- const localPath = path.join(metaDir, 'local.json')
600
- let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
601
- try {
602
- const s = await fs.promises.readFile(localPath, 'utf8')
603
- localMeta = JSON.parse(s)
604
- } catch {}
605
- const local = await collectLocal(dir)
606
- const files = {}
607
- for (const [p, v] of Object.entries(local)) files[p] = v.content
608
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
609
- // Clear conflicts if they were resolved
610
- if (localMeta.conflicts) {
611
- delete localMeta.conflicts
796
+
797
+ // Check for unresolved conflicts
798
+ spinnerUpdate(spinner, 'Checking for conflicts...')
799
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
800
+ if (unresolvedConflicts.length > 0) {
801
+ spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
802
+ if (opts.json === 'true') {
803
+ print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
804
+ } else {
805
+ process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
806
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
807
+ for (const p of unresolvedConflicts) {
808
+ process.stderr.write(color(` ${p}\n`, 'red'))
809
+ }
810
+ process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
811
+ }
812
+ return
813
+ }
814
+
815
+ spinnerUpdate(spinner, 'Collecting changes...')
816
+ const metaDir = path.join(dir, '.vcs-next')
817
+ const localPath = path.join(metaDir, 'local.json')
818
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
819
+ try {
820
+ const s = await fs.promises.readFile(localPath, 'utf8')
821
+ localMeta = JSON.parse(s)
822
+ } catch { }
823
+ const local = await collectLocal(dir)
824
+ const files = {}
825
+ for (const [p, v] of Object.entries(local)) files[p] = v.content
826
+
827
+ // Execute pre-commit hook
828
+ spinnerUpdate(spinner, 'Running pre-commit hook...')
829
+ try {
830
+ const hookResult = await hooks.executeHook(dir, 'pre-commit', { message, files: Object.keys(files) })
831
+ if (hookResult.executed && hookResult.exitCode !== 0) {
832
+ spinnerFail(spinner, 'Pre-commit hook failed')
833
+ throw new Error('pre-commit hook failed')
834
+ }
835
+ } catch (err) {
836
+ if (err.message === 'pre-commit hook failed') throw err
837
+ // Hook doesn't exist or other error, continue
838
+ }
839
+
840
+ localMeta.pendingCommit = { message, files, createdAt: Date.now() }
841
+ // Clear conflicts if they were resolved
842
+ if (localMeta.conflicts) {
843
+ delete localMeta.conflicts
844
+ }
845
+ await fs.promises.mkdir(metaDir, { recursive: true })
846
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
847
+
848
+ // Execute post-commit hook
849
+ try {
850
+ await hooks.executeHook(dir, 'post-commit', { message, files: Object.keys(files) })
851
+ } catch {}
852
+
853
+ spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
854
+ print({ pendingCommit: message }, opts.json === 'true')
855
+ } catch (err) {
856
+ spinnerFail(spinner, 'Commit failed')
857
+ throw err
612
858
  }
613
- await fs.promises.mkdir(metaDir, { recursive: true })
614
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
615
- print({ pendingCommit: message }, opts.json === 'true')
616
859
  }
617
860
 
618
861
  async function pullToDir(repo, branch, dir, server, token) {
@@ -637,7 +880,7 @@ async function pullToDir(repo, branch, dir, server, token) {
637
880
  for (const rel of Object.keys(localMap)) {
638
881
  if (!keep.has(rel)) {
639
882
  const fp = path.join(root, rel)
640
- try { await fs.promises.unlink(fp) } catch {}
883
+ try { await fs.promises.unlink(fp) } catch { }
641
884
  }
642
885
  }
643
886
  const pruneEmptyDirs = async (start) => {
@@ -650,7 +893,7 @@ async function pullToDir(repo, branch, dir, server, token) {
650
893
  if (st.isDirectory()) {
651
894
  await pruneEmptyDirs(p)
652
895
  const left = await fs.promises.readdir(p).catch(() => [])
653
- if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
896
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
654
897
  }
655
898
  }
656
899
  }
@@ -676,29 +919,83 @@ async function cmdPull(opts) {
676
919
  const cfg = loadConfig()
677
920
  const server = getServer(opts, cfg) || meta.server
678
921
  const token = getToken(opts, cfg) || meta.token
679
- await pullToDir(meta.repoId, meta.branch, dir, server, token)
680
- print('Pull complete', opts.json === 'true')
681
- }
682
-
683
- async function fetchRemoteSnapshot(server, repo, branch, token) {
684
- const headers = token ? { Authorization: `Bearer ${token}` } : {}
685
- const url = new URL(`/api/repositories/${repo}/snapshot`, server)
686
- if (branch) url.searchParams.set('branch', branch)
687
- const res = await fetch(url.toString(), { headers })
688
- if (!res.ok) {
689
- const text = await res.text()
690
- throw new Error(text || 'snapshot failed')
922
+ const spinner = createSpinner(`Pulling from branch '${meta.branch}'...`, opts.json)
923
+ try {
924
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
925
+ spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
926
+ print('Pull complete', opts.json === 'true')
927
+ } catch (err) {
928
+ spinnerFail(spinner, 'Pull failed')
929
+ throw err
691
930
  }
692
- const data = await res.json()
693
- return { files: data.files || {}, commitId: data.commitId || '' }
694
931
  }
695
932
 
696
- async function fetchSnapshotByCommit(server, repo, commitId, token) {
933
+ async function cmdFetch(opts) {
934
+ const dir = path.resolve(opts.dir || '.')
935
+ const meta = readRemoteMeta(dir)
936
+ const cfg = loadConfig()
937
+ const server = getServer(opts, cfg) || meta.server
938
+ const token = getToken(opts, cfg) || meta.token
697
939
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
698
- const url = new URL(`/api/repositories/${repo}/snapshot`, server)
699
- url.searchParams.set('commitId', commitId)
700
- const res = await fetch(url.toString(), { headers })
701
- if (!res.ok) {
940
+ const gitDir = path.join(dir, '.git')
941
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
942
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
943
+ try {
944
+ const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
945
+ const branchesRes = await fetch(branchesUrl.toString(), { headers })
946
+ if (branchesRes.ok) {
947
+ const branchesData = await branchesRes.json()
948
+ const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
949
+ const allRefs = {}
950
+ for (const b of branches) {
951
+ const name = b.name || ''
952
+ const id = b.commitId || ''
953
+ if (!name) continue
954
+ allRefs[name] = id
955
+ await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
956
+ }
957
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
958
+ const curId = allRefs[meta.branch] || ''
959
+ const metaDir = path.join(dir, '.vcs-next')
960
+ await fs.promises.mkdir(metaDir, { recursive: true })
961
+ const remoteMetaPath = path.join(metaDir, 'remote.json')
962
+ let remoteMeta = meta
963
+ try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
964
+ remoteMeta.commitId = curId
965
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
966
+ }
967
+ } catch { }
968
+ try {
969
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
970
+ commitsUrl.searchParams.set('branch', meta.branch)
971
+ const commitsRes = await fetch(commitsUrl.toString(), { headers })
972
+ if (commitsRes.ok) {
973
+ const commitsList = await commitsRes.json()
974
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
975
+ }
976
+ } catch { }
977
+ print('Fetch complete', opts.json === 'true')
978
+ }
979
+
980
+ async function fetchRemoteSnapshot(server, repo, branch, token) {
981
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
982
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
983
+ if (branch) url.searchParams.set('branch', branch)
984
+ const res = await fetch(url.toString(), { headers })
985
+ if (!res.ok) {
986
+ const text = await res.text()
987
+ throw new Error(text || 'snapshot failed')
988
+ }
989
+ const data = await res.json()
990
+ return { files: data.files || {}, commitId: data.commitId || '' }
991
+ }
992
+
993
+ async function fetchSnapshotByCommit(server, repo, commitId, token) {
994
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
995
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
996
+ url.searchParams.set('commitId', commitId)
997
+ const res = await fetch(url.toString(), { headers })
998
+ if (!res.ok) {
702
999
  const text = await res.text()
703
1000
  throw new Error(text || 'snapshot failed')
704
1001
  }
@@ -748,57 +1045,57 @@ async function cmdCherryPick(opts) {
748
1045
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
749
1046
  const m = head.match(/refs\/heads\/(.+)/)
750
1047
  if (m) current = m[1]
751
- } catch {}
1048
+ } catch { }
752
1049
  targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
753
1050
  }
754
1051
  const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
755
1052
  if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
756
-
1053
+
757
1054
  // Get the current state of the target branch (what we're cherry-picking onto)
758
1055
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
759
-
1056
+
760
1057
  // Get the commit being cherry-picked and its parent
761
1058
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
762
1059
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
763
1060
  const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
764
1061
  const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
765
-
1062
+
766
1063
  // Pull the target branch to ensure we're working with the latest state
767
1064
  await pullToDir(meta.repoId, targetBranch, dir, server, token)
768
-
1065
+
769
1066
  // Re-collect local state after pull (now it matches the target branch)
770
1067
  const localAfterPull = await collectLocal(dir)
771
-
1068
+
772
1069
  // Update local metadata to reflect the pulled state
773
1070
  const metaDirInit = path.join(dir, '.vcs-next')
774
1071
  await fs.promises.mkdir(metaDirInit, { recursive: true })
775
1072
  const localPathInit = path.join(metaDirInit, 'local.json')
776
1073
  let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
777
- try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
1074
+ try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch { }
778
1075
  localMetaInit.baseCommitId = currentSnap.commitId
779
1076
  localMetaInit.baseFiles = currentSnap.files
780
1077
  await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
781
-
1078
+
782
1079
  // Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
783
1080
  const conflicts = []
784
1081
  const changes = []
785
1082
  const mergedFiles = {}
786
1083
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
787
-
1084
+
788
1085
  for (const p of allPaths) {
789
1086
  const b = baseSnap.files[p] // base: parent of commit being cherry-picked
790
1087
  const n = targetSnap.files[p] // next: the commit being cherry-picked
791
1088
  const c = currentSnap.files[p] // current: current state of target branch (from remote)
792
-
1089
+
793
1090
  // For cherry-pick, we need to apply the changes from the commit onto the current branch
794
1091
  // The logic: if the commit changed something from its parent, apply that change to current
795
1092
  const baseContent = b !== undefined ? String(b) : null
796
1093
  const commitContent = n !== undefined ? String(n) : null
797
1094
  const currentContent = c !== undefined ? String(c) : null
798
-
1095
+
799
1096
  // Check if commit changed this file from its parent
800
1097
  const commitChanged = baseContent !== commitContent
801
-
1098
+
802
1099
  if (commitChanged) {
803
1100
  // The commit modified this file - we want to apply that change
804
1101
  // Conflict detection:
@@ -807,15 +1104,15 @@ async function cmdCherryPick(opts) {
807
1104
  // - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
808
1105
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
809
1106
  // - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
810
-
1107
+
811
1108
  const fileWasAdded = baseContent === null && commitContent !== null
812
1109
  const fileWasDeleted = baseContent !== null && commitContent === null
813
1110
  const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
814
-
1111
+
815
1112
  // Check for conflicts: current branch changed the file differently than the commit
816
1113
  const currentChangedFromBase = currentContent !== baseContent
817
1114
  const currentDiffersFromCommit = currentContent !== commitContent
818
-
1115
+
819
1116
  // Conflict detection:
820
1117
  // - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
821
1118
  // - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
@@ -823,7 +1120,7 @@ async function cmdCherryPick(opts) {
823
1120
  // - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
824
1121
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
825
1122
  // - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
826
-
1123
+
827
1124
  // Conflict if:
828
1125
  // 1. Current branch changed the file from base (current !== base)
829
1126
  // 2. AND current differs from what commit wants (current !== commit)
@@ -833,7 +1130,7 @@ async function cmdCherryPick(opts) {
833
1130
  // this is always safe - no conflict. The file is simply being added.
834
1131
  const safeAddCase = fileWasAdded && currentContent === null
835
1132
  const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
836
-
1133
+
837
1134
  // Only conflict if file exists in current AND was changed differently
838
1135
  if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
839
1136
  // Conflict: both changed the file differently
@@ -863,7 +1160,7 @@ async function cmdCherryPick(opts) {
863
1160
  }
864
1161
  }
865
1162
  }
866
-
1163
+
867
1164
  if (conflicts.length > 0) {
868
1165
  // Write conflict markers to files
869
1166
  for (const conflict of conflicts) {
@@ -873,11 +1170,11 @@ async function cmdCherryPick(opts) {
873
1170
  conflict.current,
874
1171
  conflict.incoming,
875
1172
  meta.branch,
876
- `cherry-pick-${commitId.slice(0,7)}`
1173
+ `cherry-pick-${commitId.slice(0, 7)}`
877
1174
  )
878
1175
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
879
1176
  }
880
-
1177
+
881
1178
  // Store conflict state in metadata
882
1179
  const metaDir = path.join(dir, '.vcs-next')
883
1180
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -886,9 +1183,9 @@ async function cmdCherryPick(opts) {
886
1183
  try {
887
1184
  const s = await fs.promises.readFile(localPath, 'utf8')
888
1185
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
889
- } catch {}
1186
+ } catch { }
890
1187
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
891
-
1188
+
892
1189
  if (opts.json === 'true') {
893
1190
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
894
1191
  } else {
@@ -901,22 +1198,22 @@ async function cmdCherryPick(opts) {
901
1198
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
902
1199
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
903
1200
  process.stdout.write(color(` =======\n`, 'dim'))
904
- process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
1201
+ process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0, 7)}\n`, 'dim'))
905
1202
  }
906
1203
  return
907
1204
  }
908
-
1205
+
909
1206
  // Apply the changes to the filesystem
910
1207
  for (const ch of changes) {
911
1208
  const fp = path.join(dir, ch.path)
912
1209
  if (ch.type === 'delete') {
913
- try { await fs.promises.unlink(fp) } catch {}
1210
+ try { await fs.promises.unlink(fp) } catch { }
914
1211
  } else if (ch.type === 'write') {
915
1212
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
916
1213
  await fs.promises.writeFile(fp, ch.content, 'utf8')
917
1214
  }
918
1215
  }
919
-
1216
+
920
1217
  // Update local metadata with the pending commit
921
1218
  const metaDir = path.join(dir, '.vcs-next')
922
1219
  const localPath = path.join(metaDir, 'local.json')
@@ -925,43 +1222,43 @@ async function cmdCherryPick(opts) {
925
1222
  try {
926
1223
  const s = await fs.promises.readFile(localPath, 'utf8')
927
1224
  localMeta = JSON.parse(s)
928
- } catch {}
929
-
930
- const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
931
-
1225
+ } catch { }
1226
+
1227
+ const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
1228
+
932
1229
  // Collect final state after applying changes
933
1230
  const finalLocal = await collectLocal(dir)
934
1231
  const finalFiles = {}
935
1232
  for (const [p, v] of Object.entries(finalLocal)) {
936
1233
  finalFiles[p] = v.content
937
1234
  }
938
-
1235
+
939
1236
  // Set pending commit with the actual files
940
1237
  localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
941
1238
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
942
-
1239
+
943
1240
  // Ensure remote.json has the correct target branch before pushing
944
1241
  const remoteMetaPath = path.join(metaDir, 'remote.json')
945
1242
  let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
946
1243
  try {
947
1244
  const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
948
1245
  remoteMeta = { ...JSON.parse(s), branch: targetBranch }
949
- } catch {}
1246
+ } catch { }
950
1247
  await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
951
-
1248
+
952
1249
  // Also update .git metadata
953
1250
  const gitDir = path.join(dir, '.git')
954
1251
  const gitMetaPath = path.join(gitDir, 'vcs-next.json')
955
1252
  await fs.promises.mkdir(gitDir, { recursive: true })
956
1253
  await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
957
1254
  await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
958
-
1255
+
959
1256
  if (opts.json === 'true') {
960
1257
  print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
961
1258
  } else {
962
- process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
1259
+ process.stdout.write(color(`Cherry-pick ${commitId.slice(0, 7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
963
1260
  }
964
-
1261
+
965
1262
  if (opts.noPush !== 'true') {
966
1263
  await cmdPush({ dir, json: opts.json, message: cherryMsg })
967
1264
  }
@@ -984,7 +1281,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
984
1281
  const incoming = String(incomingContent || '')
985
1282
  const currentLines = current.split(/\r?\n/)
986
1283
  const incomingLines = incoming.split(/\r?\n/)
987
-
1284
+
988
1285
  // Simple conflict marker format
989
1286
  const markers = [
990
1287
  `<<<<<<< ${currentLabel || 'HEAD'}`,
@@ -993,7 +1290,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
993
1290
  ...incomingLines,
994
1291
  `>>>>>>> ${incomingLabel || 'incoming'}`
995
1292
  ]
996
-
1293
+
997
1294
  return markers.join('\n')
998
1295
  }
999
1296
 
@@ -1019,73 +1316,106 @@ async function cmdPush(opts) {
1019
1316
  const metaDir = path.join(dir, '.vcs-next')
1020
1317
  const localPath = path.join(metaDir, 'local.json')
1021
1318
  let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1319
+ const spinner = createSpinner('Preparing to push...', opts.json)
1022
1320
  try {
1023
1321
  const s = await fs.promises.readFile(localPath, 'utf8')
1024
1322
  localMeta = JSON.parse(s)
1025
- } catch {}
1026
-
1027
- // Check for unresolved conflicts in files
1028
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1029
- if (unresolvedConflicts.length > 0) {
1030
- if (opts.json === 'true') {
1031
- print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1032
- } else {
1033
- process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
1034
- process.stderr.write(color('Conflicts in files:\n', 'yellow'))
1035
- for (const p of unresolvedConflicts) {
1036
- process.stderr.write(color(` ${p}\n`, 'red'))
1323
+ } catch { }
1324
+
1325
+ try {
1326
+ // Check for unresolved conflicts in files
1327
+ spinnerUpdate(spinner, 'Checking for conflicts...')
1328
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1329
+ if (unresolvedConflicts.length > 0) {
1330
+ spinnerFail(spinner, 'Cannot push with unresolved conflicts')
1331
+ if (opts.json === 'true') {
1332
+ print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1333
+ } else {
1334
+ process.stderr.write(color('Error: Cannot push with unresolved conflicts\\n', 'red'))
1335
+ process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
1336
+ for (const p of unresolvedConflicts) {
1337
+ process.stderr.write(color(` ${p}\\n`, 'red'))
1338
+ }
1339
+ process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
1037
1340
  }
1038
- process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1341
+ return
1039
1342
  }
1040
- return
1041
- }
1042
-
1043
- const cfg = loadConfig()
1044
- const server = getServer(opts, cfg) || remoteMeta.server
1045
- const token = getToken(opts, cfg) || remoteMeta.token
1046
- const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1047
- const base = localMeta.baseFiles || {}
1048
- const local = await collectLocal(dir)
1049
- const conflicts = []
1050
- const merged = {}
1051
- const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1052
- for (const p of paths) {
1053
- const b = p in base ? base[p] : null
1054
- const r = p in remote.files ? remote.files[p] : null
1055
- const l = p in local ? local[p].content : null
1056
- const changedLocal = String(l) !== String(b)
1057
- const changedRemote = String(r) !== String(b)
1058
- if (changedLocal && changedRemote && String(l) !== String(r)) {
1059
- const line = firstDiffLine(l || '', r || '')
1060
- conflicts.push({ path: p, line })
1061
- } else if (changedLocal && !changedRemote) {
1062
- if (l !== null) merged[p] = l
1063
- } else if (!changedLocal && changedRemote) {
1064
- if (r !== null) merged[p] = r
1065
- } else {
1066
- if (b !== null) merged[p] = b
1343
+
1344
+ const cfg = loadConfig()
1345
+ const server = getServer(opts, cfg) || remoteMeta.server
1346
+ const token = getToken(opts, cfg) || remoteMeta.token
1347
+ spinnerUpdate(spinner, 'Fetching remote state...')
1348
+ const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1349
+ const base = localMeta.baseFiles || {}
1350
+ spinnerUpdate(spinner, 'Collecting local changes...')
1351
+ const local = await collectLocal(dir)
1352
+ const conflicts = []
1353
+ const merged = {}
1354
+ const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1355
+ spinnerUpdate(spinner, 'Merging changes...')
1356
+ for (const p of paths) {
1357
+ const b = p in base ? base[p] : null
1358
+ const r = p in remote.files ? remote.files[p] : null
1359
+ const l = p in local ? local[p].content : null
1360
+ const changedLocal = String(l) !== String(b)
1361
+ const changedRemote = String(r) !== String(b)
1362
+ if (changedLocal && changedRemote && String(l) !== String(r)) {
1363
+ const line = firstDiffLine(l || '', r || '')
1364
+ conflicts.push({ path: p, line })
1365
+ } else if (changedLocal && !changedRemote) {
1366
+ if (l !== null) merged[p] = l
1367
+ } else if (!changedLocal && changedRemote) {
1368
+ if (r !== null) merged[p] = r
1369
+ } else {
1370
+ if (b !== null) merged[p] = b
1371
+ }
1067
1372
  }
1068
- }
1069
- if (conflicts.length > 0) {
1070
- if (opts.json === 'true') {
1071
- print({ conflicts }, true)
1072
- } else {
1073
- process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1074
- process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1075
- for (const c of conflicts) {
1076
- process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
1373
+ if (conflicts.length > 0) {
1374
+ spinnerFail(spinner, 'Push blocked by conflicts')
1375
+ if (opts.json === 'true') {
1376
+ print({ conflicts }, true)
1377
+ } else {
1378
+ process.stderr.write(color('Error: Cannot push with conflicts\\n', 'red'))
1379
+ process.stderr.write(color('Conflicts detected:\\n', 'yellow'))
1380
+ for (const c of conflicts) {
1381
+ process.stderr.write(color(` ${c.path}:${c.line}\\n`, 'red'))
1382
+ }
1077
1383
  }
1384
+ return
1078
1385
  }
1079
- return
1386
+ // Execute pre-push hook
1387
+ spinnerUpdate(spinner, 'Running pre-push hook...')
1388
+ try {
1389
+ const hookResult = await hooks.executeHook(dir, 'pre-push', { branch: remoteMeta.branch, files: Object.keys(merged) })
1390
+ if (hookResult.executed && hookResult.exitCode !== 0) {
1391
+ spinnerFail(spinner, 'Pre-push hook failed')
1392
+ throw new Error('pre-push hook failed')
1393
+ }
1394
+ } catch (err) {
1395
+ if (err.message === 'pre-push hook failed') throw err
1396
+ // Hook doesn't exist or other error, continue
1397
+ }
1398
+
1399
+ const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1400
+ spinnerUpdate(spinner, `Pushing to '${remoteMeta.branch}'...`)
1401
+ const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1402
+ const data = await request('POST', url, body, token)
1403
+ localMeta.baseCommitId = data.id || remote.commitId || ''
1404
+ localMeta.baseFiles = merged
1405
+ localMeta.pendingCommit = null
1406
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1407
+
1408
+ // Execute post-push hook
1409
+ try {
1410
+ await hooks.executeHook(dir, 'post-push', { branch: remoteMeta.branch, commitId: localMeta.baseCommitId })
1411
+ } catch {}
1412
+
1413
+ spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
1414
+ print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1415
+ } catch (err) {
1416
+ spinnerFail(spinner, 'Push failed')
1417
+ throw err
1080
1418
  }
1081
- const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1082
- const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1083
- const data = await request('POST', url, body, token)
1084
- localMeta.baseCommitId = data.id || remote.commitId || ''
1085
- localMeta.baseFiles = merged
1086
- localMeta.pendingCommit = null
1087
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1088
- print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1089
1419
  }
1090
1420
 
1091
1421
  async function cmdMerge(opts) {
@@ -1096,13 +1426,13 @@ async function cmdMerge(opts) {
1096
1426
  const token = getToken(opts, cfg) || meta.token
1097
1427
  const sourceBranch = opts.branch || ''
1098
1428
  if (!sourceBranch) throw new Error('Missing --branch')
1099
-
1429
+
1100
1430
  // Get current branch state
1101
1431
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1102
-
1432
+
1103
1433
  // Get source branch state
1104
1434
  const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
1105
-
1435
+
1106
1436
  // Find common ancestor (merge base)
1107
1437
  // For simplicity, we'll use the current branch's base commit as the merge base
1108
1438
  // In a full implementation, we'd find the actual common ancestor
@@ -1110,29 +1440,29 @@ async function cmdMerge(opts) {
1110
1440
  const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
1111
1441
  const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
1112
1442
  if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
1113
-
1443
+
1114
1444
  // Get base commit (for now, use current branch's commit as base)
1115
1445
  // In real Git, we'd find the merge base commit
1116
1446
  const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1117
-
1447
+
1118
1448
  // Pull current branch to ensure we're up to date
1119
1449
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1120
1450
  const localAfterPull = await collectLocal(dir)
1121
-
1451
+
1122
1452
  // Three-way merge: base, current (target), source
1123
1453
  const conflicts = []
1124
1454
  const changes = []
1125
1455
  const mergedFiles = {}
1126
1456
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
1127
-
1457
+
1128
1458
  for (const p of allPaths) {
1129
1459
  const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
1130
1460
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1131
1461
  const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
1132
-
1462
+
1133
1463
  const currentChanged = current !== base
1134
1464
  const sourceChanged = source !== base
1135
-
1465
+
1136
1466
  if (currentChanged && sourceChanged && current !== source) {
1137
1467
  // Conflict: both branches changed the file differently
1138
1468
  const line = firstDiffLine(current || '', source || '')
@@ -1166,7 +1496,7 @@ async function cmdMerge(opts) {
1166
1496
  }
1167
1497
  }
1168
1498
  }
1169
-
1499
+
1170
1500
  if (conflicts.length > 0) {
1171
1501
  // Write conflict markers to files
1172
1502
  for (const conflict of conflicts) {
@@ -1180,7 +1510,7 @@ async function cmdMerge(opts) {
1180
1510
  )
1181
1511
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1182
1512
  }
1183
-
1513
+
1184
1514
  // Store conflict state in metadata
1185
1515
  const metaDir = path.join(dir, '.vcs-next')
1186
1516
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1189,9 +1519,9 @@ async function cmdMerge(opts) {
1189
1519
  try {
1190
1520
  const s = await fs.promises.readFile(localPath, 'utf8')
1191
1521
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1192
- } catch {}
1522
+ } catch { }
1193
1523
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1194
-
1524
+
1195
1525
  const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
1196
1526
  if (opts.json === 'true') {
1197
1527
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
@@ -1209,18 +1539,18 @@ async function cmdMerge(opts) {
1209
1539
  }
1210
1540
  return
1211
1541
  }
1212
-
1542
+
1213
1543
  // Apply changes to filesystem
1214
1544
  for (const ch of changes) {
1215
1545
  const fp = path.join(dir, ch.path)
1216
1546
  if (ch.type === 'delete') {
1217
- try { await fs.promises.unlink(fp) } catch {}
1547
+ try { await fs.promises.unlink(fp) } catch { }
1218
1548
  } else if (ch.type === 'write') {
1219
1549
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1220
1550
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1221
1551
  }
1222
1552
  }
1223
-
1553
+
1224
1554
  // Update local metadata
1225
1555
  const metaDir = path.join(dir, '.vcs-next')
1226
1556
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1229,27 +1559,27 @@ async function cmdMerge(opts) {
1229
1559
  try {
1230
1560
  const s = await fs.promises.readFile(localPath, 'utf8')
1231
1561
  localMeta = JSON.parse(s)
1232
- } catch {}
1233
-
1562
+ } catch { }
1563
+
1234
1564
  const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
1235
-
1565
+
1236
1566
  // Collect final state
1237
1567
  const finalLocal = await collectLocal(dir)
1238
1568
  const finalFiles = {}
1239
1569
  for (const [p, v] of Object.entries(finalLocal)) {
1240
1570
  finalFiles[p] = v.content
1241
1571
  }
1242
-
1572
+
1243
1573
  // Set pending commit
1244
1574
  localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
1245
1575
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1246
-
1576
+
1247
1577
  if (opts.json === 'true') {
1248
1578
  print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
1249
1579
  } else {
1250
1580
  process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
1251
1581
  }
1252
-
1582
+
1253
1583
  if (opts.noPush !== 'true') {
1254
1584
  await cmdPush({ dir, json: opts.json, message: mergeMsg })
1255
1585
  }
@@ -1260,7 +1590,7 @@ async function cmdStash(sub, opts) {
1260
1590
  const metaDir = path.join(dir, '.vcs-next')
1261
1591
  const stashDir = path.join(metaDir, 'stash')
1262
1592
  await fs.promises.mkdir(stashDir, { recursive: true })
1263
-
1593
+
1264
1594
  if (sub === 'list' || sub === undefined) {
1265
1595
  try {
1266
1596
  const files = await fs.promises.readdir(stashDir)
@@ -1293,7 +1623,7 @@ async function cmdStash(sub, opts) {
1293
1623
  }
1294
1624
  return
1295
1625
  }
1296
-
1626
+
1297
1627
  if (sub === 'save' || (sub === undefined && !opts.list)) {
1298
1628
  const message = opts.message || 'WIP'
1299
1629
  const local = await collectLocal(dir)
@@ -1304,14 +1634,14 @@ async function cmdStash(sub, opts) {
1304
1634
  const stashId = Date.now().toString()
1305
1635
  const stash = { message, files, createdAt: Date.now() }
1306
1636
  await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
1307
-
1637
+
1308
1638
  // Restore to base state (discard local changes)
1309
1639
  const meta = readRemoteMeta(dir)
1310
1640
  const cfg = loadConfig()
1311
1641
  const server = getServer(opts, cfg) || meta.server
1312
1642
  const token = getToken(opts, cfg) || meta.token
1313
1643
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1314
-
1644
+
1315
1645
  if (opts.json === 'true') {
1316
1646
  print({ stashId, message }, true)
1317
1647
  } else {
@@ -1319,7 +1649,7 @@ async function cmdStash(sub, opts) {
1319
1649
  }
1320
1650
  return
1321
1651
  }
1322
-
1652
+
1323
1653
  if (sub === 'pop' || sub === 'apply') {
1324
1654
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1325
1655
  try {
@@ -1335,19 +1665,19 @@ async function cmdStash(sub, opts) {
1335
1665
  stashes.sort((a, b) => b.createdAt - a.createdAt)
1336
1666
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1337
1667
  const stash = stashes[stashIndex]
1338
-
1668
+
1339
1669
  // Apply stash files
1340
1670
  for (const [p, content] of Object.entries(stash.files || {})) {
1341
1671
  const fp = path.join(dir, p)
1342
1672
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1343
1673
  await fs.promises.writeFile(fp, content, 'utf8')
1344
1674
  }
1345
-
1675
+
1346
1676
  if (sub === 'pop') {
1347
1677
  // Remove stash
1348
1678
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1349
1679
  }
1350
-
1680
+
1351
1681
  if (opts.json === 'true') {
1352
1682
  print({ applied: stash.id, message: stash.message }, true)
1353
1683
  } else {
@@ -1358,7 +1688,7 @@ async function cmdStash(sub, opts) {
1358
1688
  }
1359
1689
  return
1360
1690
  }
1361
-
1691
+
1362
1692
  if (sub === 'drop') {
1363
1693
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1364
1694
  try {
@@ -1375,7 +1705,7 @@ async function cmdStash(sub, opts) {
1375
1705
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1376
1706
  const stash = stashes[stashIndex]
1377
1707
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1378
-
1708
+
1379
1709
  if (opts.json === 'true') {
1380
1710
  print({ dropped: stash.id }, true)
1381
1711
  } else {
@@ -1386,7 +1716,7 @@ async function cmdStash(sub, opts) {
1386
1716
  }
1387
1717
  return
1388
1718
  }
1389
-
1719
+
1390
1720
  if (sub === 'clear') {
1391
1721
  try {
1392
1722
  const files = await fs.promises.readdir(stashDir)
@@ -1403,7 +1733,7 @@ async function cmdStash(sub, opts) {
1403
1733
  }
1404
1734
  return
1405
1735
  }
1406
-
1736
+
1407
1737
  throw new Error('Unknown stash subcommand')
1408
1738
  }
1409
1739
 
@@ -1425,9 +1755,22 @@ async function cmdBranch(sub, opts) {
1425
1755
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
1426
1756
  const m = head.match(/refs\/heads\/(.+)/)
1427
1757
  if (m) current = m[1]
1428
- } catch {}
1758
+ } catch { }
1429
1759
  process.stdout.write(color('Branches:\n', 'bold'))
1430
- const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1760
+ let list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '', date: b.lastCommitDate || b.createdAt || '' }))
1761
+
1762
+ // Handle --sort option
1763
+ if (opts.sort) {
1764
+ const sortBy = opts.sort.replace(/^-/, '') // Remove leading dash
1765
+ if (sortBy === 'committerdate' || sortBy === '-committerdate') {
1766
+ list.sort((a, b) => {
1767
+ const dateA = new Date(a.date || 0).getTime()
1768
+ const dateB = new Date(b.date || 0).getTime()
1769
+ return sortBy.startsWith('-') ? dateB - dateA : dateA - dateB
1770
+ })
1771
+ }
1772
+ }
1773
+
1431
1774
  for (const b of list) {
1432
1775
  const isCur = b.name === current
1433
1776
  const mark = isCur ? color('*', 'green') : ' '
@@ -1451,19 +1794,38 @@ async function cmdBranch(sub, opts) {
1451
1794
  if (sub === 'delete') {
1452
1795
  const name = opts.name
1453
1796
  if (!name) throw new Error('Missing --name')
1797
+ const force = opts.force === 'true' || opts.D === 'true'
1454
1798
  const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1455
1799
  u.searchParams.set('name', name)
1800
+ u.searchParams.set('force', force ? 'true' : 'false')
1456
1801
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
1457
1802
  const res = await fetch(u.toString(), { method: 'DELETE', headers })
1458
1803
  if (!res.ok) {
1459
1804
  const body = await res.text().catch(() => '')
1805
+ if (force) {
1806
+ throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
1807
+ }
1460
1808
  throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
1461
1809
  }
1462
1810
  const data = await res.json()
1463
1811
  print(data, opts.json === 'true')
1464
1812
  return
1465
1813
  }
1466
-
1814
+ if (sub === 'rename') {
1815
+ const oldName = opts.old
1816
+ const newName = opts.new
1817
+ if (!oldName || !newName) throw new Error('Missing --old and --new')
1818
+ const body = { name: newName, baseBranch: oldName }
1819
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1820
+ await request('POST', url, body, token)
1821
+ const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1822
+ u.searchParams.set('name', oldName)
1823
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
1824
+ await fetch(u.toString(), { method: 'DELETE', headers })
1825
+ print({ renamed: { from: oldName, to: newName } }, opts.json === 'true')
1826
+ return
1827
+ }
1828
+
1467
1829
  throw new Error('Unknown branch subcommand')
1468
1830
  }
1469
1831
 
@@ -1471,10 +1833,24 @@ async function cmdSwitch(opts) {
1471
1833
  const dir = path.resolve(opts.dir || '.')
1472
1834
  const meta = readRemoteMeta(dir)
1473
1835
  const branch = opts.branch
1836
+ const create = opts.create === 'true' || opts.c === 'true'
1474
1837
  if (!branch) throw new Error('Missing --branch')
1475
1838
  const cfg = loadConfig()
1476
1839
  const server = getServer(opts, cfg) || meta.server
1477
1840
  const token = getToken(opts, cfg) || meta.token
1841
+
1842
+ // Handle -c flag (create and switch)
1843
+ if (create) {
1844
+ // Check if branch exists
1845
+ const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1846
+ const branchesData = await request('GET', branchesUrl.toString(), null, token)
1847
+ const exists = (branchesData.branches || []).some(b => b.name === branch)
1848
+ if (!exists) {
1849
+ // Create the branch
1850
+ await cmdBranch('create', { dir, name: branch, base: meta.branch, repo: meta.repoId, server, token })
1851
+ }
1852
+ }
1853
+
1478
1854
  await pullToDir(meta.repoId, branch, dir, server, token)
1479
1855
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1480
1856
  }
@@ -1493,7 +1869,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
1493
1869
  for (const rel of Object.keys(localMap)) {
1494
1870
  if (!keep.has(rel)) {
1495
1871
  const fp = path.join(root, rel)
1496
- try { await fs.promises.unlink(fp) } catch {}
1872
+ try { await fs.promises.unlink(fp) } catch { }
1497
1873
  }
1498
1874
  }
1499
1875
  const metaDir = path.join(root, '.vcs-next')
@@ -1521,6 +1897,11 @@ async function cmdCheckout(opts) {
1521
1897
  return
1522
1898
  }
1523
1899
  if (!branch) throw new Error('Missing --branch or --commit')
1900
+ if (opts.create === 'true') {
1901
+ const body = { name: branch, baseBranch: meta.branch }
1902
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1903
+ await request('POST', url, body, token)
1904
+ }
1524
1905
  await pullToDir(meta.repoId, branch, dir, server, token)
1525
1906
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1526
1907
  }
@@ -1614,7 +1995,7 @@ async function cmdHead(opts) {
1614
1995
  } else {
1615
1996
  commitId = head.trim()
1616
1997
  }
1617
- } catch {}
1998
+ } catch { }
1618
1999
  if (!commitId) {
1619
2000
  const cfg = loadConfig()
1620
2001
  const server = getServer(opts, cfg) || meta.server
@@ -1635,12 +2016,12 @@ async function cmdShow(opts) {
1635
2016
  const token = getToken(opts, cfg) || meta.token
1636
2017
  const commitId = opts.commit
1637
2018
  if (!commitId) throw new Error('Missing --commit')
1638
-
2019
+
1639
2020
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1640
2021
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1641
2022
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1642
2023
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1643
-
2024
+
1644
2025
  if (opts.json === 'true') {
1645
2026
  print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
1646
2027
  } else {
@@ -1652,7 +2033,7 @@ async function cmdShow(opts) {
1652
2033
  process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
1653
2034
  }
1654
2035
  process.stdout.write(`\n${commit.message || ''}\n\n`)
1655
-
2036
+
1656
2037
  // Show file changes
1657
2038
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
1658
2039
  const changed = []
@@ -1683,34 +2064,34 @@ async function cmdRevert(opts) {
1683
2064
  const token = getToken(opts, cfg) || meta.token
1684
2065
  const commitId = opts.commit
1685
2066
  if (!commitId) throw new Error('Missing --commit')
1686
-
2067
+
1687
2068
  // Get the commit to revert
1688
2069
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1689
2070
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1690
2071
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1691
2072
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1692
-
2073
+
1693
2074
  // Get current state
1694
2075
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1695
2076
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1696
2077
  const localAfterPull = await collectLocal(dir)
1697
-
2078
+
1698
2079
  // Revert: apply inverse of commit changes
1699
2080
  const conflicts = []
1700
2081
  const changes = []
1701
2082
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
1702
-
2083
+
1703
2084
  for (const p of allPaths) {
1704
2085
  const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
1705
2086
  const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
1706
2087
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1707
2088
  const local = localAfterPull[p]?.content || null
1708
-
2089
+
1709
2090
  // Revert: we want to go from commit state back to parent state
1710
2091
  // But check if current branch has changed this file
1711
2092
  const commitChanged = base !== commitContent
1712
2093
  const currentChanged = current !== base
1713
-
2094
+
1714
2095
  if (commitChanged) {
1715
2096
  // Commit changed this file - we want to revert it
1716
2097
  if (currentChanged && current !== base) {
@@ -1732,7 +2113,7 @@ async function cmdRevert(opts) {
1732
2113
  }
1733
2114
  }
1734
2115
  }
1735
-
2116
+
1736
2117
  if (conflicts.length > 0) {
1737
2118
  // Write conflict markers to files
1738
2119
  for (const conflict of conflicts) {
@@ -1742,11 +2123,11 @@ async function cmdRevert(opts) {
1742
2123
  conflict.current,
1743
2124
  conflict.incoming,
1744
2125
  meta.branch,
1745
- `revert-${commitId.slice(0,7)}`
2126
+ `revert-${commitId.slice(0, 7)}`
1746
2127
  )
1747
2128
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1748
2129
  }
1749
-
2130
+
1750
2131
  // Store conflict state in metadata
1751
2132
  const metaDir = path.join(dir, '.vcs-next')
1752
2133
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1755,9 +2136,9 @@ async function cmdRevert(opts) {
1755
2136
  try {
1756
2137
  const s = await fs.promises.readFile(localPath, 'utf8')
1757
2138
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1758
- } catch {}
2139
+ } catch { }
1759
2140
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1760
-
2141
+
1761
2142
  if (opts.json === 'true') {
1762
2143
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
1763
2144
  } else {
@@ -1770,22 +2151,22 @@ async function cmdRevert(opts) {
1770
2151
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
1771
2152
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
1772
2153
  process.stdout.write(color(` =======\n`, 'dim'))
1773
- process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
2154
+ process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
1774
2155
  }
1775
2156
  return
1776
2157
  }
1777
-
2158
+
1778
2159
  // Apply changes
1779
2160
  for (const ch of changes) {
1780
2161
  const fp = path.join(dir, ch.path)
1781
2162
  if (ch.type === 'delete') {
1782
- try { await fs.promises.unlink(fp) } catch {}
2163
+ try { await fs.promises.unlink(fp) } catch { }
1783
2164
  } else if (ch.type === 'write') {
1784
2165
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1785
2166
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1786
2167
  }
1787
2168
  }
1788
-
2169
+
1789
2170
  // Update metadata
1790
2171
  const metaDir = path.join(dir, '.vcs-next')
1791
2172
  const localPath = path.join(metaDir, 'local.json')
@@ -1794,24 +2175,24 @@ async function cmdRevert(opts) {
1794
2175
  try {
1795
2176
  const s = await fs.promises.readFile(localPath, 'utf8')
1796
2177
  localMeta = JSON.parse(s)
1797
- } catch {}
1798
-
2178
+ } catch { }
2179
+
1799
2180
  const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
1800
2181
  const finalLocal = await collectLocal(dir)
1801
2182
  const finalFiles = {}
1802
2183
  for (const [p, v] of Object.entries(finalLocal)) {
1803
2184
  finalFiles[p] = v.content
1804
2185
  }
1805
-
2186
+
1806
2187
  localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
1807
2188
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1808
-
2189
+
1809
2190
  if (opts.json === 'true') {
1810
2191
  print({ reverted: commitId, changes: changes.length }, true)
1811
2192
  } else {
1812
- process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
2193
+ process.stdout.write(color(`Reverted ${commitId.slice(0, 7)}: ${changes.length} changes\n`, 'green'))
1813
2194
  }
1814
-
2195
+
1815
2196
  if (opts.noPush !== 'true') {
1816
2197
  await cmdPush({ dir, json: opts.json, message: revertMsg })
1817
2198
  }
@@ -1823,11 +2204,29 @@ async function cmdReset(opts) {
1823
2204
  const cfg = loadConfig()
1824
2205
  const server = getServer(opts, cfg) || meta.server
1825
2206
  const token = getToken(opts, cfg) || meta.token
1826
-
1827
- const commitId = opts.commit || 'HEAD'
2207
+
2208
+ let commitId = opts.commit || 'HEAD'
1828
2209
  const mode = opts.mode || 'mixed' // soft, mixed, hard
1829
2210
  const filePath = opts.path
1830
-
2211
+
2212
+ // Handle HEAD^ syntax (parent commit)
2213
+ if (commitId === 'HEAD^' || commitId.endsWith('^')) {
2214
+ const baseCommit = commitId.replace(/\^+$/, '')
2215
+ const targetCommit = baseCommit === 'HEAD' ? 'HEAD' : baseCommit
2216
+ let targetSnap
2217
+ if (targetCommit === 'HEAD') {
2218
+ targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
2219
+ } else {
2220
+ targetSnap = await fetchSnapshotByCommit(server, meta.repoId, targetCommit, token)
2221
+ }
2222
+ const commit = await fetchCommitMeta(server, meta.repoId, targetSnap.commitId, token)
2223
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2224
+ if (!parentId) {
2225
+ throw new Error('No parent commit found')
2226
+ }
2227
+ commitId = parentId
2228
+ }
2229
+
1831
2230
  if (filePath) {
1832
2231
  // Reset specific file (unstage)
1833
2232
  const metaDir = path.join(dir, '.vcs-next')
@@ -1836,8 +2235,8 @@ async function cmdReset(opts) {
1836
2235
  try {
1837
2236
  const s = await fs.promises.readFile(localPath, 'utf8')
1838
2237
  localMeta = JSON.parse(s)
1839
- } catch {}
1840
-
2238
+ } catch { }
2239
+
1841
2240
  // Restore file from base
1842
2241
  const baseContent = localMeta.baseFiles[filePath]
1843
2242
  if (baseContent !== undefined) {
@@ -1845,7 +2244,7 @@ async function cmdReset(opts) {
1845
2244
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1846
2245
  await fs.promises.writeFile(fp, String(baseContent), 'utf8')
1847
2246
  }
1848
-
2247
+
1849
2248
  if (opts.json === 'true') {
1850
2249
  print({ reset: filePath }, true)
1851
2250
  } else {
@@ -1853,7 +2252,7 @@ async function cmdReset(opts) {
1853
2252
  }
1854
2253
  return
1855
2254
  }
1856
-
2255
+
1857
2256
  // Reset to commit
1858
2257
  let targetSnap
1859
2258
  if (commitId === 'HEAD') {
@@ -1861,7 +2260,7 @@ async function cmdReset(opts) {
1861
2260
  } else {
1862
2261
  targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1863
2262
  }
1864
-
2263
+
1865
2264
  if (mode === 'hard') {
1866
2265
  // Hard reset: discard all changes, reset to commit
1867
2266
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
@@ -1882,14 +2281,14 @@ async function cmdReset(opts) {
1882
2281
  // Mixed reset: keep changes unstaged (default)
1883
2282
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1884
2283
  }
1885
-
2284
+
1886
2285
  // Update metadata
1887
2286
  const metaDir = path.join(dir, '.vcs-next')
1888
2287
  const localPath = path.join(metaDir, 'local.json')
1889
2288
  await fs.promises.mkdir(metaDir, { recursive: true })
1890
2289
  const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
1891
2290
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1892
-
2291
+
1893
2292
  if (opts.json === 'true') {
1894
2293
  print({ reset: commitId, mode }, true)
1895
2294
  } else {
@@ -1897,23 +2296,57 @@ async function cmdReset(opts) {
1897
2296
  }
1898
2297
  }
1899
2298
 
2299
+ async function cmdInit(opts) {
2300
+ const dir = path.resolve(opts.dir || '.')
2301
+ const cfg = loadConfig()
2302
+ const server = getServer(opts, cfg)
2303
+ const token = getToken(opts, cfg)
2304
+ const repo = opts.repo || ''
2305
+ const branch = opts.branch || 'main'
2306
+ const metaDir = path.join(dir, '.vcs-next')
2307
+ const gitDir = path.join(dir, '.git')
2308
+ await fs.promises.mkdir(metaDir, { recursive: true })
2309
+ await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
2310
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
2311
+ await fs.promises.writeFile(path.join(gitDir, 'refs', 'heads', branch), '', 'utf8')
2312
+ const gitConfig = [
2313
+ '[core]',
2314
+ '\trepositoryformatversion = 0',
2315
+ '\tfilemode = true',
2316
+ '\tbare = false',
2317
+ '\tlogallrefupdates = true',
2318
+ '',
2319
+ '[vcs-next]',
2320
+ `\tserver = ${opts.server || server || ''}`,
2321
+ `\trepoId = ${repo}`,
2322
+ `\tbranch = ${branch}`,
2323
+ `\ttoken = ${opts.token || token || ''}`
2324
+ ].join('\n')
2325
+ await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
2326
+ const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
2327
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
2328
+ const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2329
+ await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
2330
+ print({ initialized: dir, branch }, opts.json === 'true')
2331
+ }
2332
+
1900
2333
  async function cmdMv(opts) {
1901
2334
  const dir = path.resolve(opts.dir || '.')
1902
2335
  const from = opts.from
1903
2336
  const to = opts.to
1904
2337
  if (!from || !to) throw new Error('Missing --from and --to')
1905
-
2338
+
1906
2339
  const fromPath = path.join(dir, from)
1907
2340
  const toPath = path.join(dir, to)
1908
-
2341
+
1909
2342
  // Check if source exists
1910
2343
  const stat = await fs.promises.stat(fromPath).catch(() => null)
1911
2344
  if (!stat) throw new Error(`Source file not found: ${from}`)
1912
-
2345
+
1913
2346
  // Move file
1914
2347
  await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
1915
2348
  await fs.promises.rename(fromPath, toPath)
1916
-
2349
+
1917
2350
  // If it's a tracked file, we need to update it in the next commit
1918
2351
  // For now, just move it - the next commit will track the change
1919
2352
  if (opts.json === 'true') {
@@ -1925,7 +2358,7 @@ async function cmdMv(opts) {
1925
2358
 
1926
2359
  async function cmdAdd(opts) {
1927
2360
  const dir = path.resolve(opts.dir || '.')
1928
-
2361
+
1929
2362
  // Handle --all flag
1930
2363
  if (opts.all === 'true' || opts.all === true) {
1931
2364
  const meta = readRemoteMeta(dir)
@@ -1934,7 +2367,7 @@ async function cmdAdd(opts) {
1934
2367
  const token = getToken(opts, cfg) || meta.token
1935
2368
  const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
1936
2369
  const local = await collectLocal(dir)
1937
-
2370
+
1938
2371
  // Stage all changes (add new, modified, delete removed)
1939
2372
  const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
1940
2373
  let stagedCount = 0
@@ -1945,7 +2378,7 @@ async function cmdAdd(opts) {
1945
2378
  stagedCount++
1946
2379
  }
1947
2380
  }
1948
-
2381
+
1949
2382
  if (opts.json === 'true') {
1950
2383
  print({ staged: stagedCount }, true)
1951
2384
  } else {
@@ -1953,7 +2386,7 @@ async function cmdAdd(opts) {
1953
2386
  }
1954
2387
  return
1955
2388
  }
1956
-
2389
+
1957
2390
  const p = opts.path
1958
2391
  if (!p) throw new Error('Missing --path')
1959
2392
  const abs = path.join(dir, p)
@@ -2001,6 +2434,563 @@ async function cmdAdd(opts) {
2001
2434
  print({ added: p, dir }, opts.json === 'true')
2002
2435
  }
2003
2436
 
2437
+ async function cmdRemote(sub, opts) {
2438
+ const dir = path.resolve(opts.dir || '.')
2439
+ const meta = readRemoteMeta(dir)
2440
+ const metaDir = path.join(dir, '.vcs-next')
2441
+ const remoteMetaPath = path.join(metaDir, 'remote.json')
2442
+ let remoteMeta = meta
2443
+ try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
2444
+ if (sub === 'show' || sub === undefined) {
2445
+ print(remoteMeta, opts.json === 'true')
2446
+ return
2447
+ }
2448
+ if (sub === 'set-url') {
2449
+ const server = opts.server || ''
2450
+ if (!server) throw new Error('Missing --server')
2451
+ remoteMeta.server = server
2452
+ await fs.promises.mkdir(metaDir, { recursive: true })
2453
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
2454
+ const gitDir = path.join(dir, '.git')
2455
+ const cfgPath = path.join(gitDir, 'config')
2456
+ let cfgText = ''
2457
+ try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
2458
+ const lines = cfgText.split('\n')
2459
+ let inSec = false
2460
+ const out = []
2461
+ for (const ln of lines) {
2462
+ if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
2463
+ if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
2464
+ if (inSec && ln.trim().startsWith('server =')) { out.push(`\tserver = ${server}`); continue }
2465
+ out.push(ln)
2466
+ }
2467
+ await fs.promises.writeFile(cfgPath, out.join('\n'))
2468
+ print({ server }, opts.json === 'true')
2469
+ return
2470
+ }
2471
+ if (sub === 'set-token') {
2472
+ const token = opts.token || ''
2473
+ if (!token) throw new Error('Missing --token')
2474
+ remoteMeta.token = token
2475
+ await fs.promises.mkdir(metaDir, { recursive: true })
2476
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
2477
+ const gitDir = path.join(dir, '.git')
2478
+ const cfgPath = path.join(gitDir, 'config')
2479
+ let cfgText = ''
2480
+ try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
2481
+ const lines = cfgText.split('\n')
2482
+ let inSec = false
2483
+ const out = []
2484
+ for (const ln of lines) {
2485
+ if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
2486
+ if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
2487
+ if (inSec && ln.trim().startsWith('token =')) { out.push(`\ttoken = ${token}`); continue }
2488
+ out.push(ln)
2489
+ }
2490
+ await fs.promises.writeFile(cfgPath, out.join('\n'))
2491
+ print({ token: 'updated' }, opts.json === 'true')
2492
+ return
2493
+ }
2494
+ throw new Error('Unknown remote subcommand')
2495
+ }
2496
+
2497
+ async function cmdConfig(sub, opts) {
2498
+ if (sub === 'list' || sub === undefined) {
2499
+ print(loadConfig(), opts.json === 'true')
2500
+ return
2501
+ }
2502
+ if (sub === 'get') {
2503
+ const key = opts.key
2504
+ if (!key) throw new Error('Missing --key')
2505
+ const cfg = loadConfig()
2506
+ const val = cfg[key]
2507
+ print({ [key]: val }, opts.json === 'true')
2508
+ return
2509
+ }
2510
+ if (sub === 'set') {
2511
+ const key = opts.key
2512
+ const value = opts.value
2513
+ if (!key) throw new Error('Missing --key')
2514
+ const next = saveConfig({ [key]: value })
2515
+ print({ [key]: next[key] }, opts.json === 'true')
2516
+ return
2517
+ }
2518
+ throw new Error('Unknown config subcommand')
2519
+ }
2520
+
2521
+ async function cmdClean(opts) {
2522
+ const dir = path.resolve(opts.dir || '.')
2523
+ const meta = readRemoteMeta(dir)
2524
+ const cfg = loadConfig()
2525
+ const server = getServer(opts, cfg) || meta.server
2526
+ const token = getToken(opts, cfg) || meta.token
2527
+ const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
2528
+ const local = await collectLocal(dir)
2529
+ const keep = new Set(Object.keys(remoteSnap.files))
2530
+ const toDelete = []
2531
+ for (const p of Object.keys(local)) {
2532
+ if (!keep.has(p)) toDelete.push(p)
2533
+ }
2534
+ const force = opts.force === 'true'
2535
+ if (!force) {
2536
+ print({ untracked: toDelete }, opts.json === 'true')
2537
+ return
2538
+ }
2539
+ for (const rel of toDelete) {
2540
+ const fp = path.join(dir, rel)
2541
+ try { await fs.promises.unlink(fp) } catch { }
2542
+ }
2543
+ const pruneEmptyDirs = async (start) => {
2544
+ const entries = await fs.promises.readdir(start).catch(() => [])
2545
+ for (const name of entries) {
2546
+ if (name === '.git' || name === '.vcs-next') continue
2547
+ const p = path.join(start, name)
2548
+ const st = await fs.promises.stat(p).catch(() => null)
2549
+ if (!st) continue
2550
+ if (st.isDirectory()) {
2551
+ await pruneEmptyDirs(p)
2552
+ const left = await fs.promises.readdir(p).catch(() => [])
2553
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
2554
+ }
2555
+ }
2556
+ }
2557
+ await pruneEmptyDirs(dir)
2558
+ print({ cleaned: toDelete.length }, opts.json === 'true')
2559
+ }
2560
+
2561
+ async function cmdRebase(opts) {
2562
+ const dir = path.resolve(opts.dir || '.')
2563
+ const meta = readRemoteMeta(dir)
2564
+ const cfg = loadConfig()
2565
+ const server = getServer(opts, cfg) || meta.server
2566
+ const token = getToken(opts, cfg) || meta.token
2567
+ const sourceBranch = opts.branch || ''
2568
+ const onto = opts.onto || meta.branch
2569
+ if (!sourceBranch) throw new Error('Missing --branch')
2570
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
2571
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2572
+ commitsUrl.searchParams.set('branch', sourceBranch)
2573
+ const res = await fetch(commitsUrl.toString(), { headers })
2574
+ if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(t || 'commits fetch failed') }
2575
+ const list = await res.json()
2576
+ const ids = Array.isArray(list) ? list.map((c) => c.id || c._id || '').filter(Boolean) : []
2577
+ for (const id of ids) {
2578
+ await cmdCherryPick({ dir, commit: id, branch: onto, noPush: 'true' })
2579
+ const unresolved = await checkForUnresolvedConflicts(dir)
2580
+ if (unresolved.length > 0) {
2581
+ if (opts.json === 'true') {
2582
+ print({ error: 'Rebase stopped due to conflicts', conflicts: unresolved }, true)
2583
+ } else {
2584
+ process.stderr.write(color('Rebase stopped due to conflicts\n', 'red'))
2585
+ }
2586
+ return
2587
+ }
2588
+ }
2589
+ const metaDir = path.join(dir, '.vcs-next')
2590
+ const localPath = path.join(metaDir, 'local.json')
2591
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2592
+ try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
2593
+ const message = opts.message || `rebase ${sourceBranch} onto ${onto}`
2594
+ if (opts.noPush !== 'true') {
2595
+ await cmdPush({ dir, json: opts.json, message })
2596
+ } else {
2597
+ print({ rebased: sourceBranch, onto }, opts.json === 'true')
2598
+ }
2599
+ }
2600
+
2601
+ async function cmdBlame(opts) {
2602
+ const dir = path.resolve(opts.dir || '.')
2603
+ const filePath = opts.path
2604
+ if (!filePath) throw new errors.ValidationError('Missing --path', 'path')
2605
+
2606
+ const validPath = validation.validateFilePath(filePath)
2607
+ const meta = readRemoteMeta(dir)
2608
+ const cfg = loadConfig()
2609
+ const server = getServer(opts, cfg) || meta.server
2610
+ const token = getToken(opts, cfg) || meta.token
2611
+
2612
+ const spinner = createSpinner(`Getting blame for ${validPath}...`, opts.json)
2613
+
2614
+ try {
2615
+ // Get file content
2616
+ const local = await collectLocal(dir)
2617
+ if (!local[validPath]) {
2618
+ throw new errors.FileSystemError(`File not found: ${validPath}`, validPath, 'read')
2619
+ }
2620
+
2621
+ // Get commits
2622
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2623
+ commitsUrl.searchParams.set('branch', meta.branch)
2624
+ const commitsRes = await fetch(commitsUrl.toString(), {
2625
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
2626
+ })
2627
+
2628
+ if (!commitsRes.ok) {
2629
+ throw new errors.NetworkError('Failed to fetch commits', commitsRes.status, commitsUrl.toString())
2630
+ }
2631
+
2632
+ const commits = await commitsRes.json()
2633
+ const blameData = parseBlame(local[validPath].content, commits, validPath)
2634
+
2635
+ spinnerSuccess(spinner, `Blame for ${validPath}`)
2636
+
2637
+ if (opts.json === 'true') {
2638
+ print(formatBlameJson(blameData), false)
2639
+ } else {
2640
+ process.stdout.write(formatBlameOutput(blameData) + '\n')
2641
+ }
2642
+ } catch (err) {
2643
+ spinnerFail(spinner, 'Blame failed')
2644
+ throw err
2645
+ }
2646
+ }
2647
+
2648
+ async function cmdLog(opts) {
2649
+ const dir = path.resolve(opts.dir || '.')
2650
+ const meta = readRemoteMeta(dir)
2651
+ const cfg = loadConfig()
2652
+ const server = getServer(opts, cfg) || meta.server
2653
+ const token = getToken(opts, cfg) || meta.token
2654
+
2655
+ const spinner = createSpinner('Fetching commit history...', opts.json)
2656
+
2657
+ try {
2658
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2659
+ if (opts.branch) url.searchParams.set('branch', opts.branch)
2660
+ else url.searchParams.set('branch', meta.branch)
2661
+
2662
+ const data = await request('GET', url.toString(), null, token)
2663
+ let commits = Array.isArray(data) ? data : []
2664
+
2665
+ // Filter by file path if provided
2666
+ const filePath = opts.path
2667
+ if (filePath) {
2668
+ const filteredCommits = []
2669
+ for (const commit of commits) {
2670
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2671
+ if (commitSnap.files[filePath] !== undefined) {
2672
+ filteredCommits.push(commit)
2673
+ }
2674
+ }
2675
+ commits = filteredCommits
2676
+ }
2677
+
2678
+ // Filter by content pattern (-G flag)
2679
+ const pattern = opts.G || opts.pattern
2680
+ if (pattern) {
2681
+ const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
2682
+ const filteredCommits = []
2683
+ for (const commit of commits) {
2684
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2685
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2686
+ const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {} }
2687
+
2688
+ // Check if pattern matches in any file changed in this commit
2689
+ const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
2690
+ let matches = false
2691
+ for (const p of allPaths) {
2692
+ const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : ''
2693
+ const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : ''
2694
+ if (regex.test(oldContent) || regex.test(newContent)) {
2695
+ matches = true
2696
+ break
2697
+ }
2698
+ }
2699
+ if (matches) {
2700
+ filteredCommits.push(commit)
2701
+ }
2702
+ }
2703
+ commits = filteredCommits
2704
+ }
2705
+
2706
+ spinnerSuccess(spinner, `Found ${commits.length} commits`)
2707
+
2708
+ if (opts.json === 'true') {
2709
+ print(commits, true)
2710
+ } else if (opts.oneline === 'true') {
2711
+ process.stdout.write(formatCompactLog(commits) + '\n')
2712
+ } else if (opts.stats === 'true') {
2713
+ const stats = generateCommitStats(commits)
2714
+ process.stdout.write(color('Commit Statistics\n', 'bold'))
2715
+ process.stdout.write(color('━'.repeat(50) + '\n', 'dim'))
2716
+ process.stdout.write(`Total commits: ${stats.totalCommits}\n`)
2717
+ process.stdout.write(`\nTop authors:\n`)
2718
+ for (const author of stats.topAuthors) {
2719
+ process.stdout.write(` ${author.name.padEnd(30)} ${color(author.commits + ' commits', 'cyan')}\n`)
2720
+ }
2721
+ if (stats.datesRange.earliest && stats.datesRange.latest) {
2722
+ process.stdout.write(`\nDate range: ${stats.datesRange.earliest.toISOString().split('T')[0]} to ${stats.datesRange.latest.toISOString().split('T')[0]}\n`)
2723
+ }
2724
+ } else {
2725
+ const maxCommits = parseInt(opts.max || '50', 10)
2726
+ process.stdout.write(generateLogGraph(commits, { maxCommits }) + '\n')
2727
+ }
2728
+ } catch (err) {
2729
+ spinnerFail(spinner, 'Log fetch failed')
2730
+ throw err
2731
+ }
2732
+ }
2733
+
2734
+ async function cmdHook(sub, opts) {
2735
+ const dir = path.resolve(opts.dir || '.')
2736
+
2737
+ if (sub === 'list') {
2738
+ const hooksList = await hooks.listHooks(dir)
2739
+ if (opts.json === 'true') {
2740
+ print(hooksList, true)
2741
+ } else {
2742
+ if (hooksList.length === 0) {
2743
+ process.stdout.write('No hooks installed.\n')
2744
+ } else {
2745
+ process.stdout.write(color('Installed Hooks:\n', 'bold'))
2746
+ for (const hook of hooksList) {
2747
+ const exe = hook.executable ? color('✓', 'green') : color('✗', 'red')
2748
+ process.stdout.write(` ${exe} ${color(hook.name, 'cyan')}\n`)
2749
+ }
2750
+ }
2751
+ }
2752
+ return
2753
+ }
2754
+
2755
+ if (sub === 'install') {
2756
+ const hookName = opts.name
2757
+ if (!hookName) throw new Error('Missing --name')
2758
+
2759
+ let script = opts.script
2760
+ if (!script && opts.sample === 'true') {
2761
+ script = hooks.SAMPLE_HOOKS[hookName]
2762
+ if (!script) throw new Error(`No sample available for ${hookName}`)
2763
+ }
2764
+ if (!script) throw new Error('Missing --script or use --sample')
2765
+
2766
+ const result = await hooks.installHook(dir, hookName, script)
2767
+ print(result, opts.json === 'true')
2768
+ if (opts.json !== 'true') {
2769
+ process.stdout.write(color(`✓ Hook '${hookName}' installed\n`, 'green'))
2770
+ }
2771
+ return
2772
+ }
2773
+
2774
+ if (sub === 'remove') {
2775
+ const hookName = opts.name
2776
+ if (!hookName) throw new Error('Missing --name')
2777
+
2778
+ const result = await hooks.removeHook(dir, hookName)
2779
+ print(result, opts.json === 'true')
2780
+ return
2781
+ }
2782
+
2783
+ if (sub === 'show') {
2784
+ const hookName = opts.name
2785
+ if (!hookName) throw new Error('Missing --name')
2786
+
2787
+ const result = await hooks.readHook(dir, hookName)
2788
+ if (result.content) {
2789
+ process.stdout.write(result.content + '\n')
2790
+ } else {
2791
+ process.stdout.write(`Hook '${hookName}' not found.\n`)
2792
+ }
2793
+ return
2794
+ }
2795
+
2796
+ throw new Error('Unknown hook subcommand. Use: list, install, remove, show')
2797
+ }
2798
+
2799
+ async function cmdGrep(opts) {
2800
+ const dir = path.resolve(opts.dir || '.')
2801
+ const pattern = opts.pattern || opts.p || ''
2802
+ if (!pattern) throw new Error('Missing --pattern or -p')
2803
+
2804
+ const meta = readRemoteMeta(dir)
2805
+ const local = await collectLocal(dir)
2806
+ const results = []
2807
+
2808
+ const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
2809
+
2810
+ for (const [filePath, fileData] of Object.entries(local)) {
2811
+ const lines = fileData.content.split(/\r?\n/)
2812
+ for (let i = 0; i < lines.length; i++) {
2813
+ if (regex.test(lines[i])) {
2814
+ results.push({
2815
+ path: filePath,
2816
+ line: i + 1,
2817
+ content: lines[i].trim()
2818
+ })
2819
+ }
2820
+ }
2821
+ }
2822
+
2823
+ if (opts.json === 'true') {
2824
+ print(results, true)
2825
+ } else {
2826
+ for (const result of results) {
2827
+ process.stdout.write(color(`${result.path}:${result.line}`, 'cyan'))
2828
+ process.stdout.write(`: ${result.content}\n`)
2829
+ }
2830
+ }
2831
+ }
2832
+
2833
+ async function cmdLsFiles(opts) {
2834
+ const dir = path.resolve(opts.dir || '.')
2835
+ const meta = readRemoteMeta(dir)
2836
+ const cfg = loadConfig()
2837
+ const server = getServer(opts, cfg) || meta.server
2838
+ const token = getToken(opts, cfg) || meta.token
2839
+
2840
+ const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
2841
+ const files = Object.keys(remote.map)
2842
+
2843
+ if (opts.json === 'true') {
2844
+ print(files.map(f => ({ path: f })), true)
2845
+ } else {
2846
+ for (const file of files) {
2847
+ process.stdout.write(`${file}\n`)
2848
+ }
2849
+ }
2850
+ }
2851
+
2852
+ async function cmdReflog(opts) {
2853
+ const dir = path.resolve(opts.dir || '.')
2854
+ const metaDir = path.join(dir, '.vcs-next')
2855
+ const reflogPath = path.join(metaDir, 'reflog.json')
2856
+
2857
+ let reflog = []
2858
+ try {
2859
+ const content = await fs.promises.readFile(reflogPath, 'utf8')
2860
+ reflog = JSON.parse(content)
2861
+ } catch {}
2862
+
2863
+ if (opts.json === 'true') {
2864
+ print(reflog, true)
2865
+ } else {
2866
+ if (reflog.length === 0) {
2867
+ process.stdout.write('No reflog entries.\n')
2868
+ } else {
2869
+ for (const entry of reflog) {
2870
+ const date = new Date(entry.timestamp).toLocaleString()
2871
+ process.stdout.write(`${color(entry.commitId.slice(0, 7), 'yellow')} ${entry.action} ${date} ${entry.message || ''}\n`)
2872
+ }
2873
+ }
2874
+ }
2875
+ }
2876
+
2877
+ async function cmdCatFile(opts) {
2878
+ const dir = path.resolve(opts.dir || '.')
2879
+ const type = opts.type || ''
2880
+ const object = opts.object || ''
2881
+
2882
+ if (!type || !object) throw new Error('Missing --type and --object')
2883
+
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
+ if (type === 'blob') {
2890
+ const snap = await fetchSnapshotByCommit(server, meta.repoId, object, token)
2891
+ const filePath = opts.path || ''
2892
+ if (filePath && snap.files[filePath]) {
2893
+ process.stdout.write(String(snap.files[filePath]))
2894
+ } else {
2895
+ throw new Error('File not found in commit')
2896
+ }
2897
+ } else if (type === 'commit') {
2898
+ const commit = await fetchCommitMeta(server, meta.repoId, object, token)
2899
+ if (opts.json === 'true') {
2900
+ print(commit, true)
2901
+ } else {
2902
+ process.stdout.write(`commit ${commit.id || commit._id}\n`)
2903
+ process.stdout.write(`Author: ${commit.author?.name || ''} <${commit.author?.email || ''}>\n`)
2904
+ process.stdout.write(`Date: ${new Date(commit.createdAt || commit.committer?.date || '').toLocaleString()}\n\n`)
2905
+ process.stdout.write(`${commit.message || ''}\n`)
2906
+ }
2907
+ } else {
2908
+ throw new Error(`Unsupported type: ${type}`)
2909
+ }
2910
+ }
2911
+
2912
+ async function cmdRevParse(opts) {
2913
+ const dir = path.resolve(opts.dir || '.')
2914
+ const rev = opts.rev || 'HEAD'
2915
+
2916
+ const meta = readRemoteMeta(dir)
2917
+ const cfg = loadConfig()
2918
+ const server = getServer(opts, cfg) || meta.server
2919
+ const token = getToken(opts, cfg) || meta.token
2920
+
2921
+ if (rev === 'HEAD') {
2922
+ const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
2923
+ const found = (info.branches || []).find(b => b.name === meta.branch)
2924
+ const commitId = found ? (found.commitId || '') : ''
2925
+ print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
2926
+ } else if (rev.startsWith('refs/heads/')) {
2927
+ const branchName = rev.replace('refs/heads/', '')
2928
+ const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
2929
+ const found = (info.branches || []).find(b => b.name === branchName)
2930
+ const commitId = found ? (found.commitId || '') : ''
2931
+ print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
2932
+ } else {
2933
+ // Assume it's a commit ID
2934
+ print(opts.json === 'true' ? { rev, commitId: rev } : rev, opts.json === 'true')
2935
+ }
2936
+ }
2937
+
2938
+ async function cmdDescribe(opts) {
2939
+ const dir = path.resolve(opts.dir || '.')
2940
+ const commitId = opts.commit || 'HEAD'
2941
+
2942
+ const meta = readRemoteMeta(dir)
2943
+ const cfg = loadConfig()
2944
+ const server = getServer(opts, cfg) || meta.server
2945
+ const token = getToken(opts, cfg) || meta.token
2946
+
2947
+ // Get tags
2948
+ const tagsUrl = new URL(`/api/repositories/${meta.repoId}/tags`, server)
2949
+ const tags = await request('GET', tagsUrl.toString(), null, token)
2950
+ const tagsList = Array.isArray(tags) ? tags : []
2951
+
2952
+ // Find nearest tag (simplified - just find any tag)
2953
+ const nearestTag = tagsList[0]
2954
+
2955
+ if (nearestTag) {
2956
+ const desc = `${nearestTag.name}-0-g${commitId.slice(0, 7)}`
2957
+ print(opts.json === 'true' ? { tag: nearestTag.name, commitId, describe: desc } : desc, opts.json === 'true')
2958
+ } else {
2959
+ print(opts.json === 'true' ? { commitId, describe: commitId.slice(0, 7) } : commitId.slice(0, 7), opts.json === 'true')
2960
+ }
2961
+ }
2962
+
2963
+ async function cmdShortlog(opts) {
2964
+ const dir = path.resolve(opts.dir || '.')
2965
+ const meta = readRemoteMeta(dir)
2966
+ const cfg = loadConfig()
2967
+ const server = getServer(opts, cfg) || meta.server
2968
+ const token = getToken(opts, cfg) || meta.token
2969
+
2970
+ const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2971
+ if (opts.branch) url.searchParams.set('branch', opts.branch)
2972
+ else url.searchParams.set('branch', meta.branch)
2973
+
2974
+ const commits = await request('GET', url.toString(), null, token)
2975
+ const commitsList = Array.isArray(commits) ? commits : []
2976
+
2977
+ const stats = generateCommitStats(commitsList)
2978
+
2979
+ if (opts.json === 'true') {
2980
+ print(stats, true)
2981
+ } else {
2982
+ for (const author of stats.topAuthors) {
2983
+ process.stdout.write(`\n${color(author.name, 'bold')} (${author.commits} commit${author.commits !== 1 ? 's' : ''})\n`)
2984
+ // Show commit messages for this author
2985
+ const authorCommits = commitsList.filter(c => (c.author?.name || 'Unknown') === author.name)
2986
+ for (const commit of authorCommits.slice(0, opts.max ? parseInt(opts.max, 10) : 10)) {
2987
+ const msg = (commit.message || 'No message').split('\n')[0].slice(0, 60)
2988
+ process.stdout.write(` ${color((commit.id || commit._id || '').slice(0, 7), 'yellow')} ${msg}\n`)
2989
+ }
2990
+ }
2991
+ }
2992
+ }
2993
+
2004
2994
  function help() {
2005
2995
  const h = [
2006
2996
  'Usage: resulgit <group> <command> [options]',
@@ -2012,40 +3002,57 @@ function help() {
2012
3002
  ' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
2013
3003
  ' repo list [--json]',
2014
3004
  ' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
2015
- ' repo log --repo <id> [--branch <name>] [--json]',
2016
- ' repo head --repo <id> [--branch <name>] [--json]',
2017
- ' repo select [--workspace] (interactive select and clone/open)',
2018
- ' branch list|create|delete [--dir <path>] [--name <branch>] [--base <branch>]',
2019
- ' switch --branch <name> [--dir <path>]',
3005
+ ' repo log --repo <id> [--branch <name>] [--json]',
3006
+ ' repo head --repo <id> [--branch <name>] [--json]',
3007
+ ' repo select [--workspace] (interactive select and clone/open)',
3008
+ ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>] [--sort <field>] [--force|-D]',
3009
+ ' switch --branch <name> [--create|-c] [--dir <path>]',
3010
+ ' checkout --branch <name> [--create|-b] [--commit <id>] [--dir <path>]',
2020
3011
  ' current [--dir <path>] (show active repo/branch)',
2021
3012
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
2022
3013
  ' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
2023
3014
  ' status [--dir <path>] [--json]',
2024
- ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
2025
- ' commit --message <text> [--dir <path>] [--json]',
2026
- ' push [--dir <path>] [--json]',
2027
- ' head [--dir <path>] [--json]',
2028
- ' rm --path <file> [--dir <path>] [--json]',
3015
+ ' diff [--dir <path>] [--path <file>] [--commit <id>] [--commit1 <id>] [--commit2 <id>] [--stat] [--json]',
3016
+ ' commit --message <text> [--all|-a] [--amend] [--dir <path>] [--json]',
3017
+ ' push [--dir <path>] [--json]',
3018
+ ' head [--dir <path>] [--json]',
3019
+ ' rm --path <file> [--cached] [--dir <path>] [--json]',
2029
3020
  ' pull [--dir <path>]',
2030
- ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
2031
- ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
2032
- ' restore --path <file> [--source <commit>] [--dir <path>]',
2033
- ' revert --commit <id> [--no-push] [--dir <path>]',
2034
- ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2035
- ' show --commit <id> [--dir <path>] [--json]',
2036
- ' mv --from <old> --to <new> [--dir <path>]',
2037
- '',
2038
- 'Conflict Resolution:',
2039
- ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
2040
- ' <<<<<<< HEAD (current changes)',
2041
- ' =======',
2042
- ' >>>>>>> incoming (incoming changes)',
2043
- ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
2044
- ' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
2045
- ' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
2046
- ' workspace set-root --path <dir>',
2047
- ' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
2048
- ' checkout <branch>|--branch <name> | --commit <id> [--dir <path>]',
3021
+ ' fetch [--dir <path>]',
3022
+ ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
3023
+ ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
3024
+ ' restore --path <file> [--source <commit>] [--dir <path>]',
3025
+ ' revert --commit <id> [--no-push] [--dir <path>]',
3026
+ ' reset [--commit <id>|HEAD^] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
3027
+ ' show --commit <id> [--dir <path>] [--json]',
3028
+ ' mv --from <old> --to <new> [--dir <path>]',
3029
+ ' blame --path <file> [--dir <path>] [--json] - Show line-by-line authorship',
3030
+ ' log [--branch <name>] [--max <N>] [--oneline] [--stats] [--path <file>] [-G <pattern>] [--json] - Show commit history',
3031
+ ' hook list|install|remove|show [--name <hook>] [--script <code>] [--sample] - Manage Git hooks',
3032
+ ' grep --pattern <pattern> [--ignore-case] [--dir <path>] [--json] - Search in repository',
3033
+ ' ls-files [--dir <path>] [--json] - List tracked files',
3034
+ ' reflog [--dir <path>] [--json] - Show reference log',
3035
+ ' cat-file --type <type> --object <id> [--path <file>] [--dir <path>] [--json] - Display file contents',
3036
+ ' rev-parse --rev <ref> [--dir <path>] [--json] - Parse revision names',
3037
+ ' describe [--commit <id>] [--dir <path>] [--json] - Describe a commit',
3038
+ ' shortlog [--branch <name>] [--max <N>] [--dir <path>] [--json] - Summarize commit log',
3039
+ '',
3040
+ 'Conflict Resolution:',
3041
+ ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
3042
+ ' <<<<<<< HEAD (current changes)',
3043
+ ' =======',
3044
+ ' >>>>>>> incoming (incoming changes)',
3045
+ ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
3046
+ ' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
3047
+ ' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
3048
+ ' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
3049
+ ' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
3050
+ ' config list|get|set [--key <k>] [--value <v>]',
3051
+ ' clean [--dir <path>] [--force]',
3052
+ ' workspace set-root --path <dir>',
3053
+ ' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
3054
+ ' checkout <branch>|--branch <name> | --commit <id> [--create] [--dir <path>]',
3055
+ ' rebase --branch <name> [--onto <name>] [--no-push] [--dir <path>]',
2049
3056
  '',
2050
3057
  'Global options:',
2051
3058
  ' --server <url> Override default server',
@@ -2071,7 +3078,6 @@ async function main() {
2071
3078
  return
2072
3079
  }
2073
3080
  if (cmd[0] === 'clone') {
2074
- print({ url: 'Rajaram' }, opts.json === 'true')
2075
3081
  if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
2076
3082
  if (opts.url) {
2077
3083
  await cmdCloneFromUrl(opts, cfg)
@@ -2104,6 +3110,10 @@ async function main() {
2104
3110
  await cmdPull(opts)
2105
3111
  return
2106
3112
  }
3113
+ if (cmd[0] === 'fetch') {
3114
+ await cmdFetch(opts)
3115
+ return
3116
+ }
2107
3117
  if (cmd[0] === 'branch') {
2108
3118
  await cmdBranch(cmd[1], opts)
2109
3119
  return
@@ -2148,6 +3158,26 @@ async function main() {
2148
3158
  await cmdMv(opts)
2149
3159
  return
2150
3160
  }
3161
+ if (cmd[0] === 'init') {
3162
+ await cmdInit(opts)
3163
+ return
3164
+ }
3165
+ if (cmd[0] === 'remote') {
3166
+ await cmdRemote(cmd[1], opts)
3167
+ return
3168
+ }
3169
+ if (cmd[0] === 'config') {
3170
+ await cmdConfig(cmd[1], opts)
3171
+ return
3172
+ }
3173
+ if (cmd[0] === 'clean') {
3174
+ await cmdClean(opts)
3175
+ return
3176
+ }
3177
+ if (cmd[0] === 'rebase') {
3178
+ await cmdRebase(opts)
3179
+ return
3180
+ }
2151
3181
  if (cmd[0] === 'cherry-pick') {
2152
3182
  await cmdCherryPick(opts)
2153
3183
  return
@@ -2180,6 +3210,46 @@ async function main() {
2180
3210
  await cmdAdd(opts)
2181
3211
  return
2182
3212
  }
3213
+ if (cmd[0] === 'blame') {
3214
+ await cmdBlame(opts)
3215
+ return
3216
+ }
3217
+ if (cmd[0] === 'log') {
3218
+ await cmdLog(opts)
3219
+ return
3220
+ }
3221
+ if (cmd[0] === 'hook') {
3222
+ await cmdHook(cmd[1], opts)
3223
+ return
3224
+ }
3225
+ if (cmd[0] === 'grep') {
3226
+ await cmdGrep(opts)
3227
+ return
3228
+ }
3229
+ if (cmd[0] === 'ls-files') {
3230
+ await cmdLsFiles(opts)
3231
+ return
3232
+ }
3233
+ if (cmd[0] === 'reflog') {
3234
+ await cmdReflog(opts)
3235
+ return
3236
+ }
3237
+ if (cmd[0] === 'cat-file') {
3238
+ await cmdCatFile(opts)
3239
+ return
3240
+ }
3241
+ if (cmd[0] === 'rev-parse') {
3242
+ await cmdRevParse(opts)
3243
+ return
3244
+ }
3245
+ if (cmd[0] === 'describe') {
3246
+ await cmdDescribe(opts)
3247
+ return
3248
+ }
3249
+ if (cmd[0] === 'shortlog') {
3250
+ await cmdShortlog(opts)
3251
+ return
3252
+ }
2183
3253
  throw new Error('Unknown command')
2184
3254
  }
2185
3255
 
@@ -2316,7 +3386,7 @@ async function tuiSelectBranch(branches, current) {
2316
3386
  process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
2317
3387
  }
2318
3388
  const cleanup = (listener) => {
2319
- try { process.stdin.removeListener('data', listener) } catch {}
3389
+ try { process.stdin.removeListener('data', listener) } catch { }
2320
3390
  process.stdin.setRawMode(false)
2321
3391
  process.stdin.pause()
2322
3392
  process.stdout.write('\x1b[?25h')