resulgit 1.0.1 → 1.0.2

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 (2) hide show
  1. package/package.json +14 -7
  2. package/resulgit.js +730 -348
package/resulgit.js CHANGED
@@ -3,9 +3,27 @@ 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')
6
7
  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
8
  function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
8
9
 
10
+ function createSpinner(text, jsonMode) {
11
+ if (jsonMode === 'true' || !process.stdout.isTTY) return null
12
+ return ora(text).start()
13
+ }
14
+
15
+ function spinnerSuccess(spinner, text) {
16
+ if (spinner) spinner.succeed(text)
17
+ }
18
+
19
+ function spinnerFail(spinner, text) {
20
+ if (spinner) spinner.fail(text)
21
+ }
22
+
23
+ function spinnerUpdate(spinner, text) {
24
+ if (spinner) spinner.text = text
25
+ }
26
+
9
27
  function parseArgs(argv) {
10
28
  const tokens = argv.slice(2)
11
29
  const cmd = []
@@ -106,11 +124,17 @@ async function cmdAuth(sub, opts) {
106
124
  const password = opts.password
107
125
  if (!email || !password) throw new Error('Missing --email and --password')
108
126
  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')
127
+ const spinner = createSpinner('Logging in...', opts.json)
128
+ try {
129
+ const res = await request('POST', url, { email, password }, '')
130
+ const token = res.token || ''
131
+ if (token) saveConfig({ token })
132
+ spinnerSuccess(spinner, `Logged in as ${email}`)
133
+ print(res, opts.json === 'true')
134
+ } catch (err) {
135
+ spinnerFail(spinner, 'Login failed')
136
+ throw err
137
+ }
114
138
  return
115
139
  }
116
140
  if (sub === 'register') {
@@ -121,10 +145,17 @@ async function cmdAuth(sub, opts) {
121
145
  const displayName = opts.displayName || username
122
146
  if (!username || !email || !password) throw new Error('Missing --username --email --password')
123
147
  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')
148
+ const spinner = createSpinner('Registering account...', opts.json)
149
+ try {
150
+ const res = await request('POST', url, { username, email, password, displayName }, '')
151
+ const token = res.token || ''
152
+ if (token) saveConfig({ token })
153
+ spinnerSuccess(spinner, `Registered as ${username}`)
154
+ print(res, opts.json === 'true')
155
+ } catch (err) {
156
+ spinnerFail(spinner, 'Registration failed')
157
+ throw err
158
+ }
128
159
  return
129
160
  }
130
161
  throw new Error('Unknown auth subcommand')
@@ -134,10 +165,17 @@ async function cmdRepo(sub, opts, cfg) {
134
165
  const server = getServer(opts, cfg)
135
166
  const token = getToken(opts, cfg)
136
167
  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)
168
+ const spinner = createSpinner('Fetching repositories...', opts.json)
169
+ try {
170
+ const url = new URL('/api/repositories', server).toString()
171
+ const data = await request('GET', url, null, token)
172
+ spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
173
+ print(data, opts.json === 'true')
174
+ await tuiSelectRepo(data || [], cfg, opts)
175
+ } catch (err) {
176
+ spinnerFail(spinner, 'Failed to fetch repositories')
177
+ throw err
178
+ }
141
179
  return
142
180
  }
143
181
  if (sub === 'create') {
@@ -148,14 +186,21 @@ async function cmdRepo(sub, opts, cfg) {
148
186
  initializeWithReadme: opts.init === 'true'
149
187
  }
150
188
  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')
189
+ const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
154
190
  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 {}
191
+ const url = new URL('/api/repositories', server).toString()
192
+ const data = await request('POST', url, body, token)
193
+ spinnerSuccess(spinner, `Repository '${body.name}' created`)
194
+ print(data, opts.json === 'true')
195
+ try {
196
+ const repoId = String(data.id || '')
197
+ const branch = String(data.defaultBranch || 'main')
198
+ if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
199
+ } catch { }
200
+ } catch (err) {
201
+ spinnerFail(spinner, `Failed to create repository '${body.name}'`)
202
+ throw err
203
+ }
159
204
  return
160
205
  }
161
206
  if (sub === 'log') {
@@ -224,91 +269,105 @@ async function cmdClone(opts, cfg) {
224
269
  const branch = opts.branch
225
270
  let dest = opts.dest
226
271
  if (!repo || !branch) throw new Error('Missing --repo and --branch')
272
+ const spinner = createSpinner('Initializing clone...', opts.json)
227
273
  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 {
274
+ try {
275
+ if (!dest) {
276
+ spinnerUpdate(spinner, 'Fetching repository info...')
277
+ try {
278
+ const infoUrl = new URL(`/api/repositories/${repo}`, server)
279
+ const infoRes = await fetch(infoUrl.toString(), { headers })
280
+ if (infoRes.ok) {
281
+ const info = await infoRes.json()
282
+ const name = info.name || String(repo)
283
+ dest = name
284
+ } else {
285
+ dest = String(repo)
286
+ }
287
+ } catch {
237
288
  dest = String(repo)
238
289
  }
239
- } catch {
240
- dest = String(repo)
241
290
  }
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))
291
+ dest = path.resolve(dest)
292
+ spinnerUpdate(spinner, `Downloading snapshot from branch '${branch}'...`)
293
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
294
+ url.searchParams.set('branch', branch)
295
+ const res = await fetch(url.toString(), { headers })
296
+ if (!res.ok) {
297
+ const text = await res.text()
298
+ throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
300
299
  }
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))
300
+ const data = await res.json()
301
+ const files = data.files || {}
302
+ const fileCount = Object.keys(files).length
303
+ spinnerUpdate(spinner, `Writing ${fileCount} files to ${dest}...`)
304
+ const root = dest
305
+ for (const [p, content] of Object.entries(files)) {
306
+ const fullPath = path.join(root, p)
307
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
308
+ await fs.promises.writeFile(fullPath, content, 'utf8')
309
309
  }
310
- } catch {}
311
- print('Clone complete', opts.json === 'true')
310
+ spinnerUpdate(spinner, 'Setting up repository metadata...')
311
+ const metaDir = path.join(root, '.vcs-next')
312
+ await fs.promises.mkdir(metaDir, { recursive: true })
313
+ const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
314
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
315
+ const gitDir = path.join(root, '.git')
316
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
317
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
318
+ const headContent = `ref: refs/heads/${branch}\\n`
319
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
320
+ const commitId = data.commitId || ''
321
+ await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
322
+ const gitConfig = [
323
+ '[core]',
324
+ '\\trepositoryformatversion = 0',
325
+ '\\tfilemode = true',
326
+ '\\tbare = false',
327
+ '\\tlogallrefupdates = true',
328
+ '',
329
+ '[vcs-next]',
330
+ `\\tserver = ${server}`,
331
+ `\\trepoId = ${repo}`,
332
+ `\\tbranch = ${branch}`,
333
+ `\\ttoken = ${token || ''}`
334
+ ].join('\\n')
335
+ await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
336
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
337
+ spinnerUpdate(spinner, 'Fetching branch information...')
338
+ try {
339
+ const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
340
+ const branchesRes = await fetch(branchesUrl.toString(), { headers })
341
+ if (branchesRes.ok) {
342
+ const branchesData = await branchesRes.json()
343
+ const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
344
+ const allRefs = {}
345
+ for (const b of branches) {
346
+ const name = b.name || ''
347
+ const id = b.commitId || ''
348
+ if (!name) continue
349
+ allRefs[name] = id
350
+ await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
351
+ }
352
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
353
+ }
354
+ } catch { }
355
+ spinnerUpdate(spinner, 'Fetching commit history...')
356
+ try {
357
+ const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
358
+ commitsUrl.searchParams.set('branch', branch)
359
+ const commitsRes = await fetch(commitsUrl.toString(), { headers })
360
+ if (commitsRes.ok) {
361
+ const commitsList = await commitsRes.json()
362
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
363
+ }
364
+ } catch { }
365
+ spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
366
+ print('Clone complete', opts.json === 'true')
367
+ } catch (err) {
368
+ spinnerFail(spinner, 'Clone failed')
369
+ throw err
370
+ }
312
371
  }
