resulgit 1.0.0 → 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 +766 -349
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 = []
@@ -35,7 +53,7 @@ function loadConfig() {
35
53
  const s = fs.readFileSync(file, 'utf8')
36
54
  return JSON.parse(s)
37
55
  } catch {
38
- return { server: 'https://delightful-dango-ee4842.netlify.app', token: '', workspaceRoot: path.join(os.homedir(), 'resulgit-workspace') }
56
+ return { server: 'http://10.205.11.8:7513', token: '', workspaceRoot: path.join(os.homedir(), 'resulgit-workspace') }
39
57
  }
40
58
  }
41
59
 
@@ -49,7 +67,7 @@ function saveConfig(update) {
49
67
  }
50
68
 
51
69
  function getServer(opts, cfg) {
52
- return opts.server || cfg.server || 'https://delightful-dango-ee4842.netlify.app'
70
+ return opts.server || cfg.server || 'http://10.205.11.8:7513'
53
71
  }
54
72
 
55
73
  function getToken(opts, cfg) {
@@ -106,10 +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
- const res = await request('POST', url, { email, password }, '')
110
- const token = res.token || ''
111
- if (token) saveConfig({ token })
112
- 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
+ }
113
138
  return
114
139
  }
115
140
  if (sub === 'register') {
@@ -120,10 +145,17 @@ async function cmdAuth(sub, opts) {
120
145
  const displayName = opts.displayName || username
121
146
  if (!username || !email || !password) throw new Error('Missing --username --email --password')
122
147
  const url = new URL('/api/auth/register', server).toString()
123
- const res = await request('POST', url, { username, email, password, displayName }, '')
124
- const token = res.token || ''
125
- if (token) saveConfig({ token })
126
- 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
+ }
127
159
  return
128
160
  }
129
161
  throw new Error('Unknown auth subcommand')
@@ -133,10 +165,17 @@ async function cmdRepo(sub, opts, cfg) {
133
165
  const server = getServer(opts, cfg)
134
166
  const token = getToken(opts, cfg)
135
167
  if (sub === 'list') {
136
- const url = new URL('/api/repositories', server).toString()
137
- const data = await request('GET', url, null, token)
138
- print(data, opts.json === 'true')
139
- 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
+ }
140
179
  return
141
180
  }
142
181
  if (sub === 'create') {
@@ -147,14 +186,21 @@ async function cmdRepo(sub, opts, cfg) {
147
186
  initializeWithReadme: opts.init === 'true'
148
187
  }
149
188
  if (!body.name) throw new Error('Missing --name')
150
- const url = new URL('/api/repositories', server).toString()
151
- const data = await request('POST', url, body, token)
152
- print(data, opts.json === 'true')
189
+ const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
153
190
  try {
154
- const repoId = String(data.id || '')
155
- const branch = String(data.defaultBranch || 'main')
156
- if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
157
- } 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
+ }
158
204
  return
159
205
  }
160
206
  if (sub === 'log') {
@@ -188,6 +234,34 @@ async function cmdRepo(sub, opts, cfg) {
188
234
  throw new Error('Unknown repo subcommand')
189
235
  }
190
236
 
237
+ function parseCloneUrl(urlStr) {
238
+ try {
239
+ const u = new URL(String(urlStr))
240
+ const parts = u.pathname.split('/').filter(Boolean)
241
+ const idx = parts.findIndex((p) => p === 'repositories')
242
+ const repo = idx >= 0 ? (parts[idx + 1] || '') : ''
243
+ const branch = u.searchParams.get('branch') || ''
244
+ const server = u.origin
245
+ return { server, repo, branch }
246
+ } catch {
247
+ return { server: '', repo: '', branch: '' }
248
+ }
249
+ }
250
+
251
+ async function cmdCloneFromUrl(opts, cfg) {
252
+ const parsed = parseCloneUrl(opts.url || '')
253
+ if (!parsed.repo) throw new Error('Invalid clone URL')
254
+ const server = parsed.server || getServer(opts, cfg)
255
+ const token = getToken(opts, cfg)
256
+ let branch = parsed.branch
257
+ if (!branch) {
258
+ const info = await request('GET', new URL(`/api/repositories/${parsed.repo}/branches`, server).toString(), null, token)
259
+ branch = info.defaultBranch || 'main'
260
+ }
261
+ const nextOpts = { repo: parsed.repo, branch, dest: opts.dest, server }
262
+ await cmdClone(nextOpts, cfg)
263
+ }
264
+
191
265
  async function cmdClone(opts, cfg) {
192
266
  const server = getServer(opts, cfg)
193
267
  const token = getToken(opts, cfg)
@@ -195,91 +269,105 @@ async function cmdClone(opts, cfg) {
195
269
  const branch = opts.branch
196
270
  let dest = opts.dest
197
271
  if (!repo || !branch) throw new Error('Missing --repo and --branch')
272
+ const spinner = createSpinner('Initializing clone...', opts.json)
198
273
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
199
- if (!dest) {
200
- try {
201
- const infoUrl = new URL(`/api/repositories/${repo}`, server)
202
- const infoRes = await fetch(infoUrl.toString(), { headers })
203
- if (infoRes.ok) {
204
- const info = await infoRes.json()
205
- const name = info.name || String(repo)
206
- dest = name
207
- } 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 {
208
288
  dest = String(repo)
209
289
  }
210
- } catch {
211
- dest = String(repo)
212
290
  }
213
- }
214
- dest = path.resolve(dest)
215
- const url = new URL(`/api/repositories/${repo}/snapshot`, server)
216
- url.searchParams.set('branch', branch)
217
- const res = await fetch(url.toString(), { headers })
218
- if (!res.ok) {
219
- const text = await res.text()
220
- throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
221
- }
222
- const data = await res.json()
223
- const files = data.files || {}
224
- const root = dest
225
- for (const [p, content] of Object.entries(files)) {
226
- const fullPath = path.join(root, p)
227
- await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
228
- await fs.promises.writeFile(fullPath, content, 'utf8')
229
- }
230
- const metaDir = path.join(root, '.vcs-next')
231
- await fs.promises.mkdir(metaDir, { recursive: true })
232
- const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
233
- await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
234
- const gitDir = path.join(root, '.git')
235
- const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
236
- await fs.promises.mkdir(refsHeadsDir, { recursive: true })
237
- const headContent = `ref: refs/heads/${branch}\n`
238
- await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
239
- const commitId = data.commitId || ''
240
- await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
241
- const gitConfig = [
242
- '[core]',
243
- '\trepositoryformatversion = 0',
244
- '\tfilemode = true',
245
- '\tbare = false',
246
- '\tlogallrefupdates = true',
247
- '',
248
- '[vcs-next]',
249
- `\tserver = ${server}`,
250
- `\trepoId = ${repo}`,
251
- `\tbranch = ${branch}`,
252
- `\ttoken = ${token || ''}`
253
- ].join('\n')
254
- await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
255
- await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
256
- try {
257
- const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
258
- const branchesRes = await fetch(branchesUrl.toString(), { headers })
259
- if (branchesRes.ok) {
260
- const branchesData = await branchesRes.json()
261
- const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
262
- const allRefs = {}
263
- for (const b of branches) {
264
- const name = b.name || ''
265
- const id = b.commitId || ''
266
- if (!name) continue
267
- allRefs[name] = id
268
- await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
269
- }
270
- 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}`)
271
299
  }
272
- } catch {}
273
- try {
274
- const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
275
- commitsUrl.searchParams.set('branch', branch)
276
- const commitsRes = await fetch(commitsUrl.toString(), { headers })
277
- if (commitsRes.ok) {
278
- const commitsList = await commitsRes.json()
279
- 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')
280
309
  }
281
- } catch {}
282
- 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
+ }
283
371
  }
284
372
 
285
373
  function readRemoteMeta(dir) {
@@ -295,7 +383,7 @@ function readRemoteMeta(dir) {
295
383
  const s = fs.readFileSync(fp, 'utf8')
296
384
  const meta = JSON.parse(s)
297
385
  if (meta.repoId && meta.branch) return meta
298
- } catch {}
386
+ } catch { }
299
387
  }
300
388
  const parent = path.dirname(cur)
301
389
  if (parent === cur) break
@@ -392,7 +480,7 @@ async function cmdRestore(opts) {
392
480
  const cfg = loadConfig()
393
481
  const server = getServer(opts, cfg) || meta.server
394
482
  const token = getToken(opts, cfg) || meta.token
395
-
483
+
396
484
  const sourceCommit = opts.source || 'HEAD'
397
485
  let sourceSnap
398
486
  if (sourceCommit === 'HEAD') {
@@ -400,7 +488,7 @@ async function cmdRestore(opts) {
400
488
  } else {
401
489
  sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
402
490
  }
403
-
491
+
404
492
  const sourceContent = sourceSnap.files[filePath]
405
493
  if (sourceContent === undefined) {
406
494
  // File doesn't exist in source - delete it
@@ -435,17 +523,17 @@ async function cmdDiff(opts) {
435
523
  const cfg = loadConfig()
436
524
  const server = getServer(opts, cfg) || meta.server
437
525
  const token = getToken(opts, cfg) || meta.token
438
-
526
+
439
527
  const filePath = opts.path
440
528
  const commitId = opts.commit
441
-
529
+
442
530
  if (commitId) {
443
531
  // Show diff for specific commit
444
532
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
445
533
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
446
534
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
447
535
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
448
-
536
+
449
537
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
450
538
  for (const p of files) {
451
539
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
@@ -481,18 +569,18 @@ async function cmdDiff(opts) {
481
569
  }
482
570
  return
483
571
  }
484
-
572
+
485
573
  // Show diff for working directory
486
574
  const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
487
575
  const local = await collectLocal(dir)
488
576
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
489
-
577
+
490
578
  for (const p of files) {
491
579
  const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
492
580
  const localContent = local[p]?.content || null
493
581
  const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
494
582
  const localId = local[p]?.id
495
-
583
+
496
584
  if (remoteId !== localId) {
497
585
  if (opts.json === 'true') {
498
586
  print({ path: p, remote: remoteContent, local: localContent }, true)
@@ -549,41 +637,51 @@ async function cmdCommit(opts) {
549
637
  const dir = path.resolve(opts.dir || '.')
550
638
  const message = opts.message || ''
551
639
  if (!message) throw new Error('Missing --message')
552
-
553
- // Check for unresolved conflicts
554
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
555
- if (unresolvedConflicts.length > 0) {
556
- if (opts.json === 'true') {
557
- print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
558
- } else {
559
- process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
560
- process.stderr.write(color('Conflicts in files:\n', 'yellow'))
561
- for (const p of unresolvedConflicts) {
562
- 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'))
563
657
  }
564
- process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
658
+ return
565
659
  }
566
- return
567
- }
568
-
569
- const metaDir = path.join(dir, '.vcs-next')
570
- const localPath = path.join(metaDir, 'local.json')
571
- let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
572
- try {
573
- const s = await fs.promises.readFile(localPath, 'utf8')
574
- localMeta = JSON.parse(s)
575
- } catch {}
576
- const local = await collectLocal(dir)
577
- const files = {}
578
- for (const [p, v] of Object.entries(local)) files[p] = v.content
579
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
580
- // Clear conflicts if they were resolved
581
- if (localMeta.conflicts) {
582
- 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
583
684
  }
584
- await fs.promises.mkdir(metaDir, { recursive: true })
585
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
586
- print({ pendingCommit: message }, opts.json === 'true')
587
685
  }
588
686
 
589
687
  async function pullToDir(repo, branch, dir, server, token) {
@@ -608,7 +706,7 @@ async function pullToDir(repo, branch, dir, server, token) {
608
706
  for (const rel of Object.keys(localMap)) {
609
707
  if (!keep.has(rel)) {
610
708
  const fp = path.join(root, rel)
611
- try { await fs.promises.unlink(fp) } catch {}
709
+ try { await fs.promises.unlink(fp) } catch { }
612
710
  }
613
711
  }
614
712
  const pruneEmptyDirs = async (start) => {
@@ -621,7 +719,7 @@ async function pullToDir(repo, branch, dir, server, token) {
621
719
  if (st.isDirectory()) {
622
720
  await pruneEmptyDirs(p)
623
721
  const left = await fs.promises.readdir(p).catch(() => [])
624
- if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
722
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
625
723
  }
626
724
  }
627
725
  }
@@ -647,8 +745,62 @@ async function cmdPull(opts) {
647
745
  const cfg = loadConfig()
648
746
  const server = getServer(opts, cfg) || meta.server
649
747
  const token = getToken(opts, cfg) || meta.token
650
- await pullToDir(meta.repoId, meta.branch, dir, server, token)
651
- 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')
652
804
  }
653
805
 
654
806
  async function fetchRemoteSnapshot(server, repo, branch, token) {
@@ -719,57 +871,57 @@ async function cmdCherryPick(opts) {
719
871
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
720
872
  const m = head.match(/refs\/heads\/(.+)/)
721
873
  if (m) current = m[1]
722
- } catch {}
874
+ } catch { }
723
875
  targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
724
876
  }
725
877
  const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
726
878
  if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
727
-
879
+
728
880
  // Get the current state of the target branch (what we're cherry-picking onto)
729
881
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
730
-
882
+
731
883
  // Get the commit being cherry-picked and its parent
732
884
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
733
885
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
734
886
  const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
735
887
  const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
736
-
888
+
737
889
  // Pull the target branch to ensure we're working with the latest state
738
890
  await pullToDir(meta.repoId, targetBranch, dir, server, token)
739
-
891
+
740
892
  // Re-collect local state after pull (now it matches the target branch)
741
893
  const localAfterPull = await collectLocal(dir)
742
-
894
+
743
895
  // Update local metadata to reflect the pulled state
744
896
  const metaDirInit = path.join(dir, '.vcs-next')
745
897
  await fs.promises.mkdir(metaDirInit, { recursive: true })
746
898
  const localPathInit = path.join(metaDirInit, 'local.json')
747
899
  let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
748
- 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 { }
749
901
  localMetaInit.baseCommitId = currentSnap.commitId
750
902
  localMetaInit.baseFiles = currentSnap.files
751
903
  await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
752
-
904
+
753
905
  // Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
754
906
  const conflicts = []
755
907
  const changes = []
756
908
  const mergedFiles = {}
757
909
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
758
-
910
+
759
911
  for (const p of allPaths) {
760
912
  const b = baseSnap.files[p] // base: parent of commit being cherry-picked
761
913
  const n = targetSnap.files[p] // next: the commit being cherry-picked
762
914
  const c = currentSnap.files[p] // current: current state of target branch (from remote)
763
-
915
+
764
916
  // For cherry-pick, we need to apply the changes from the commit onto the current branch
765
917
  // The logic: if the commit changed something from its parent, apply that change to current
766
918
  const baseContent = b !== undefined ? String(b) : null
767
919
  const commitContent = n !== undefined ? String(n) : null
768
920
  const currentContent = c !== undefined ? String(c) : null
769
-
921
+
770
922
  // Check if commit changed this file from its parent
771
923
  const commitChanged = baseContent !== commitContent
772
-
924
+
773
925
  if (commitChanged) {
774
926
  // The commit modified this file - we want to apply that change
775
927
  // Conflict detection:
@@ -778,15 +930,15 @@ async function cmdCherryPick(opts) {
778
930
  // - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
779
931
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
780
932
  // - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
781
-
933
+
782
934
  const fileWasAdded = baseContent === null && commitContent !== null
783
935
  const fileWasDeleted = baseContent !== null && commitContent === null
784
936
  const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
785
-
937
+
786
938
  // Check for conflicts: current branch changed the file differently than the commit
787
939
  const currentChangedFromBase = currentContent !== baseContent
788
940
  const currentDiffersFromCommit = currentContent !== commitContent
789
-
941
+
790
942
  // Conflict detection:
791
943
  // - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
792
944
  // - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
@@ -794,7 +946,7 @@ async function cmdCherryPick(opts) {
794
946
  // - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
795
947
  // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
796
948
  // - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
797
-
949
+
798
950
  // Conflict if:
799
951
  // 1. Current branch changed the file from base (current !== base)
800
952
  // 2. AND current differs from what commit wants (current !== commit)
@@ -804,7 +956,7 @@ async function cmdCherryPick(opts) {
804
956
  // this is always safe - no conflict. The file is simply being added.
805
957
  const safeAddCase = fileWasAdded && currentContent === null
806
958
  const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
807
-
959
+
808
960
  // Only conflict if file exists in current AND was changed differently
809
961
  if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
810
962
  // Conflict: both changed the file differently
@@ -834,7 +986,7 @@ async function cmdCherryPick(opts) {
834
986
  }
835
987
  }
836
988
  }
837
-
989
+
838
990
  if (conflicts.length > 0) {
839
991
  // Write conflict markers to files
840
992
  for (const conflict of conflicts) {
@@ -844,11 +996,11 @@ async function cmdCherryPick(opts) {
844
996
  conflict.current,
845
997
  conflict.incoming,
846
998
  meta.branch,
847
- `cherry-pick-${commitId.slice(0,7)}`
999
+ `cherry-pick-${commitId.slice(0, 7)}`
848
1000
  )
849
1001
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
850
1002
  }
851
-
1003
+
852
1004
  // Store conflict state in metadata
853
1005
  const metaDir = path.join(dir, '.vcs-next')
854
1006
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -857,9 +1009,9 @@ async function cmdCherryPick(opts) {
857
1009
  try {
858
1010
  const s = await fs.promises.readFile(localPath, 'utf8')
859
1011
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
860
- } catch {}
1012
+ } catch { }
861
1013
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
862
-
1014
+
863
1015
  if (opts.json === 'true') {
864
1016
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
865
1017
  } else {
@@ -872,22 +1024,22 @@ async function cmdCherryPick(opts) {
872
1024
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
873
1025
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
874
1026
  process.stdout.write(color(` =======\n`, 'dim'))
875
- 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'))
876
1028
  }
877
1029
  return
878
1030
  }
879
-
1031
+
880
1032
  // Apply the changes to the filesystem
881
1033
  for (const ch of changes) {
882
1034
  const fp = path.join(dir, ch.path)
883
1035
  if (ch.type === 'delete') {
884
- try { await fs.promises.unlink(fp) } catch {}
1036
+ try { await fs.promises.unlink(fp) } catch { }
885
1037
  } else if (ch.type === 'write') {
886
1038
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
887
1039
  await fs.promises.writeFile(fp, ch.content, 'utf8')
888
1040
  }
889
1041
  }
890
-
1042
+
891
1043
  // Update local metadata with the pending commit
892
1044
  const metaDir = path.join(dir, '.vcs-next')
893
1045
  const localPath = path.join(metaDir, 'local.json')
@@ -896,43 +1048,43 @@ async function cmdCherryPick(opts) {
896
1048
  try {
897
1049
  const s = await fs.promises.readFile(localPath, 'utf8')
898
1050
  localMeta = JSON.parse(s)
899
- } catch {}
900
-
901
- const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
902
-
1051
+ } catch { }
1052
+
1053
+ const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
1054
+
903
1055
  // Collect final state after applying changes
904
1056
  const finalLocal = await collectLocal(dir)
905
1057
  const finalFiles = {}
906
1058
  for (const [p, v] of Object.entries(finalLocal)) {
907
1059
  finalFiles[p] = v.content
908
1060
  }
909
-
1061
+
910
1062
  // Set pending commit with the actual files
911
1063
  localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
912
1064
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
913
-
1065
+
914
1066
  // Ensure remote.json has the correct target branch before pushing
915
1067
  const remoteMetaPath = path.join(metaDir, 'remote.json')
916
1068
  let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
917
1069
  try {
918
1070
  const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
919
1071
  remoteMeta = { ...JSON.parse(s), branch: targetBranch }
920
- } catch {}
1072
+ } catch { }
921
1073
  await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
922
-
1074
+
923
1075
  // Also update .git metadata
924
1076
  const gitDir = path.join(dir, '.git')
925
1077
  const gitMetaPath = path.join(gitDir, 'vcs-next.json')
926
1078
  await fs.promises.mkdir(gitDir, { recursive: true })
927
1079
  await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
928
1080
  await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
929
-
1081
+
930
1082
  if (opts.json === 'true') {
931
1083
  print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
932
1084
  } else {
933
- 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'))
934
1086
  }
935
-
1087
+
936
1088
  if (opts.noPush !== 'true') {
937
1089
  await cmdPush({ dir, json: opts.json, message: cherryMsg })
938
1090
  }
@@ -955,7 +1107,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
955
1107
  const incoming = String(incomingContent || '')
956
1108
  const currentLines = current.split(/\r?\n/)
957
1109
  const incomingLines = incoming.split(/\r?\n/)
958
-
1110
+
959
1111
  // Simple conflict marker format
960
1112
  const markers = [
961
1113
  `<<<<<<< ${currentLabel || 'HEAD'}`,
@@ -964,7 +1116,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
964
1116
  ...incomingLines,
965
1117
  `>>>>>>> ${incomingLabel || 'incoming'}`
966
1118
  ]
967
-
1119
+
968
1120
  return markers.join('\n')
969
1121
  }
970
1122
 
@@ -990,73 +1142,87 @@ async function cmdPush(opts) {
990
1142
  const metaDir = path.join(dir, '.vcs-next')
991
1143
  const localPath = path.join(metaDir, 'local.json')
992
1144
  let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1145
+ const spinner = createSpinner('Preparing to push...', opts.json)
993
1146
  try {
994
1147
  const s = await fs.promises.readFile(localPath, 'utf8')
995
1148
  localMeta = JSON.parse(s)
996
- } catch {}
997
-
998
- // Check for unresolved conflicts in files
999
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1000
- if (unresolvedConflicts.length > 0) {
1001
- if (opts.json === 'true') {
1002
- print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1003
- } else {
1004
- process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
1005
- process.stderr.write(color('Conflicts in files:\n', 'yellow'))
1006
- for (const p of unresolvedConflicts) {
1007
- 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'))
1008
1166
  }
1009
- process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1167
+ return
1010
1168
  }
1011
- return
1012
- }
1013
-
1014
- const cfg = loadConfig()
1015
- const server = getServer(opts, cfg) || remoteMeta.server
1016
- const token = getToken(opts, cfg) || remoteMeta.token
1017
- const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1018
- const base = localMeta.baseFiles || {}
1019
- const local = await collectLocal(dir)
1020
- const conflicts = []
1021
- const merged = {}
1022
- const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1023
- for (const p of paths) {
1024
- const b = p in base ? base[p] : null
1025
- const r = p in remote.files ? remote.files[p] : null
1026
- const l = p in local ? local[p].content : null
1027
- const changedLocal = String(l) !== String(b)
1028
- const changedRemote = String(r) !== String(b)
1029
- if (changedLocal && changedRemote && String(l) !== String(r)) {
1030
- const line = firstDiffLine(l || '', r || '')
1031
- conflicts.push({ path: p, line })
1032
- } else if (changedLocal && !changedRemote) {
1033
- if (l !== null) merged[p] = l
1034
- } else if (!changedLocal && changedRemote) {
1035
- if (r !== null) merged[p] = r
1036
- } else {
1037
- 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
+ }
1038
1198
  }
1039
- }
1040
- if (conflicts.length > 0) {
1041
- if (opts.json === 'true') {
1042
- print({ conflicts }, true)
1043
- } else {
1044
- process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1045
- process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1046
- for (const c of conflicts) {
1047
- 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
+ }
1048
1209
  }
1210
+ return
1049
1211
  }
1050
- 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
1051
1225
  }
1052
- const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1053
- const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1054
- const data = await request('POST', url, body, token)
1055
- localMeta.baseCommitId = data.id || remote.commitId || ''
1056
- localMeta.baseFiles = merged
1057
- localMeta.pendingCommit = null
1058
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1059
- print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1060
1226
  }
1061
1227
 
1062
1228
  async function cmdMerge(opts) {
@@ -1067,13 +1233,13 @@ async function cmdMerge(opts) {
1067
1233
  const token = getToken(opts, cfg) || meta.token
1068
1234
  const sourceBranch = opts.branch || ''
1069
1235
  if (!sourceBranch) throw new Error('Missing --branch')
1070
-
1236
+
1071
1237
  // Get current branch state
1072
1238
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1073
-
1239
+
1074
1240
  // Get source branch state
1075
1241
  const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
1076
-
1242
+
1077
1243
  // Find common ancestor (merge base)
1078
1244
  // For simplicity, we'll use the current branch's base commit as the merge base
1079
1245
  // In a full implementation, we'd find the actual common ancestor
@@ -1081,29 +1247,29 @@ async function cmdMerge(opts) {
1081
1247
  const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
1082
1248
  const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
1083
1249
  if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
1084
-
1250
+
1085
1251
  // Get base commit (for now, use current branch's commit as base)
1086
1252
  // In real Git, we'd find the merge base commit
1087
1253
  const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1088
-
1254
+
1089
1255
  // Pull current branch to ensure we're up to date
1090
1256
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1091
1257
  const localAfterPull = await collectLocal(dir)
1092
-
1258
+
1093
1259
  // Three-way merge: base, current (target), source
1094
1260
  const conflicts = []
1095
1261
  const changes = []
1096
1262
  const mergedFiles = {}
1097
1263
  const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
1098
-
1264
+
1099
1265
  for (const p of allPaths) {
1100
1266
  const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
1101
1267
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1102
1268
  const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
1103
-
1269
+
1104
1270
  const currentChanged = current !== base
1105
1271
  const sourceChanged = source !== base
1106
-
1272
+
1107
1273
  if (currentChanged && sourceChanged && current !== source) {
1108
1274
  // Conflict: both branches changed the file differently
1109
1275
  const line = firstDiffLine(current || '', source || '')
@@ -1137,7 +1303,7 @@ async function cmdMerge(opts) {
1137
1303
  }
1138
1304
  }
1139
1305
  }
1140
-
1306
+
1141
1307
  if (conflicts.length > 0) {
1142
1308
  // Write conflict markers to files
1143
1309
  for (const conflict of conflicts) {
@@ -1151,7 +1317,7 @@ async function cmdMerge(opts) {
1151
1317
  )
1152
1318
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1153
1319
  }
1154
-
1320
+
1155
1321
  // Store conflict state in metadata
1156
1322
  const metaDir = path.join(dir, '.vcs-next')
1157
1323
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1160,9 +1326,9 @@ async function cmdMerge(opts) {
1160
1326
  try {
1161
1327
  const s = await fs.promises.readFile(localPath, 'utf8')
1162
1328
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1163
- } catch {}
1329
+ } catch { }
1164
1330
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1165
-
1331
+
1166
1332
  const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
1167
1333
  if (opts.json === 'true') {
1168
1334
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
@@ -1180,18 +1346,18 @@ async function cmdMerge(opts) {
1180
1346
  }
1181
1347
  return
1182
1348
  }
1183
-
1349
+
1184
1350
  // Apply changes to filesystem
1185
1351
  for (const ch of changes) {
1186
1352
  const fp = path.join(dir, ch.path)
1187
1353
  if (ch.type === 'delete') {
1188
- try { await fs.promises.unlink(fp) } catch {}
1354
+ try { await fs.promises.unlink(fp) } catch { }
1189
1355
  } else if (ch.type === 'write') {
1190
1356
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1191
1357
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1192
1358
  }
1193
1359
  }
1194
-
1360
+
1195
1361
  // Update local metadata
1196
1362
  const metaDir = path.join(dir, '.vcs-next')
1197
1363
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1200,27 +1366,27 @@ async function cmdMerge(opts) {
1200
1366
  try {
1201
1367
  const s = await fs.promises.readFile(localPath, 'utf8')
1202
1368
  localMeta = JSON.parse(s)
1203
- } catch {}
1204
-
1369
+ } catch { }
1370
+
1205
1371
  const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
1206
-
1372
+
1207
1373
  // Collect final state
1208
1374
  const finalLocal = await collectLocal(dir)
1209
1375
  const finalFiles = {}
1210
1376
  for (const [p, v] of Object.entries(finalLocal)) {
1211
1377
  finalFiles[p] = v.content
1212
1378
  }
1213
-
1379
+
1214
1380
  // Set pending commit
1215
1381
  localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
1216
1382
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1217
-
1383
+
1218
1384
  if (opts.json === 'true') {
1219
1385
  print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
1220
1386
  } else {
1221
1387
  process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
1222
1388
  }
1223
-
1389
+
1224
1390
  if (opts.noPush !== 'true') {
1225
1391
  await cmdPush({ dir, json: opts.json, message: mergeMsg })
1226
1392
  }
@@ -1231,7 +1397,7 @@ async function cmdStash(sub, opts) {
1231
1397
  const metaDir = path.join(dir, '.vcs-next')
1232
1398
  const stashDir = path.join(metaDir, 'stash')
1233
1399
  await fs.promises.mkdir(stashDir, { recursive: true })
1234
-
1400
+
1235
1401
  if (sub === 'list' || sub === undefined) {
1236
1402
  try {
1237
1403
  const files = await fs.promises.readdir(stashDir)
@@ -1264,7 +1430,7 @@ async function cmdStash(sub, opts) {
1264
1430
  }
1265
1431
  return
1266
1432
  }
1267
-
1433
+
1268
1434
  if (sub === 'save' || (sub === undefined && !opts.list)) {
1269
1435
  const message = opts.message || 'WIP'
1270
1436
  const local = await collectLocal(dir)
@@ -1275,14 +1441,14 @@ async function cmdStash(sub, opts) {
1275
1441
  const stashId = Date.now().toString()
1276
1442
  const stash = { message, files, createdAt: Date.now() }
1277
1443
  await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
1278
-
1444
+
1279
1445
  // Restore to base state (discard local changes)
1280
1446
  const meta = readRemoteMeta(dir)
1281
1447
  const cfg = loadConfig()
1282
1448
  const server = getServer(opts, cfg) || meta.server
1283
1449
  const token = getToken(opts, cfg) || meta.token
1284
1450
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1285
-
1451
+
1286
1452
  if (opts.json === 'true') {
1287
1453
  print({ stashId, message }, true)
1288
1454
  } else {
@@ -1290,7 +1456,7 @@ async function cmdStash(sub, opts) {
1290
1456
  }
1291
1457
  return
1292
1458
  }
1293
-
1459
+
1294
1460
  if (sub === 'pop' || sub === 'apply') {
1295
1461
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1296
1462
  try {
@@ -1306,19 +1472,19 @@ async function cmdStash(sub, opts) {
1306
1472
  stashes.sort((a, b) => b.createdAt - a.createdAt)
1307
1473
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1308
1474
  const stash = stashes[stashIndex]
1309
-
1475
+
1310
1476
  // Apply stash files
1311
1477
  for (const [p, content] of Object.entries(stash.files || {})) {
1312
1478
  const fp = path.join(dir, p)
1313
1479
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1314
1480
  await fs.promises.writeFile(fp, content, 'utf8')
1315
1481
  }
1316
-
1482
+
1317
1483
  if (sub === 'pop') {
1318
1484
  // Remove stash
1319
1485
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1320
1486
  }
1321
-
1487
+
1322
1488
  if (opts.json === 'true') {
1323
1489
  print({ applied: stash.id, message: stash.message }, true)
1324
1490
  } else {
@@ -1329,7 +1495,7 @@ async function cmdStash(sub, opts) {
1329
1495
  }
1330
1496
  return
1331
1497
  }
1332
-
1498
+
1333
1499
  if (sub === 'drop') {
1334
1500
  const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1335
1501
  try {
@@ -1346,7 +1512,7 @@ async function cmdStash(sub, opts) {
1346
1512
  if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1347
1513
  const stash = stashes[stashIndex]
1348
1514
  await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1349
-
1515
+
1350
1516
  if (opts.json === 'true') {
1351
1517
  print({ dropped: stash.id }, true)
1352
1518
  } else {
@@ -1357,7 +1523,7 @@ async function cmdStash(sub, opts) {
1357
1523
  }
1358
1524
  return
1359
1525
  }
1360
-
1526
+
1361
1527
  if (sub === 'clear') {
1362
1528
  try {
1363
1529
  const files = await fs.promises.readdir(stashDir)
@@ -1374,7 +1540,7 @@ async function cmdStash(sub, opts) {
1374
1540
  }
1375
1541
  return
1376
1542
  }
1377
-
1543
+
1378
1544
  throw new Error('Unknown stash subcommand')
1379
1545
  }
1380
1546
 
@@ -1396,7 +1562,7 @@ async function cmdBranch(sub, opts) {
1396
1562
  const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
1397
1563
  const m = head.match(/refs\/heads\/(.+)/)
1398
1564
  if (m) current = m[1]
1399
- } catch {}
1565
+ } catch { }
1400
1566
  process.stdout.write(color('Branches:\n', 'bold'))
1401
1567
  const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1402
1568
  for (const b of list) {
@@ -1434,7 +1600,21 @@ async function cmdBranch(sub, opts) {
1434
1600
  print(data, opts.json === 'true')
1435
1601
  return
1436
1602
  }
1437
-
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
+
1438
1618
  throw new Error('Unknown branch subcommand')
1439
1619
  }
1440
1620
 
@@ -1464,7 +1644,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
1464
1644
  for (const rel of Object.keys(localMap)) {
1465
1645
  if (!keep.has(rel)) {
1466
1646
  const fp = path.join(root, rel)
1467
- try { await fs.promises.unlink(fp) } catch {}
1647
+ try { await fs.promises.unlink(fp) } catch { }
1468
1648
  }
1469
1649
  }
1470
1650
  const metaDir = path.join(root, '.vcs-next')
@@ -1492,6 +1672,11 @@ async function cmdCheckout(opts) {
1492
1672
  return
1493
1673
  }
1494
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
+ }
1495
1680
  await pullToDir(meta.repoId, branch, dir, server, token)
1496
1681
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1497
1682
  }
@@ -1585,7 +1770,7 @@ async function cmdHead(opts) {
1585
1770
  } else {
1586
1771
  commitId = head.trim()
1587
1772
  }
1588
- } catch {}
1773
+ } catch { }
1589
1774
  if (!commitId) {
1590
1775
  const cfg = loadConfig()
1591
1776
  const server = getServer(opts, cfg) || meta.server
@@ -1606,12 +1791,12 @@ async function cmdShow(opts) {
1606
1791
  const token = getToken(opts, cfg) || meta.token
1607
1792
  const commitId = opts.commit
1608
1793
  if (!commitId) throw new Error('Missing --commit')
1609
-
1794
+
1610
1795
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1611
1796
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1612
1797
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1613
1798
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1614
-
1799
+
1615
1800
  if (opts.json === 'true') {
1616
1801
  print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
1617
1802
  } else {
@@ -1623,7 +1808,7 @@ async function cmdShow(opts) {
1623
1808
  process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
1624
1809
  }
1625
1810
  process.stdout.write(`\n${commit.message || ''}\n\n`)
1626
-
1811
+
1627
1812
  // Show file changes
1628
1813
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
1629
1814
  const changed = []
@@ -1654,34 +1839,34 @@ async function cmdRevert(opts) {
1654
1839
  const token = getToken(opts, cfg) || meta.token
1655
1840
  const commitId = opts.commit
1656
1841
  if (!commitId) throw new Error('Missing --commit')
1657
-
1842
+
1658
1843
  // Get the commit to revert
1659
1844
  const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1660
1845
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1661
1846
  const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1662
1847
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1663
-
1848
+
1664
1849
  // Get current state
1665
1850
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1666
1851
  const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1667
1852
  const localAfterPull = await collectLocal(dir)
1668
-
1853
+
1669
1854
  // Revert: apply inverse of commit changes
1670
1855
  const conflicts = []
1671
1856
  const changes = []
1672
1857
  const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
1673
-
1858
+
1674
1859
  for (const p of allPaths) {
1675
1860
  const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
1676
1861
  const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
1677
1862
  const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1678
1863
  const local = localAfterPull[p]?.content || null
1679
-
1864
+
1680
1865
  // Revert: we want to go from commit state back to parent state
1681
1866
  // But check if current branch has changed this file
1682
1867
  const commitChanged = base !== commitContent
1683
1868
  const currentChanged = current !== base
1684
-
1869
+
1685
1870
  if (commitChanged) {
1686
1871
  // Commit changed this file - we want to revert it
1687
1872
  if (currentChanged && current !== base) {
@@ -1703,7 +1888,7 @@ async function cmdRevert(opts) {
1703
1888
  }
1704
1889
  }
1705
1890
  }
1706
-
1891
+
1707
1892
  if (conflicts.length > 0) {
1708
1893
  // Write conflict markers to files
1709
1894
  for (const conflict of conflicts) {
@@ -1713,11 +1898,11 @@ async function cmdRevert(opts) {
1713
1898
  conflict.current,
1714
1899
  conflict.incoming,
1715
1900
  meta.branch,
1716
- `revert-${commitId.slice(0,7)}`
1901
+ `revert-${commitId.slice(0, 7)}`
1717
1902
  )
1718
1903
  await fs.promises.writeFile(fp, conflictContent, 'utf8')
1719
1904
  }
1720
-
1905
+
1721
1906
  // Store conflict state in metadata
1722
1907
  const metaDir = path.join(dir, '.vcs-next')
1723
1908
  await fs.promises.mkdir(metaDir, { recursive: true })
@@ -1726,9 +1911,9 @@ async function cmdRevert(opts) {
1726
1911
  try {
1727
1912
  const s = await fs.promises.readFile(localPath, 'utf8')
1728
1913
  localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1729
- } catch {}
1914
+ } catch { }
1730
1915
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1731
-
1916
+
1732
1917
  if (opts.json === 'true') {
1733
1918
  print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
1734
1919
  } else {
@@ -1741,22 +1926,22 @@ async function cmdRevert(opts) {
1741
1926
  process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
1742
1927
  process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
1743
1928
  process.stdout.write(color(` =======\n`, 'dim'))
1744
- process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
1929
+ process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
1745
1930
  }
1746
1931
  return
1747
1932
  }
1748
-
1933
+
1749
1934
  // Apply changes
1750
1935
  for (const ch of changes) {
1751
1936
  const fp = path.join(dir, ch.path)
1752
1937
  if (ch.type === 'delete') {
1753
- try { await fs.promises.unlink(fp) } catch {}
1938
+ try { await fs.promises.unlink(fp) } catch { }
1754
1939
  } else if (ch.type === 'write') {
1755
1940
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1756
1941
  await fs.promises.writeFile(fp, ch.content, 'utf8')
1757
1942
  }
1758
1943
  }
1759
-
1944
+
1760
1945
  // Update metadata
1761
1946
  const metaDir = path.join(dir, '.vcs-next')
1762
1947
  const localPath = path.join(metaDir, 'local.json')
@@ -1765,24 +1950,24 @@ async function cmdRevert(opts) {
1765
1950
  try {
1766
1951
  const s = await fs.promises.readFile(localPath, 'utf8')
1767
1952
  localMeta = JSON.parse(s)
1768
- } catch {}
1769
-
1953
+ } catch { }
1954
+
1770
1955
  const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
1771
1956
  const finalLocal = await collectLocal(dir)
1772
1957
  const finalFiles = {}
1773
1958
  for (const [p, v] of Object.entries(finalLocal)) {
1774
1959
  finalFiles[p] = v.content
1775
1960
  }
1776
-
1961
+
1777
1962
  localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
1778
1963
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1779
-
1964
+
1780
1965
  if (opts.json === 'true') {
1781
1966
  print({ reverted: commitId, changes: changes.length }, true)
1782
1967
  } else {
1783
- 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'))
1784
1969
  }
1785
-
1970
+
1786
1971
  if (opts.noPush !== 'true') {
1787
1972
  await cmdPush({ dir, json: opts.json, message: revertMsg })
1788
1973
  }
@@ -1794,11 +1979,11 @@ async function cmdReset(opts) {
1794
1979
  const cfg = loadConfig()
1795
1980
  const server = getServer(opts, cfg) || meta.server
1796
1981
  const token = getToken(opts, cfg) || meta.token
1797
-
1982
+
1798
1983
  const commitId = opts.commit || 'HEAD'
1799
1984
  const mode = opts.mode || 'mixed' // soft, mixed, hard
1800
1985
  const filePath = opts.path
1801
-
1986
+
1802
1987
  if (filePath) {
1803
1988
  // Reset specific file (unstage)
1804
1989
  const metaDir = path.join(dir, '.vcs-next')
@@ -1807,8 +1992,8 @@ async function cmdReset(opts) {
1807
1992
  try {
1808
1993
  const s = await fs.promises.readFile(localPath, 'utf8')
1809
1994
  localMeta = JSON.parse(s)
1810
- } catch {}
1811
-
1995
+ } catch { }
1996
+
1812
1997
  // Restore file from base
1813
1998
  const baseContent = localMeta.baseFiles[filePath]
1814
1999
  if (baseContent !== undefined) {
@@ -1816,7 +2001,7 @@ async function cmdReset(opts) {
1816
2001
  await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1817
2002
  await fs.promises.writeFile(fp, String(baseContent), 'utf8')
1818
2003
  }
1819
-
2004
+
1820
2005
  if (opts.json === 'true') {
1821
2006
  print({ reset: filePath }, true)
1822
2007
  } else {
@@ -1824,7 +2009,7 @@ async function cmdReset(opts) {
1824
2009
  }
1825
2010
  return
1826
2011
  }
1827
-
2012
+
1828
2013
  // Reset to commit
1829
2014
  let targetSnap
1830
2015
  if (commitId === 'HEAD') {
@@ -1832,7 +2017,7 @@ async function cmdReset(opts) {
1832
2017
  } else {
1833
2018
  targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1834
2019
  }
1835
-
2020
+
1836
2021
  if (mode === 'hard') {
1837
2022
  // Hard reset: discard all changes, reset to commit
1838
2023
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
@@ -1853,14 +2038,14 @@ async function cmdReset(opts) {
1853
2038
  // Mixed reset: keep changes unstaged (default)
1854
2039
  await pullToDir(meta.repoId, meta.branch, dir, server, token)
1855
2040
  }
1856
-
2041
+
1857
2042
  // Update metadata
1858
2043
  const metaDir = path.join(dir, '.vcs-next')
1859
2044
  const localPath = path.join(metaDir, 'local.json')
1860
2045
  await fs.promises.mkdir(metaDir, { recursive: true })
1861
2046
  const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
1862
2047
  await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1863
-
2048
+
1864
2049
  if (opts.json === 'true') {
1865
2050
  print({ reset: commitId, mode }, true)
1866
2051
  } else {
@@ -1868,23 +2053,57 @@ async function cmdReset(opts) {
1868
2053
  }
1869
2054
  }
1870
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
+
1871
2090
  async function cmdMv(opts) {
1872
2091
  const dir = path.resolve(opts.dir || '.')
1873
2092
  const from = opts.from
1874
2093
  const to = opts.to
1875
2094
  if (!from || !to) throw new Error('Missing --from and --to')
1876
-
2095
+
1877
2096
  const fromPath = path.join(dir, from)
1878
2097
  const toPath = path.join(dir, to)
1879
-
2098
+
1880
2099
  // Check if source exists
1881
2100
  const stat = await fs.promises.stat(fromPath).catch(() => null)
1882
2101
  if (!stat) throw new Error(`Source file not found: ${from}`)
1883
-
2102
+
1884
2103
  // Move file
1885
2104
  await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
1886
2105
  await fs.promises.rename(fromPath, toPath)
1887
-
2106
+
1888
2107
  // If it's a tracked file, we need to update it in the next commit
1889
2108
  // For now, just move it - the next commit will track the change
1890
2109
  if (opts.json === 'true') {
@@ -1896,7 +2115,7 @@ async function cmdMv(opts) {
1896
2115
 
1897
2116
  async function cmdAdd(opts) {
1898
2117
  const dir = path.resolve(opts.dir || '.')
1899
-
2118
+
1900
2119
  // Handle --all flag
1901
2120
  if (opts.all === 'true' || opts.all === true) {
1902
2121
  const meta = readRemoteMeta(dir)
@@ -1905,7 +2124,7 @@ async function cmdAdd(opts) {
1905
2124
  const token = getToken(opts, cfg) || meta.token
1906
2125
  const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
1907
2126
  const local = await collectLocal(dir)
1908
-
2127
+
1909
2128
  // Stage all changes (add new, modified, delete removed)
1910
2129
  const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
1911
2130
  let stagedCount = 0
@@ -1916,7 +2135,7 @@ async function cmdAdd(opts) {
1916
2135
  stagedCount++
1917
2136
  }
1918
2137
  }
1919
-
2138
+
1920
2139
  if (opts.json === 'true') {
1921
2140
  print({ staged: stagedCount }, true)
1922
2141
  } else {
@@ -1924,7 +2143,7 @@ async function cmdAdd(opts) {
1924
2143
  }
1925
2144
  return
1926
2145
  }
1927
-
2146
+
1928
2147
  const p = opts.path
1929
2148
  if (!p) throw new Error('Missing --path')
1930
2149
  const abs = path.join(dir, p)
@@ -1972,6 +2191,169 @@ async function cmdAdd(opts) {
1972
2191
  print({ added: p, dir }, opts.json === 'true')
1973
2192
  }
1974
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
+ }
1975
2357
  function help() {
1976
2358
  const h = [
1977
2359
  'Usage: resulgit <group> <command> [options]',
@@ -1983,10 +2365,10 @@ function help() {
1983
2365
  ' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
1984
2366
  ' repo list [--json]',
1985
2367
  ' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
1986
- ' repo log --repo <id> [--branch <name>] [--json]',
1987
- ' repo head --repo <id> [--branch <name>] [--json]',
1988
- ' repo select [--workspace] (interactive select and clone/open)',
1989
- ' 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>]',
1990
2372
  ' switch --branch <name> [--dir <path>]',
1991
2373
  ' current [--dir <path>] (show active repo/branch)',
1992
2374
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
@@ -1994,29 +2376,35 @@ function help() {
1994
2376
  ' status [--dir <path>] [--json]',
1995
2377
  ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
1996
2378
  ' commit --message <text> [--dir <path>] [--json]',
1997
- ' push [--dir <path>] [--json]',
1998
- ' head [--dir <path>] [--json]',
1999
- ' rm --path <file> [--dir <path>] [--json]',
2379
+ ' push [--dir <path>] [--json]',
2380
+ ' head [--dir <path>] [--json]',
2381
+ ' rm --path <file> [--dir <path>] [--json]',
2000
2382
  ' pull [--dir <path>]',
2001
- ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
2002
- ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
2003
- ' restore --path <file> [--source <commit>] [--dir <path>]',
2004
- ' revert --commit <id> [--no-push] [--dir <path>]',
2005
- ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2006
- ' show --commit <id> [--dir <path>] [--json]',
2007
- ' mv --from <old> --to <new> [--dir <path>]',
2008
- '',
2009
- 'Conflict Resolution:',
2010
- ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
2011
- ' <<<<<<< HEAD (current changes)',
2012
- ' =======',
2013
- ' >>>>>>> incoming (incoming changes)',
2014
- ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
2015
- ' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
2016
- ' clone --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
2017
- ' workspace set-root --path <dir>',
2018
- ' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
2019
- ' 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>]',
2020
2408
  '',
2021
2409
  'Global options:',
2022
2410
  ' --server <url> Override default server',
@@ -2042,7 +2430,12 @@ async function main() {
2042
2430
  return
2043
2431
  }
2044
2432
  if (cmd[0] === 'clone') {
2045
- await cmdClone(opts, cfg)
2433
+ if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
2434
+ if (opts.url) {
2435
+ await cmdCloneFromUrl(opts, cfg)
2436
+ } else {
2437
+ await cmdClone(opts, cfg)
2438
+ }
2046
2439
  return
2047
2440
  }
2048
2441
  if (cmd[0] === 'status') {
@@ -2069,6 +2462,10 @@ async function main() {
2069
2462
  await cmdPull(opts)
2070
2463
  return
2071
2464
  }
2465
+ if (cmd[0] === 'fetch') {
2466
+ await cmdFetch(opts)
2467
+ return
2468
+ }
2072
2469
  if (cmd[0] === 'branch') {
2073
2470
  await cmdBranch(cmd[1], opts)
2074
2471
  return
@@ -2113,6 +2510,26 @@ async function main() {
2113
2510
  await cmdMv(opts)
2114
2511
  return
2115
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
+ }
2116
2533
  if (cmd[0] === 'cherry-pick') {
2117
2534
  await cmdCherryPick(opts)
2118
2535
  return
@@ -2281,7 +2698,7 @@ async function tuiSelectBranch(branches, current) {
2281
2698
  process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
2282
2699
  }
2283
2700
  const cleanup = (listener) => {
2284
- try { process.stdin.removeListener('data', listener) } catch {}
2701
+ try { process.stdin.removeListener('data', listener) } catch { }
2285
2702
  process.stdin.setRawMode(false)
2286
2703
  process.stdin.pause()
2287
2704
  process.stdout.write('\x1b[?25h')