313
372
 
314
373
  function readRemoteMeta(dir) {
@@ -324,7 +383,7 @@ function readRemoteMeta(dir) {
324
383
  const s = fs.readFileSync(fp, 'utf8')
325
384
  const meta = JSON.parse(s)
326
385
  if (meta.repoId && meta.branch) return meta
327
- } catch {}
386
+ } catch { }
328
387
  }
329
388
  const parent = path.dirname(cur)
330
389
  if (parent === cur) break
@@ -421,7 +480,7 @@ async function cmdRestore(opts) {
421
480
  const cfg = loadConfig()
422
481
  const server = getServer(opts, cfg) || meta.server
423
482
  const token = getToken(opts, cfg) || meta.token
424
-
483
+
425
484
  const sourceCommit = opts.source || 'HEAD'
426
485
  let sourceSnap
427
486
  if (sourceCommit === 'HEAD') {
@@ -429,7 +488,7 @@ async function cmdRestore(opts) {
429
488
  } else {
430
489
  sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
431
490
  }
432
-
491
+
433
492
  const sourceContent = sourceSnap.files[filePath]
434
493
  if (sourceContent === undefined) {
435
494
  // File doesn't exist in source - delete it
@@ -464,17 +523,17 @@ async function cmdDiff(opts) {
464
523
  const cfg = loadConfig()
465
524
  const server = getServer(opts, cfg) || meta.server
466
525
  const token = getToken(opts, cfg) || meta.token
467
-
526
+
468
527
  const filePath = opts.path
469
528
  const commitId = opts.commit
470
-
529
+
471
530
  if (commitId) {
472
531
  // Show diff for specific commit
473
532
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
474
533
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
475
534
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
476
535
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
477
-
536
+
478
537
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
479
538
  for (const p of files) {
480
539
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
@@ -510,18 +569,18 @@ async function cmdDiff(opts) {
510
569
  }
511
570
  return
512
571
  }
513
-
572
+
514
573
  // Show diff for working directory
515
574
  const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
516
575
  const local = await collectLocal(dir)
517
576
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
518
-
577
+
519
578
  for (const p of files) {
520
579
  const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
521
580
  const localContent = local[p]?.content || null
522
581
  const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
523
582
  const localId = local[p]?.id
524
-
583
+
525
584
  if (remoteId !== localId) {
526
585
  if (opts.json === 'true') {
527
586
  print({ path: p, remote: remoteContent, local: localContent }, true)
@@ -578,41 +637,51 @@ async function cmdCommit(opts) {
578
637
  const dir = path.resolve(opts.dir || '.')
579
638
  const message = opts.message || ''
580
639
  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'))
640
+
641
+ const spinner = createSpinner('Preparing commit...', opts.json)
642
+ try {
643
+ // Check for unresolved conflicts
644
+ spinnerUpdate(spinner, 'Checking for conflicts...')
645
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
646
+ if (unresolvedConflicts.length > 0) {
647
+ spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
648
+ if (opts.json === 'true') {
649
+ print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
650
+ } else {
651
+ process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
652
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
653
+ for (const p of unresolvedConflicts) {
654
+ process.stderr.write(color(` ${p}\n`, 'red'))
655
+ }
656
+ process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
592
657
  }
593
- process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
658
+ return
594
659
  }
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
660
+
661
+ spinnerUpdate(spinner, 'Collecting changes...')
662
+ const metaDir = path.join(dir, '.vcs-next')
663
+ const localPath = path.join(metaDir, 'local.json')
664
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
665
+ try {
666
+ const s = await fs.promises.readFile(localPath, 'utf8')
667
+ localMeta = JSON.parse(s)
668
+ } catch { }
669
+ const local = await collectLocal(dir)
670
+ const files = {}
671
+ for (const [p, v] of Object.entries(local)) files[p] = v.content
672
+ localMeta.pendingCommit = { message, files, createdAt: Date.now() }
673
+ // Clear conflicts if they were resolved
674
+ if (localMeta.conflicts) {
675
+ delete localMeta.conflicts
676
+ }
677
+ await fs.promises.mkdir(metaDir, { recursive: true })
678
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
679
+ spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
680
+ print({ pendingCommit: message }, opts.json === 'true')
681
+ } catch (err) {
682
+ spinnerFail(spinner, 'Commit failed')
683
+ throw err
612
684
  }
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
685
  }
617
686
 
618
687
  async function pullToDir(repo, branch, dir, server, token) {
@@ -637,7 +706,7 @@ async function pullToDir(repo, branch, dir, server, token) {
637
706
  for (const rel of Object.keys(localMap)) {
638
707
  if (!keep.has(rel)) {
639
708
  const fp = path.join(root, rel)
640
- try { await fs.promises.unlink(fp) } catch {}
709
+ try { await fs.promises.unlink(fp) } catch { }
641
710
  }
642
711
  }
643
712
  const pruneEmptyDirs = async (start) => {
@@ -650,7 +719,7 @@ async function pullToDir(repo, branch, dir, server, token) {
650
719
  if (st.isDirectory()) {
651
720
  await pruneEmptyDirs(p)
652
721
  const left = await fs.promises.readdir(p).catch(() => [])
653
- if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
722
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
654
723
  }
655
724
  }
656
725
  }
@@ -676,8 +745,62 @@ async function cmdPull(opts) {
676
745
  const cfg = loadConfig()
677
746
  const server = getServer(opts, cfg) || meta.server
678
747
  const token = getToken(opts, cfg) || meta.token
679
- await pullToDir(meta.repoId, meta.branch, dir, server, token)
680
- print('Pull complete', opts.json === 'true')
748
+ const spinner = createSpinner(`Pulling from branch '${meta.branch}'...`, opts.json)
749
+ try {
750
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
751
+ spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
752
+ print('Pull complete', opts.json === 'true')
753
+ } catch (err) {
754
+ spinnerFail(spinner, 'Pull failed')
755
+ throw err
756
+ }
757
+ }
758
+
759
+ async function cmdFetch(opts) {
760
+ const dir = path.resolve(opts.dir || '.')
761
+ const meta = readRemoteMeta(dir)
762
+ const cfg = loadConfig()
763
+ const server = getServer(opts, cfg) || meta.server
764
+ const token = getToken(opts, cfg) || meta.token
765
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
766
+ const gitDir = path.join(dir, '.git')
767
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
768
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
769
+ try {
770
+ const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
771
+ const branchesRes = await fetch(branchesUrl.toString(), { headers })
772
+ if (branchesRes.ok) {
773
+ const branchesData = await branchesRes.json()
774
+ const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
775
+ const allRefs = {}
776
+ for (const b of branches) {
777
+ const name = b.name || ''
778
+ const id = b.commitId || ''
779
+ if (!name) continue
780
+ allRefs[name] = id
781
+ await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
782
+ }
783
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
784
+ const curId = allRefs[meta.branch] || ''
785
+ const metaDir = path.join(dir, '.vcs-next')
786
+ await fs.promises.mkdir(metaDir, { recursive: true })
787
+ const remoteMetaPath = path.join(metaDir, 'remote.json')
788
+ let remoteMeta = meta
789
+ try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
790
+ remoteMeta.commitId = curId
791
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
792
+ }
793
+ } catch { }
794
+ try {
795
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
796
+ commitsUrl.searchParams.set('branch', meta.branch)
797
+ const commitsRes = await fetch(commitsUrl.toString(), { headers })
798
+ if (commitsRes.ok) {
799
+ const commitsList = await commitsRes.json()
800
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
801
+ }
802
+ } catch { }
803
+ print('Fetch complete', opts.json === 'true')
681
804
  }
682
805
 
683
806
  async function fetchRemoteSnapshot(server, repo, branch, token) {
@@ -748,57 +871,57 @@ async function cmdCherryPick(opts) {
748
871
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
749
872
  const m = head.match(/refs\/heads\/(.+)/)
750
873
  if (m) current = m[1]
751
- } catch {}
874
+ } catch { }
752
875
  targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
753
876
  }
754
877
  const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
755
878
  if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
756
-
879
+
757
880
  // Get the current state of the target branch (what we're cherry-picking onto)
758
881
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
759
-
882
+
760
883
  // Get the commit being cherry-picked and its parent
761
884
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
762
885
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
763
886
  const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
764
887
  const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
765
-
888
+
766
889
  // Pull the target branch to ensure we're working with the latest state
767
890
  await pullToDir(meta.repoId, targetBranch, dir, server, token)
768
-
891
+
769
892
  // Re-collect local state after pull (now it matches the target branch)
770
893
  const localAfterPull = await collectLocal(dir)
771
-
894
+
772
895
  // Update local metadata to reflect the pulled state
773
896
  const metaDirInit = path.join(dir, '.vcs-next')
774
897
  await fs.promises.mkdir(metaDirInit, { recursive: true })
775
898
  const localPathInit = path.join(metaDirInit, 'local.json')
776
899
  let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
777
- try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
900
+ try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch { }
778
901
  localMetaInit.baseCommitId = currentSnap.commitId
779
902
  localMetaInit.baseFiles = currentSnap.files
780
903
  await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
781
-
904
+
782
905
  // Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
783
906
  const conflicts = []
784
907
  const changes = []
785
908
  const mergedFiles = {}
786
909
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
787
-
910
+
788
911
  for (const p of allPaths) {
789
912
  const b = baseSnap.files[p] // base: parent of commit being cherry-picked
790
913
  const n = targetSnap.files[p] // next: the commit being cherry-picked
791
914
  const c = currentSnap.files[p] // current: current state of target branch (from remote)
792
-
915
+
793
916
  // For cherry-pick, we need to apply the changes from the commit onto the current branch
794
917
  // The logic: if the commit changed something from its parent, apply that change to current
795
918
  const baseContent = b !== undefined ? String(b) : null
796
919
  const commitContent = n !== undefined ? String(n) : null
797
920
  const currentContent = c !== undefined ? String(c) : null
798
-
921
+
799
922
  // Check if commit changed this file from its parent
800
923
  const commitChanged = baseContent !== commitContent
801
-
924
+
802
925
  if (commitChanged) {
803
926
  // The commit modified this file - we want to apply that change
804
927
  // Conflict detection:
@@ -807,15 +930,15 @@ async function cmdCherryPick(opts) {
807
930
  // - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
808
931
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
809
932
  // - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
810
-
933
+
811
934
  const fileWasAdded = baseContent === null && commitContent !== null
812
935
  const fileWasDeleted = baseContent !== null && commitContent === null
813
936
  const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
814
-
937
+
815
938
  // Check for conflicts: current branch changed the file differently than the commit
816
939
  const currentChangedFromBase = currentContent !== baseContent
817
940
  const currentDiffersFromCommit = currentContent !== commitContent
818
-
941
+
819
942
  // Conflict detection:
820
943
  // - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
821
944
  // - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
@@ -823,7 +946,7 @@ async function cmdCherryPick(opts) {
823
946
  // - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
824
947
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
825
948
  // - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
826
-
949
+
827
950
  // Conflict if:
828
951
  // 1. Current branch changed the file from base (current !== base)
829
952
  // 2. AND current differs from what commit wants (current !== commit)
@@ -833,7 +956,7 @@ async function cmdCherryPick(opts) {
833
956
  // this is always safe - no conflict. The file is simply being added.
834
957
  const safeAddCase = fileWasAdded && currentContent === null
835
958
  const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
836
-
959
+
837
960
  // Only conflict if file exists in current AND was changed differently
838
961
  if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
839
962
  // Conflict: both changed the file differently
@@ -863,7 +986,7 @@ async function cmdCherryPick(opts) {
863
986
  }
864
987
  }
865
988
  }
866
-
989
+
867
990
  if (conflicts.length > 0) {
868
991
  // Write conflict markers to files
869
992
  for (const conflict of conflicts) {
@@ -873,11 +996,11 @@ async function cmdCherryPick(opts) {
873
996
  conflict.current,
874
997
  conflict.incoming,
875
998
  meta.branch,
876
- `cherry-pick-${commitId.slice(0,7)}`
999
+ `cherry-pick-${commitId.slice(0, 7)}`
877
1000
  )
878
1001
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
879
1002
  }
880
-
1003
+
881
1004
  // Store conflict state in metadata
882
1005
  const metaDir = path.join(dir, '.vcs-next')
883
1006
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -886,9 +1009,9 @@ async function cmdCherryPick(opts) {
886
1009
  try {
887
1010
  const s = await fs.promises.readFile(localPath, 'utf8')
888
1011
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
889
- } catch {}
1012
+ } catch { }
890
1013
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
891
-
1014
+
892
1015
  if (opts.json === 'true') {
893
1016
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
894
1017
  } else {
@@ -901,22 +1024,22 @@ async function cmdCherryPick(opts) {
901
1024
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
902
1025
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
903
1026
  process.stdout.write(color(` =======\n`, 'dim'))
904
- process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
1027
+ process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0, 7)}\n`, 'dim'))
905
1028
  }
906
1029
  return
907
1030
  }
908
-
1031
+
909
1032
  // Apply the changes to the filesystem
910
1033
  for (const ch of changes) {
911
1034
  const fp = path.join(dir, ch.path)
912
1035
  if (ch.type === 'delete') {
913
- try { await fs.promises.unlink(fp) } catch {}
1036
+ try { await fs.promises.unlink(fp) } catch { }
914
1037
  } else if (ch.type === 'write') {
915
1038
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
916
1039
  await fs.promises.writeFile(fp, ch.content, 'utf8')
917
1040
  }
918
1041
  }
919
-
1042
+
920
1043
  // Update local metadata with the pending commit
921
1044
  const metaDir = path.join(dir, '.vcs-next')
922
1045
  const localPath = path.join(metaDir, 'local.json')
@@ -925,43 +1048,43 @@ async function cmdCherryPick(opts) {
925
1048
  try {
926
1049
  const s = await fs.promises.readFile(localPath, 'utf8')
927
1050
  localMeta = JSON.parse(s)
928
- } catch {}
929
-
930
- const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
931
-
1051
+ } catch { }
1052
+
1053
+ const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
1054
+
932
1055
  // Collect final state after applying changes
933
1056
  const finalLocal = await collectLocal(dir)
934
1057
  const finalFiles = {}
935
1058
  for (const [p, v] of Object.entries(finalLocal)) {
936
1059
  finalFiles[p] = v.content
937
1060
  }
938
-
1061
+
939
1062
  // Set pending commit with the actual files
940
1063
  localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
941
1064
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
942
-
1065
+
943
1066
  // Ensure remote.json has the correct target branch before pushing
944
1067
  const remoteMetaPath = path.join(metaDir, 'remote.json')
945
1068
  let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
946
1069
  try {
947
1070
  const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
948
1071
  remoteMeta = { ...JSON.parse(s), branch: targetBranch }
949
- } catch {}
1072
+ } catch { }
950
1073
  await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
951
-
1074
+
952
1075
  // Also update .git metadata
953
1076
  const gitDir = path.join(dir, '.git')
954
1077
  const gitMetaPath = path.join(gitDir, 'vcs-next.json')
955
1078
  await fs.promises.mkdir(gitDir, { recursive: true })
956
1079
  await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
957
1080
  await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
958
-
1081
+
959
1082
  if (opts.json === 'true') {
960
1083
  print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
961
1084
  } else {
962
- process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
1085
+ process.stdout.write(color(`Cherry-pick ${commitId.slice(0, 7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
963
1086
  }
964
-
1087
+
965
1088
  if (opts.noPush !== 'true') {
966
1089
  await cmdPush({ dir, json: opts.json, message: cherryMsg })
967
1090
  }
@@ -984,7 +1107,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
984
1107
  const incoming = String(incomingContent || '')
985
1108
  const currentLines = current.split(/\r?\n/)
986
1109
  const incomingLines = incoming.split(/\r?\n/)
987
-
1110
+
988
1111
  // Simple conflict marker format
989
1112
  const markers = [
990
1113
  `<<<<<<< ${currentLabel || 'HEAD'}`,
@@ -993,7 +1116,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
993
1116
  ...incomingLines,
994
1117
  `>>>>>>> ${incomingLabel || 'incoming'}`
995
1118
  ]
996
-
1119
+
997
1120
  return markers.join('\n')
998
1121
  }
999
1122
 
@@ -1019,73 +1142,87 @@ async function cmdPush(opts) {
1019
1142
  const metaDir = path.join(dir, '.vcs-next')
1020
1143
  const localPath = path.join(metaDir, 'local.json')
1021
1144
  let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1145
+ const spinner = createSpinner('Preparing to push...', opts.json)
1022
1146
  try {
1023
1147
  const s = await fs.promises.readFile(localPath, 'utf8')
1024
1148
  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'))
1149
+ } catch { }
1150
+
1151
+ try {
1152
+ // Check for unresolved conflicts in files
1153
+ spinnerUpdate(spinner, 'Checking for conflicts...')
1154
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1155
+ if (unresolvedConflicts.length > 0) {
1156
+ spinnerFail(spinner, 'Cannot push with unresolved conflicts')
1157
+ if (opts.json === 'true') {
1158
+ print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1159
+ } else {
1160
+ process.stderr.write(color('Error: Cannot push with unresolved conflicts\\n', 'red'))
1161
+ process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
1162
+ for (const p of unresolvedConflicts) {
1163
+ process.stderr.write(color(` ${p}\\n`, 'red'))
1164
+ }
1165
+ process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
1037
1166
  }
1038
- process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1167
+ return
1039
1168
  }
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
1169
+
1170
+ const cfg = loadConfig()
1171
+ const server = getServer(opts, cfg) || remoteMeta.server
1172
+ const token = getToken(opts, cfg) || remoteMeta.token
1173
+ spinnerUpdate(spinner, 'Fetching remote state...')
1174
+ const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1175
+ const base = localMeta.baseFiles || {}
1176
+ spinnerUpdate(spinner, 'Collecting local changes...')
1177
+ const local = await collectLocal(dir)
1178
+ const conflicts = []
1179
+ const merged = {}
1180
+ const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1181
+ spinnerUpdate(spinner, 'Merging changes...')
1182
+ for (const p of paths) {
1183
+ const b = p in base ? base[p] : null
1184
+ const r = p in remote.files ? remote.files[p] : null
1185
+ const l = p in local ? local[p].content : null
1186
+ const changedLocal = String(l) !== String(b)
1187
+ const changedRemote = String(r) !== String(b)
1188
+ if (changedLocal && changedRemote && String(l) !== String(r)) {
1189
+ const line = firstDiffLine(l || '', r || '')
1190
+ conflicts.push({ path: p, line })
1191
+ } else if (changedLocal && !changedRemote) {
1192
+ if (l !== null) merged[p] = l
1193
+ } else if (!changedLocal && changedRemote) {
1194
+ if (r !== null) merged[p] = r
1195
+ } else {
1196
+ if (b !== null) merged[p] = b
1197
+ }
1067
1198
  }
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'))
1199
+ if (conflicts.length > 0) {
1200
+ spinnerFail(spinner, 'Push blocked by conflicts')
1201
+ if (opts.json === 'true') {
1202
+ print({ conflicts }, true)
1203
+ } else {
1204
+ process.stderr.write(color('Error: Cannot push with conflicts\\n', 'red'))
1205
+ process.stderr.write(color('Conflicts detected:\\n', 'yellow'))
1206
+ for (const c of conflicts) {
1207
+ process.stderr.write(color(` ${c.path}:${c.line}\\n`, 'red'))
1208
+ }
1077
1209
  }
1210
+ return
1078
1211
  }
1079
- return
1212
+ const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1213
+ spinnerUpdate(spinner, `Pushing to '${remoteMeta.branch}'...`)
1214
+ const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1215
+ const data = await request('POST', url, body, token)
1216
+ localMeta.baseCommitId = data.id || remote.commitId || ''
1217
+ localMeta.baseFiles = merged
1218
+ localMeta.pendingCommit = null
1219
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1220
+ spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
1221
+ print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1222
+ } catch (err) {
1223
+ spinnerFail(spinner, 'Push failed')
1224
+ throw err
1080
1225
  }
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
1226
  }
1090
1227
 
1091
1228
  async function cmdMerge(opts) {
@@ -1096,13 +1233,13 @@ async function cmdMerge(opts) {
1096
1233
  const token = getToken(opts, cfg) || meta.token
1097
1234
  const sourceBranch = opts.branch || ''
1098
1235
  if (!sourceBranch) throw new Error('Missing --branch')
1099
-
1236
+
1100
1237
  // Get current branch state
1101
1238
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1102
-
1239
+
1103
1240
  // Get source branch state
1104
1241
  const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
1105
-
1242
+
1106
1243
  // Find common ancestor (merge base)
1107
1244
  // For simplicity, we'll use the current branch's base commit as the merge base
1108
1245
  // In a full implementation, we'd find the actual common ancestor
@@ -1110,29 +1247,29 @@ async function cmdMerge(opts) {
1110
1247
  const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
1111
1248
  const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
1112
1249
  if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
1113
-
1250
+
1114
1251
  // Get base commit (for now, use current branch's commit as base)
1115
1252
  // In real Git, we'd find the merge base commit
1116
1253
  const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1117
-
1254
+
1118
1255
  // Pull current branch to ensure we're up to date
1119
1256
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1120
1257
  const localAfterPull = await collectLocal(dir)
1121
-
1258
+
1122
1259
  // Three-way merge: base, current (target), source
1123
1260
  const conflicts = []
1124
1261
  const changes = []
1125
1262
  const mergedFiles = {}
1126
1263
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
1127
-
1264
+
1128
1265
  for (const p of allPaths) {
1129
1266
  const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
1130
1267
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1131
1268
  const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
1132
-
1269
+
1133
1270
  const currentChanged = current !== base
1134
1271
  const sourceChanged = source !== base
1135
-
1272
+
1136
1273
  if (currentChanged && sourceChanged && current !== source) {
1137
1274
  // Conflict: both branches changed the file differently
1138
1275
  const line = firstDiffLine(current || '', source || '')
@@ -1166,7 +1303,7 @@ async function cmdMerge(opts) {
1166
1303
  }
1167
1304
  }
1168
1305
  }
1169
-
1306
+
1170
1307
  if (conflicts.length > 0) {
1171
1308
  // Write conflict markers to files
1172
1309
  for (const conflict of conflicts) {
@@ -1180,7 +1317,7 @@ async function cmdMerge(opts) {
1180
1317
  )
1181
1318
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1182
1319
  }
1183
-
1320
+
1184
1321
  // Store conflict state in metadata
1185
1322
  const metaDir = path.join(dir, '.vcs-next')
1186
1323
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1189,9 +1326,9 @@ async function cmdMerge(opts) {
1189
1326
  try {
1190
1327
  const s = await fs.promises.readFile(localPath, 'utf8')
1191
1328
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1192
- } catch {}
1329
+ } catch { }
1193
1330
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1194
-
1331
+
1195
1332
  const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
1196
1333
  if (opts.json === 'true') {
1197
1334
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
@@ -1209,18 +1346,18 @@ async function cmdMerge(opts) {
1209
1346
  }
1210
1347
  return
1211
1348
  }
1212
-
1349
+
1213
1350
  // Apply changes to filesystem
1214
1351
  for (const ch of changes) {
1215
1352
  const fp = path.join(dir, ch.path)
1216
1353
  if (ch.type === 'delete') {
1217
- try { await fs.promises.unlink(fp) } catch {}
1354
+ try { await fs.promises.unlink(fp) } catch { }
1218
1355
  } else if (ch.type === 'write') {
1219
1356
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1220
1357
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1221
1358
  }
1222
1359
  }
1223
-
1360
+
1224
1361
  // Update local metadata
1225
1362
  const metaDir = path.join(dir, '.vcs-next')
1226
1363
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1229,27 +1366,27 @@ async function cmdMerge(opts) {
1229
1366
  try {
1230
1367
  const s = await fs.promises.readFile(localPath, 'utf8')
1231
1368
  localMeta = JSON.parse(s)
1232
- } catch {}
1233
-
1369
+ } catch { }
1370
+
1234
1371
  const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
1235
-
1372
+
1236
1373
  // Collect final state
1237
1374
  const finalLocal = await collectLocal(dir)
1238
1375
  const finalFiles = {}
1239
1376
  for (const [p, v] of Object.entries(finalLocal)) {
1240
1377
  finalFiles[p] = v.content
1241
1378
  }
1242
-
1379
+
1243
1380
  // Set pending commit
1244
1381
  localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
1245
1382
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1246
-
1383
+
1247
1384
  if (opts.json === 'true') {
1248
1385
  print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
1249
1386
  } else {
1250
1387
  process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
1251
1388
  }
1252
-
1389
+
1253
1390
  if (opts.noPush !== 'true') {
1254
1391
  await cmdPush({ dir, json: opts.json, message: mergeMsg })
1255
1392
  }
@@ -1260,7 +1397,7 @@ async function cmdStash(sub, opts) {
1260
1397
  const metaDir = path.join(dir, '.vcs-next')
1261
1398
  const stashDir = path.join(metaDir, 'stash')
1262
1399
  await fs.promises.mkdir(stashDir, { recursive: true })
1263
-
1400
+
1264
1401
  if (sub === 'list' || sub === undefined) {
1265
1402
  try {
1266
1403
  const files = await fs.promises.readdir(stashDir)
@@ -1293,7 +1430,7 @@ async function cmdStash(sub, opts) {
1293
1430
  }
1294
1431
  return
1295
1432
  }
1296
-
1433
+
1297
1434
  if (sub === 'save' || (sub === undefined && !opts.list)) {
1298
1435
  const message = opts.message || 'WIP'
1299
1436
  const local = await collectLocal(dir)
@@ -1304,14 +1441,14 @@ async function cmdStash(sub, opts) {
1304
1441
  const stashId = Date.now().toString()
1305
1442
  const stash = { message, files, createdAt: Date.now() }
1306
1443
  await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
1307
-
1444
+
1308
1445
  // Restore to base state (discard local changes)
1309
1446
  const meta = readRemoteMeta(dir)
1310
1447
  const cfg = loadConfig()
1311
1448
  const server = getServer(opts, cfg) || meta.server
1312
1449
  const token = getToken(opts, cfg) || meta.token
1313
1450
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1314
-
1451
+
1315
1452
  if (opts.json === 'true') {
1316
1453
  print({ stashId, message }, true)
1317
1454
  } else {
@@ -1319,7 +1456,7 @@ async function cmdStash(sub, opts) {
1319
1456
  }
1320
1457
  return
1321
1458
  }
1322
-
1459
+
1323
1460
  if (sub === 'pop' || sub === 'apply') {
1324
1461
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1325
1462
  try {
@@ -1335,19 +1472,19 @@ async function cmdStash(sub, opts) {
1335
1472
  stashes.sort((a, b) => b.createdAt - a.createdAt)
1336
1473
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1337
1474
  const stash = stashes[stashIndex]
1338
-
1475
+
1339
1476
  // Apply stash files
1340
1477
  for (const [p, content] of Object.entries(stash.files || {})) {
1341
1478
  const fp = path.join(dir, p)
1342
1479
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1343
1480
  await fs.promises.writeFile(fp, content, 'utf8')
1344
1481
  }
1345
-
1482
+
1346
1483
  if (sub === 'pop') {
1347
1484
  // Remove stash
1348
1485
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1349
1486
  }
1350
-
1487
+
1351
1488
  if (opts.json === 'true') {
1352
1489
  print({ applied: stash.id, message: stash.message }, true)
1353
1490
  } else {
@@ -1358,7 +1495,7 @@ async function cmdStash(sub, opts) {
1358
1495
  }
1359
1496
  return
1360
1497
  }
1361
-
1498
+
1362
1499
  if (sub === 'drop') {
1363
1500
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1364
1501
  try {
@@ -1375,7 +1512,7 @@ async function cmdStash(sub, opts) {
1375
1512
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1376
1513
  const stash = stashes[stashIndex]
1377
1514
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1378
-
1515
+
1379
1516
  if (opts.json === 'true') {
1380
1517
  print({ dropped: stash.id }, true)
1381
1518
  } else {
@@ -1386,7 +1523,7 @@ async function cmdStash(sub, opts) {
1386
1523
  }
1387
1524
  return
1388
1525
  }
1389
-
1526
+
1390
1527
  if (sub === 'clear') {
1391
1528
  try {
1392
1529
  const files = await fs.promises.readdir(stashDir)
@@ -1403,7 +1540,7 @@ async function cmdStash(sub, opts) {
1403
1540
  }
1404
1541
  return
1405
1542
  }
1406
-
1543
+
1407
1544
  throw new Error('Unknown stash subcommand')
1408
1545
  }
1409
1546
 
@@ -1425,7 +1562,7 @@ async function cmdBranch(sub, opts) {
1425
1562
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
1426
1563
  const m = head.match(/refs\/heads\/(.+)/)
1427
1564
  if (m) current = m[1]
1428
- } catch {}
1565
+ } catch { }
1429
1566
  process.stdout.write(color('Branches:\n', 'bold'))
1430
1567
  const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1431
1568
  for (const b of list) {
@@ -1463,7 +1600,21 @@ async function cmdBranch(sub, opts) {
1463
1600
  print(data, opts.json === 'true')
1464
1601
  return
1465
1602
  }
1466
-
1603
+ if (sub === 'rename') {
1604
+ const oldName = opts.old
1605
+ const newName = opts.new
1606
+ if (!oldName || !newName) throw new Error('Missing --old and --new')
1607
+ const body = { name: newName, baseBranch: oldName }
1608
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1609
+ await request('POST', url, body, token)
1610
+ const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1611
+ u.searchParams.set('name', oldName)
1612
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
1613
+ await fetch(u.toString(), { method: 'DELETE', headers })
1614
+ print({ renamed: { from: oldName, to: newName } }, opts.json === 'true')
1615
+ return
1616
+ }
1617
+
1467
1618
  throw new Error('Unknown branch subcommand')
1468
1619
  }
1469
1620
 
@@ -1493,7 +1644,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
1493
1644
  for (const rel of Object.keys(localMap)) {
1494
1645
  if (!keep.has(rel)) {
1495
1646
  const fp = path.join(root, rel)
1496
- try { await fs.promises.unlink(fp) } catch {}
1647
+ try { await fs.promises.unlink(fp) } catch { }
1497
1648
  }
1498
1649
  }
1499
1650
  const metaDir = path.join(root, '.vcs-next')
@@ -1521,6 +1672,11 @@ async function cmdCheckout(opts) {
1521
1672
  return
1522
1673
  }
1523
1674
  if (!branch) throw new Error('Missing --branch or --commit')
1675
+ if (opts.create === 'true') {
1676
+ const body = { name: branch, baseBranch: meta.branch }
1677
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1678
+ await request('POST', url, body, token)
1679
+ }
1524
1680
  await pullToDir(meta.repoId, branch, dir, server, token)
1525
1681
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1526
1682
  }
@@ -1614,7 +1770,7 @@ async function cmdHead(opts) {
1614
1770
  } else {
1615
1771
  commitId = head.trim()
1616
1772
  }
1617
- } catch {}
1773
+ } catch { }
1618
1774
  if (!commitId) {
1619
1775
  const cfg = loadConfig()
1620
1776
  const server = getServer(opts, cfg) || meta.server
@@ -1635,12 +1791,12 @@ async function cmdShow(opts) {
1635
1791
  const token = getToken(opts, cfg) || meta.token
1636
1792
  const commitId = opts.commit
1637
1793
  if (!commitId) throw new Error('Missing --commit')
1638
-
1794
+
1639
1795
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1640
1796
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1641
1797
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1642
1798
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1643
-
1799
+
1644
1800
  if (opts.json === 'true') {
1645
1801
  print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
1646
1802
  } else {
@@ -1652,7 +1808,7 @@ async function cmdShow(opts) {
1652
1808
  process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
1653
1809
  }
1654
1810
  process.stdout.write(`\n${commit.message || ''}\n\n`)
1655
-
1811
+
1656
1812
  // Show file changes
1657
1813
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
1658
1814
  const changed = []
@@ -1683,34 +1839,34 @@ async function cmdRevert(opts) {
1683
1839
  const token = getToken(opts, cfg) || meta.token
1684
1840
  const commitId = opts.commit
1685
1841
  if (!commitId) throw new Error('Missing --commit')
1686
-
1842
+
1687
1843
  // Get the commit to revert
1688
1844
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1689
1845
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1690
1846
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1691
1847
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1692
-
1848
+
1693
1849
  // Get current state
1694
1850
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1695
1851
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1696
1852
  const localAfterPull = await collectLocal(dir)
1697
-
1853
+
1698
1854
  // Revert: apply inverse of commit changes
1699
1855
  const conflicts = []
1700
1856
  const changes = []
1701
1857
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
1702
-
1858
+
1703
1859
  for (const p of allPaths) {
1704
1860
  const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
1705
1861
  const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
1706
1862
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1707
1863
  const local = localAfterPull[p]?.content || null
1708
-
1864
+
1709
1865
  // Revert: we want to go from commit state back to parent state
1710
1866
  // But check if current branch has changed this file
1711
1867
  const commitChanged = base !== commitContent
1712
1868
  const currentChanged = current !== base
1713
-
1869
+
1714
1870
  if (commitChanged) {
1715
1871
  // Commit changed this file - we want to revert it
1716
1872
  if (currentChanged && current !== base) {
@@ -1732,7 +1888,7 @@ async function cmdRevert(opts) {
1732
1888
  }
1733
1889
  }
1734
1890
  }
1735
-
1891
+
1736
1892
  if (conflicts.length > 0) {
1737
1893
  // Write conflict markers to files
1738
1894
  for (const conflict of conflicts) {
@@ -1742,11 +1898,11 @@ async function cmdRevert(opts) {
1742
1898
  conflict.current,
1743
1899
  conflict.incoming,
1744
1900
  meta.branch,
1745
- `revert-${commitId.slice(0,7)}`
1901
+ `revert-${commitId.slice(0, 7)}`
1746
1902
  )
1747
1903
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1748
1904
  }
1749
-
1905
+
1750
1906
  // Store conflict state in metadata
1751
1907
  const metaDir = path.join(dir, '.vcs-next')
1752
1908
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1755,9 +1911,9 @@ async function cmdRevert(opts) {
1755
1911
  try {
1756
1912
  const s = await fs.promises.readFile(localPath, 'utf8')
1757
1913
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1758
- } catch {}
1914
+ } catch { }
1759
1915
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1760
-
1916
+
1761
1917
  if (opts.json === 'true') {
1762
1918
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
1763
1919
  } else {
@@ -1770,22 +1926,22 @@ async function cmdRevert(opts) {
1770
1926
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
1771
1927
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
1772
1928
  process.stdout.write(color(` =======\n`, 'dim'))
1773
- process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
1929
+ process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
1774
1930
  }
1775
1931
  return
1776
1932
  }
1777
-
1933
+
1778
1934
  // Apply changes
1779
1935
  for (const ch of changes) {
1780
1936
  const fp = path.join(dir, ch.path)
1781
1937
  if (ch.type === 'delete') {
1782
- try { await fs.promises.unlink(fp) } catch {}
1938
+ try { await fs.promises.unlink(fp) } catch { }
1783
1939
  } else if (ch.type === 'write') {
1784
1940
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1785
1941
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1786
1942
  }
1787
1943
  }
1788
-
1944
+
1789
1945
  // Update metadata
1790
1946
  const metaDir = path.join(dir, '.vcs-next')
1791
1947
  const localPath = path.join(metaDir, 'local.json')
@@ -1794,24 +1950,24 @@ async function cmdRevert(opts) {
1794
1950
  try {
1795
1951
  const s = await fs.promises.readFile(localPath, 'utf8')
1796
1952
  localMeta = JSON.parse(s)
1797
- } catch {}
1798
-
1953
+ } catch { }
1954
+
1799
1955
  const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
1800
1956
  const finalLocal = await collectLocal(dir)
1801
1957
  const finalFiles = {}
1802
1958
  for (const [p, v] of Object.entries(finalLocal)) {
1803
1959
  finalFiles[p] = v.content
1804
1960
  }
1805
-
1961
+
1806
1962
  localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
1807
1963
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1808
-
1964
+
1809
1965
  if (opts.json === 'true') {
1810
1966
  print({ reverted: commitId, changes: changes.length }, true)
1811
1967
  } else {
1812
- process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
1968
+ process.stdout.write(color(`Reverted ${commitId.slice(0, 7)}: ${changes.length} changes\n`, 'green'))
1813
1969
  }
1814
-
1970
+
1815
1971
  if (opts.noPush !== 'true') {
1816
1972
  await cmdPush({ dir, json: opts.json, message: revertMsg })
1817
1973
  }
@@ -1823,11 +1979,11 @@ async function cmdReset(opts) {
1823
1979
  const cfg = loadConfig()
1824
1980
  const server = getServer(opts, cfg) || meta.server
1825
1981
  const token = getToken(opts, cfg) || meta.token
1826
-
1982
+
1827
1983
  const commitId = opts.commit || 'HEAD'
1828
1984
  const mode = opts.mode || 'mixed' // soft, mixed, hard
1829
1985
  const filePath = opts.path
1830
-
1986
+
1831
1987
  if (filePath) {
1832
1988
  // Reset specific file (unstage)
1833
1989
  const metaDir = path.join(dir, '.vcs-next')
@@ -1836,8 +1992,8 @@ async function cmdReset(opts) {
1836
1992
  try {
1837
1993
  const s = await fs.promises.readFile(localPath, 'utf8')
1838
1994
  localMeta = JSON.parse(s)
1839
- } catch {}
1840
-
1995
+ } catch { }
1996
+
1841
1997
  // Restore file from base
1842
1998
  const baseContent = localMeta.baseFiles[filePath]
1843
1999
  if (baseContent !== undefined) {
@@ -1845,7 +2001,7 @@ async function cmdReset(opts) {
1845
2001
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1846
2002
  await fs.promises.writeFile(fp, String(baseContent), 'utf8')
1847
2003
  }
1848
-
2004
+
1849
2005
  if (opts.json === 'true') {
1850
2006
  print({ reset: filePath }, true)
1851
2007
  } else {
@@ -1853,7 +2009,7 @@ async function cmdReset(opts) {
1853
2009
  }
1854
2010
  return
1855
2011
  }
1856
-
2012
+
1857
2013
  // Reset to commit
1858
2014
  let targetSnap
1859
2015
  if (commitId === 'HEAD') {
@@ -1861,7 +2017,7 @@ async function cmdReset(opts) {
1861
2017
  } else {
1862
2018
  targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1863
2019
  }
1864
-
2020
+
1865
2021
  if (mode === 'hard') {
1866
2022
  // Hard reset: discard all changes, reset to commit
1867
2023
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
@@ -1882,14 +2038,14 @@ async function cmdReset(opts) {
1882
2038
  // Mixed reset: keep changes unstaged (default)
1883
2039
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1884
2040
  }
1885
-
2041
+
1886
2042
  // Update metadata
1887
2043
  const metaDir = path.join(dir, '.vcs-next')
1888
2044
  const localPath = path.join(metaDir, 'local.json')
1889
2045
  await fs.promises.mkdir(metaDir, { recursive: true })
1890
2046
  const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
1891
2047
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1892
-
2048
+
1893
2049
  if (opts.json === 'true') {
1894
2050
  print({ reset: commitId, mode }, true)
1895
2051
  } else {
@@ -1897,23 +2053,57 @@ async function cmdReset(opts) {
1897
2053
  }
1898
2054
  }
1899
2055
 
2056
+ async function cmdInit(opts) {
2057
+ const dir = path.resolve(opts.dir || '.')
2058
+ const cfg = loadConfig()
2059
+ const server = getServer(opts, cfg)
2060
+ const token = getToken(opts, cfg)
2061
+ const repo = opts.repo || ''
2062
+ const branch = opts.branch || 'main'
2063
+ const metaDir = path.join(dir, '.vcs-next')
2064
+ const gitDir = path.join(dir, '.git')
2065
+ await fs.promises.mkdir(metaDir, { recursive: true })
2066
+ await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
2067
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
2068
+ await fs.promises.writeFile(path.join(gitDir, 'refs', 'heads', branch), '', 'utf8')
2069
+ const gitConfig = [
2070
+ '[core]',
2071
+ '\trepositoryformatversion = 0',
2072
+ '\tfilemode = true',
2073
+ '\tbare = false',
2074
+ '\tlogallrefupdates = true',
2075
+ '',
2076
+ '[vcs-next]',
2077
+ `\tserver = ${opts.server || server || ''}`,
2078
+ `\trepoId = ${repo}`,
2079
+ `\tbranch = ${branch}`,
2080
+ `\ttoken = ${opts.token || token || ''}`
2081
+ ].join('\n')
2082
+ await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
2083
+ const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
2084
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
2085
+ const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2086
+ await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
2087
+ print({ initialized: dir, branch }, opts.json === 'true')
2088
+ }
2089
+
1900
2090
  async function cmdMv(opts) {
1901
2091
  const dir = path.resolve(opts.dir || '.')
1902
2092
  const from = opts.from
1903
2093
  const to = opts.to
1904
2094
  if (!from || !to) throw new Error('Missing --from and --to')
1905
-
2095
+
1906
2096
  const fromPath = path.join(dir, from)
1907
2097
  const toPath = path.join(dir, to)
1908
-
2098
+
1909
2099
  // Check if source exists
1910
2100
  const stat = await fs.promises.stat(fromPath).catch(() => null)
1911
2101
  if (!stat) throw new Error(`Source file not found: ${from}`)
1912
-
2102
+
1913
2103
  // Move file
1914
2104
  await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
1915
2105
  await fs.promises.rename(fromPath, toPath)
1916
-
2106
+
1917
2107
  // If it's a tracked file, we need to update it in the next commit
1918
2108
  // For now, just move it - the next commit will track the change
1919
2109
  if (opts.json === 'true') {
@@ -1925,7 +2115,7 @@ async function cmdMv(opts) {
1925
2115
 
1926
2116
  async function cmdAdd(opts) {
1927
2117
  const dir = path.resolve(opts.dir || '.')
1928
-
2118
+
1929
2119
  // Handle --all flag
1930
2120
  if (opts.all === 'true' || opts.all === true) {
1931
2121
  const meta = readRemoteMeta(dir)
@@ -1934,7 +2124,7 @@ async function cmdAdd(opts) {
1934
2124
  const token = getToken(opts, cfg) || meta.token
1935
2125
  const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
1936
2126
  const local = await collectLocal(dir)
1937
-
2127
+
1938
2128
  // Stage all changes (add new, modified, delete removed)
1939
2129
  const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
1940
2130
  let stagedCount = 0
@@ -1945,7 +2135,7 @@ async function cmdAdd(opts) {
1945
2135
  stagedCount++
1946
2136
  }
1947
2137
  }
1948
-
2138
+
1949
2139
  if (opts.json === 'true') {
1950
2140
  print({ staged: stagedCount }, true)
1951
2141
  } else {
@@ -1953,7 +2143,7 @@ async function cmdAdd(opts) {
1953
2143
  }
1954
2144
  return
1955
2145
  }
1956
-
2146
+
1957
2147
  const p = opts.path
1958
2148
  if (!p) throw new Error('Missing --path')
1959
2149
  const abs = path.join(dir, p)
@@ -2001,6 +2191,169 @@ async function cmdAdd(opts) {
2001
2191
  print({ added: p, dir }, opts.json === 'true')
2002
2192
  }
2003
2193
 
2194
+ async function cmdRemote(sub, opts) {
2195
+ const dir = path.resolve(opts.dir || '.')
2196
+ const meta = readRemoteMeta(dir)
2197
+ const metaDir = path.join(dir, '.vcs-next')
2198
+ const remoteMetaPath = path.join(metaDir, 'remote.json')
2199
+ let remoteMeta = meta
2200
+ try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
2201
+ if (sub === 'show' || sub === undefined) {
2202
+ print(remoteMeta, opts.json === 'true')
2203
+ return
2204
+ }
2205
+ if (sub === 'set-url') {
2206
+ const server = opts.server || ''
2207
+ if (!server) throw new Error('Missing --server')
2208
+ remoteMeta.server = server
2209
+ await fs.promises.mkdir(metaDir, { recursive: true })
2210
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
2211
+ const gitDir = path.join(dir, '.git')
2212
+ const cfgPath = path.join(gitDir, 'config')
2213
+ let cfgText = ''
2214
+ try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
2215
+ const lines = cfgText.split('\n')
2216
+ let inSec = false
2217
+ const out = []
2218
+ for (const ln of lines) {
2219
+ if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
2220
+ if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
2221
+ if (inSec && ln.trim().startsWith('server =')) { out.push(`\tserver = ${server}`); continue }
2222
+ out.push(ln)
2223
+ }
2224
+ await fs.promises.writeFile(cfgPath, out.join('\n'))
2225
+ print({ server }, opts.json === 'true')
2226
+ return
2227
+ }
2228
+ if (sub === 'set-token') {
2229
+ const token = opts.token || ''
2230
+ if (!token) throw new Error('Missing --token')
2231
+ remoteMeta.token = token
2232
+ await fs.promises.mkdir(metaDir, { recursive: true })
2233
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
2234
+ const gitDir = path.join(dir, '.git')
2235
+ const cfgPath = path.join(gitDir, 'config')
2236
+ let cfgText = ''
2237
+ try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
2238
+ const lines = cfgText.split('\n')
2239
+ let inSec = false
2240
+ const out = []
2241
+ for (const ln of lines) {
2242
+ if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
2243
+ if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
2244
+ if (inSec && ln.trim().startsWith('token =')) { out.push(`\ttoken = ${token}`); continue }
2245
+ out.push(ln)
2246
+ }
2247
+ await fs.promises.writeFile(cfgPath, out.join('\n'))
2248
+ print({ token: 'updated' }, opts.json === 'true')
2249
+ return
2250
+ }
2251
+ throw new Error('Unknown remote subcommand')
2252
+ }
2253
+
2254
+ async function cmdConfig(sub, opts) {
2255
+ if (sub === 'list' || sub === undefined) {
2256
+ print(loadConfig(), opts.json === 'true')
2257
+ return
2258
+ }
2259
+ if (sub === 'get') {
2260
+ const key = opts.key
2261
+ if (!key) throw new Error('Missing --key')
2262
+ const cfg = loadConfig()
2263
+ const val = cfg[key]
2264
+ print({ [key]: val }, opts.json === 'true')
2265
+ return
2266
+ }
2267
+ if (sub === 'set') {
2268
+ const key = opts.key
2269
+ const value = opts.value
2270
+ if (!key) throw new Error('Missing --key')
2271
+ const next = saveConfig({ [key]: value })
2272
+ print({ [key]: next[key] }, opts.json === 'true')
2273
+ return
2274
+ }
2275
+ throw new Error('Unknown config subcommand')
2276
+ }
2277
+
2278
+ async function cmdClean(opts) {
2279
+ const dir = path.resolve(opts.dir || '.')
2280
+ const meta = readRemoteMeta(dir)
2281
+ const cfg = loadConfig()
2282
+ const server = getServer(opts, cfg) || meta.server
2283
+ const token = getToken(opts, cfg) || meta.token
2284
+ const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
2285
+ const local = await collectLocal(dir)
2286
+ const keep = new Set(Object.keys(remoteSnap.files))
2287
+ const toDelete = []
2288
+ for (const p of Object.keys(local)) {
2289
+ if (!keep.has(p)) toDelete.push(p)
2290
+ }
2291
+ const force = opts.force === 'true'
2292
+ if (!force) {
2293
+ print({ untracked: toDelete }, opts.json === 'true')
2294
+ return
2295
+ }
2296
+ for (const rel of toDelete) {
2297
+ const fp = path.join(dir, rel)
2298
+ try { await fs.promises.unlink(fp) } catch { }
2299
+ }
2300
+ const pruneEmptyDirs = async (start) => {
2301
+ const entries = await fs.promises.readdir(start).catch(() => [])
2302
+ for (const name of entries) {
2303
+ if (name === '.git' || name === '.vcs-next') continue
2304
+ const p = path.join(start, name)
2305
+ const st = await fs.promises.stat(p).catch(() => null)
2306
+ if (!st) continue
2307
+ if (st.isDirectory()) {
2308
+ await pruneEmptyDirs(p)
2309
+ const left = await fs.promises.readdir(p).catch(() => [])
2310
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
2311
+ }
2312
+ }
2313
+ }
2314
+ await pruneEmptyDirs(dir)
2315
+ print({ cleaned: toDelete.length }, opts.json === 'true')
2316
+ }
2317
+
2318
+ async function cmdRebase(opts) {
2319
+ const dir = path.resolve(opts.dir || '.')
2320
+ const meta = readRemoteMeta(dir)
2321
+ const cfg = loadConfig()
2322
+ const server = getServer(opts, cfg) || meta.server
2323
+ const token = getToken(opts, cfg) || meta.token
2324
+ const sourceBranch = opts.branch || ''
2325
+ const onto = opts.onto || meta.branch
2326
+ if (!sourceBranch) throw new Error('Missing --branch')
2327
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
2328
+ const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2329
+ commitsUrl.searchParams.set('branch', sourceBranch)
2330
+ const res = await fetch(commitsUrl.toString(), { headers })
2331
+ if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(t || 'commits fetch failed') }
2332
+ const list = await res.json()
2333
+ const ids = Array.isArray(list) ? list.map((c) => c.id || c._id || '').filter(Boolean) : []
2334
+ for (const id of ids) {
2335
+ await cmdCherryPick({ dir, commit: id, branch: onto, noPush: 'true' })
2336
+ const unresolved = await checkForUnresolvedConflicts(dir)
2337
+ if (unresolved.length > 0) {
2338
+ if (opts.json === 'true') {
2339
+ print({ error: 'Rebase stopped due to conflicts', conflicts: unresolved }, true)
2340
+ } else {
2341
+ process.stderr.write(color('Rebase stopped due to conflicts\n', 'red'))
2342
+ }
2343
+ return
2344
+ }
2345
+ }
2346
+ const metaDir = path.join(dir, '.vcs-next')
2347
+ const localPath = path.join(metaDir, 'local.json')
2348
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2349
+ try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
2350
+ const message = opts.message || `rebase ${sourceBranch} onto ${onto}`
2351
+ if (opts.noPush !== 'true') {
2352
+ await cmdPush({ dir, json: opts.json, message })
2353
+ } else {
2354
+ print({ rebased: sourceBranch, onto }, opts.json === 'true')
2355
+ }
2356
+ }
2004
2357
  function help() {
2005
2358
  const h = [
2006
2359
  'Usage: resulgit <group> <command> [options]',
@@ -2012,10 +2365,10 @@ function help() {
2012
2365
  ' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
2013
2366
  ' repo list [--json]',
2014
2367
  ' 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>]',
2368
+ ' repo log --repo <id> [--branch <name>] [--json]',
2369
+ ' repo head --repo <id> [--branch <name>] [--json]',
2370
+ ' repo select [--workspace] (interactive select and clone/open)',
2371
+ ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
2019
2372
  ' switch --branch <name> [--dir <path>]',
2020
2373
  ' current [--dir <path>] (show active repo/branch)',
2021
2374
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
@@ -2023,29 +2376,35 @@ function help() {
2023
2376
  ' status [--dir <path>] [--json]',
2024
2377
  ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
2025
2378
  ' commit --message <text> [--dir <path>] [--json]',
2026
- ' push [--dir <path>] [--json]',
2027
- ' head [--dir <path>] [--json]',
2028
- ' rm --path <file> [--dir <path>] [--json]',
2379
+ ' push [--dir <path>] [--json]',
2380
+ ' head [--dir <path>] [--json]',
2381
+ ' rm --path <file> [--dir <path>] [--json]',
2029
2382
  ' 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>]',
2383
+ ' fetch [--dir <path>]',
2384
+ ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
2385
+ ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
2386
+ ' restore --path <file> [--source <commit>] [--dir <path>]',
2387
+ ' revert --commit <id> [--no-push] [--dir <path>]',
2388
+ ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2389
+ ' show --commit <id> [--dir <path>] [--json]',
2390
+ ' mv --from <old> --to <new> [--dir <path>]',
2391
+ '',
2392
+ 'Conflict Resolution:',
2393
+ ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
2394
+ ' <<<<<<< HEAD (current changes)',
2395
+ ' =======',
2396
+ ' >>>>>>> incoming (incoming changes)',
2397
+ ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
2398
+ ' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
2399
+ ' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
2400
+ ' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
2401
+ ' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
2402
+ ' config list|get|set [--key <k>] [--value <v>]',
2403
+ ' clean [--dir <path>] [--force]',
2404
+ ' workspace set-root --path <dir>',
2405
+ ' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
2406
+ ' checkout <branch>|--branch <name> | --commit <id> [--create] [--dir <path>]',
2407
+ ' rebase --branch <name> [--onto <name>] [--no-push] [--dir <path>]',
2049
2408
  '',
2050
2409
  'Global options:',
2051
2410
  ' --server <url> Override default server',
@@ -2071,7 +2430,6 @@ async function main() {
2071
2430
  return
2072
2431
  }
2073
2432
  if (cmd[0] === 'clone') {
2074
- print({ url: 'Rajaram' }, opts.json === 'true')
2075
2433
  if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
2076
2434
  if (opts.url) {
2077
2435
  await cmdCloneFromUrl(opts, cfg)
@@ -2104,6 +2462,10 @@ async function main() {
2104
2462
  await cmdPull(opts)
2105
2463
  return
2106
2464
  }
2465
+ if (cmd[0] === 'fetch') {
2466
+ await cmdFetch(opts)
2467
+ return
2468
+ }
2107
2469
  if (cmd[0] === 'branch') {
2108
2470
  await cmdBranch(cmd[1], opts)
2109
2471
  return
@@ -2148,6 +2510,26 @@ async function main() {
2148
2510
  await cmdMv(opts)
2149
2511
  return
2150
2512
  }
2513
+ if (cmd[0] === 'init') {
2514
+ await cmdInit(opts)
2515
+ return
2516
+ }
2517
+ if (cmd[0] === 'remote') {
2518
+ await cmdRemote(cmd[1], opts)
2519
+ return
2520
+ }
2521
+ if (cmd[0] === 'config') {
2522
+ await cmdConfig(cmd[1], opts)
2523
+ return
2524
+ }
2525
+ if (cmd[0] === 'clean') {
2526
+ await cmdClean(opts)
2527
+ return
2528
+ }
2529
+ if (cmd[0] === 'rebase') {
2530
+ await cmdRebase(opts)
2531
+ return
2532
+ }
2151
2533
  if (cmd[0] === 'cherry-pick') {
2152
2534
  await cmdCherryPick(opts)
2153
2535
  return
@@ -2316,7 +2698,7 @@ async function tuiSelectBranch(branches, current) {
2316
2698
  process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
2317
2699
  }
2318
2700
  const cleanup = (listener) => {
2319
- try { process.stdin.removeListener('data', listener) } catch {}
2701
+ try { process.stdin.removeListener('data', listener) } catch { }
2320
2702
  process.stdin.setRawMode(false)
2321
2703
  process.stdin.pause()
2322
2704
  process.stdout.write('\x1b[?25h')