resulgit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +143 -0
  2. package/package.json +38 -0
  3. package/resulgit.js +2309 -0
package/resulgit.js ADDED
@@ -0,0 +1,2309 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const os = require('os')
5
+ const crypto = require('crypto')
6
+ const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
7
+ function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
8
+
9
+ function parseArgs(argv) {
10
+ const tokens = argv.slice(2)
11
+ const cmd = []
12
+ const opts = {}
13
+ for (let i = 0; i < tokens.length; i++) {
14
+ const t = tokens[i]
15
+ if (t.startsWith('--')) {
16
+ const key = t.slice(2)
17
+ const val = tokens[i + 1] && !tokens[i + 1].startsWith('--') ? tokens[++i] : 'true'
18
+ opts[key] = val
19
+ } else if (cmd.length < 2) {
20
+ cmd.push(t)
21
+ }
22
+ }
23
+ return { cmd, opts }
24
+ }
25
+
26
+ function configPath() {
27
+ const dir = path.join(os.homedir(), '.resulgit')
28
+ const file = path.join(dir, 'config.json')
29
+ return { dir, file }
30
+ }
31
+
32
+ function loadConfig() {
33
+ const { dir, file } = configPath()
34
+ try {
35
+ const s = fs.readFileSync(file, 'utf8')
36
+ return JSON.parse(s)
37
+ } catch {
38
+ return { server: 'https://delightful-dango-ee4842.netlify.app', token: '', workspaceRoot: path.join(os.homedir(), 'resulgit-workspace') }
39
+ }
40
+ }
41
+
42
+ function saveConfig(update) {
43
+ const { dir, file } = configPath()
44
+ const cur = loadConfig()
45
+ const next = { ...cur, ...update }
46
+ fs.mkdirSync(dir, { recursive: true })
47
+ fs.writeFileSync(file, JSON.stringify(next, null, 2))
48
+ return next
49
+ }
50
+
51
+ function getServer(opts, cfg) {
52
+ return opts.server || cfg.server || 'https://delightful-dango-ee4842.netlify.app'
53
+ }
54
+
55
+ function getToken(opts, cfg) {
56
+ return opts.token || cfg.token || ''
57
+ }
58
+
59
+ async function request(method, url, body, token) {
60
+ const headers = { 'Content-Type': 'application/json' }
61
+ if (token) headers.Authorization = `Bearer ${token}`
62
+ const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })
63
+ if (!res.ok) {
64
+ const text = await res.text()
65
+ throw new Error(`${res.status} ${res.statusText} ${text}`)
66
+ }
67
+ const ct = res.headers.get('content-type') || ''
68
+ if (ct.includes('application/json')) return res.json()
69
+ return res.text()
70
+ }
71
+
72
+ function print(obj, json) {
73
+ if (json) {
74
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
75
+ return
76
+ }
77
+ if (Array.isArray(obj)) {
78
+ for (const o of obj) process.stdout.write(`${o.id || ''}\t${o.name || ''}\n`)
79
+ return
80
+ }
81
+ if (typeof obj === 'string') {
82
+ process.stdout.write(obj + '\n')
83
+ return
84
+ }
85
+ process.stdout.write(Object.entries(obj).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join('\n') + '\n')
86
+ }
87
+
88
+ async function cmdAuth(sub, opts) {
89
+ if (sub === 'set-token') {
90
+ const token = opts.token || ''
91
+ if (!token) throw new Error('Missing --token')
92
+ const cfg = saveConfig({ token })
93
+ print({ token: cfg.token }, opts.json === 'true')
94
+ return
95
+ }
96
+ if (sub === 'set-server') {
97
+ const server = opts.server || ''
98
+ if (!server) throw new Error('Missing --server')
99
+ const cfg = saveConfig({ server })
100
+ print({ server: cfg.server }, opts.json === 'true')
101
+ return
102
+ }
103
+ if (sub === 'login') {
104
+ const server = opts.server || loadConfig().server
105
+ const email = opts.email
106
+ const password = opts.password
107
+ if (!email || !password) throw new Error('Missing --email and --password')
108
+ 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')
113
+ return
114
+ }
115
+ if (sub === 'register') {
116
+ const server = opts.server || loadConfig().server
117
+ const username = opts.username
118
+ const email = opts.email
119
+ const password = opts.password
120
+ const displayName = opts.displayName || username
121
+ if (!username || !email || !password) throw new Error('Missing --username --email --password')
122
+ 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')
127
+ return
128
+ }
129
+ throw new Error('Unknown auth subcommand')
130
+ }
131
+
132
+ async function cmdRepo(sub, opts, cfg) {
133
+ const server = getServer(opts, cfg)
134
+ const token = getToken(opts, cfg)
135
+ 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)
140
+ return
141
+ }
142
+ if (sub === 'create') {
143
+ const body = {
144
+ name: opts.name || '',
145
+ description: opts.description || '',
146
+ visibility: opts.visibility || 'private',
147
+ initializeWithReadme: opts.init === 'true'
148
+ }
149
+ 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')
153
+ 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 {}
158
+ return
159
+ }
160
+ if (sub === 'log') {
161
+ const repo = opts.repo
162
+ const branch = opts.branch
163
+ if (!repo) throw new Error('Missing --repo')
164
+ const u = new URL(`/api/repositories/${repo}/commits`, server)
165
+ if (branch) u.searchParams.set('branch', branch)
166
+ const data = await request('GET', u.toString(), null, token)
167
+ print(data, opts.json === 'true')
168
+ return
169
+ }
170
+ if (sub === 'head') {
171
+ const repo = opts.repo
172
+ const branchOpt = opts.branch
173
+ if (!repo) throw new Error('Missing --repo')
174
+ const info = await request('GET', new URL(`/api/repositories/${repo}/branches`, server).toString(), null, token)
175
+ let branch = branchOpt || info.defaultBranch || 'main'
176
+ const found = (info.branches || []).find(b => b.name === branch)
177
+ const commitId = found ? (found.commitId || '') : ''
178
+ const out = { branch, commitId }
179
+ print(opts.json === 'true' ? out : (commitId || ''), opts.json === 'true')
180
+ return
181
+ }
182
+ if (sub === 'select') {
183
+ const url = new URL('/api/repositories', server).toString()
184
+ const repos = await request('GET', url, null, token)
185
+ await tuiSelectRepo(repos || [], cfg, opts)
186
+ return
187
+ }
188
+ throw new Error('Unknown repo subcommand')
189
+ }
190
+
191
+ async function cmdClone(opts, cfg) {
192
+ const server = getServer(opts, cfg)
193
+ const token = getToken(opts, cfg)
194
+ const repo = opts.repo
195
+ const branch = opts.branch
196
+ let dest = opts.dest
197
+ if (!repo || !branch) throw new Error('Missing --repo and --branch')
198
+ 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 {
208
+ dest = String(repo)
209
+ }
210
+ } catch {
211
+ dest = String(repo)
212
+ }
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))
271
+ }
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))
280
+ }
281
+ } catch {}
282
+ print('Clone complete', opts.json === 'true')
283
+ }
284
+
285
+ function readRemoteMeta(dir) {
286
+ const abs = path.resolve(dir)
287
+ const tryFiles = (d) => [
288
+ path.join(d, '.vcs-next', 'remote.json'),
289
+ path.join(d, '.git', 'vcs-next.json')
290
+ ]
291
+ let cur = abs
292
+ while (true) {
293
+ for (const fp of tryFiles(cur)) {
294
+ try {
295
+ const s = fs.readFileSync(fp, 'utf8')
296
+ const meta = JSON.parse(s)
297
+ if (meta.repoId && meta.branch) return meta
298
+ } catch {}
299
+ }
300
+ const parent = path.dirname(cur)
301
+ if (parent === cur) break
302
+ cur = parent
303
+ }
304
+ const hint = `${abs}`
305
+ const err = new Error(`Not a cloned repository: ${hint}. Use --dir <path> or run inside a cloned repo.`)
306
+ throw err
307
+ }
308
+
309
+ function hashContent(buf) {
310
+ return crypto.createHash('sha1').update(buf).digest('hex')
311
+ }
312
+
313
+ async function collectLocal(dir) {
314
+ const out = {}
315
+ const base = path.resolve(dir)
316
+ async function walk(cur, rel) {
317
+ const entries = await fs.promises.readdir(cur, { withFileTypes: true })
318
+ for (const e of entries) {
319
+ if (e.name === '.git' || e.name === '.vcs-next') continue
320
+ const abs = path.join(cur, e.name)
321
+ const rp = rel ? rel + '/' + e.name : e.name
322
+ if (e.isDirectory()) {
323
+ await walk(abs, rp)
324
+ } else if (e.isFile()) {
325
+ const buf = await fs.promises.readFile(abs)
326
+ out[rp] = { content: buf.toString('utf8'), id: hashContent(buf) }
327
+ }
328
+ }
329
+ }
330
+ await walk(base, '')
331
+ return out
332
+ }
333
+
334
+ async function fetchRemoteFilesMap(server, repo, branch, token) {
335
+ const u = new URL(`/api/repositories/${repo}/files/all`, server)
336
+ u.searchParams.set('branchId', branch)
337
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
338
+ const res = await fetch(u.toString(), { headers })
339
+ if (res.ok) {
340
+ const data = await res.json()
341
+ const map = {}
342
+ for (const it of data.entries || []) map[it.path] = it.id
343
+ return { map, headCommitId: data.commitId }
344
+ }
345
+ const su = new URL(`/api/repositories/${repo}/snapshot`, server)
346
+ su.searchParams.set('branch', branch)
347
+ const sres = await fetch(su.toString(), { headers })
348
+ if (sres.ok) {
349
+ const data = await sres.json()
350
+ const files = data.files || {}
351
+ const map = {}
352
+ for (const [p, content] of Object.entries(files)) {
353
+ const id = hashContent(Buffer.from(String(content)))
354
+ map[p] = id
355
+ }
356
+ return { map, headCommitId: data.commitId }
357
+ }
358
+ const rtext = await res.text().catch(() => '')
359
+ const stext = await sres.text().catch(() => '')
360
+ throw new Error(`remote fetch failed: ${res.status} ${res.statusText} ${rtext}; snapshot failed: ${sres.status} ${sres.statusText} ${stext}`)
361
+ }
362
+
363
+ async function cmdStatus(opts) {
364
+ const dir = path.resolve(opts.dir || '.')
365
+ const meta = readRemoteMeta(dir)
366
+ const cfg = loadConfig()
367
+ const server = getServer(opts, cfg) || meta.server
368
+ const token = getToken(opts, cfg) || meta.token
369
+ const local = await collectLocal(dir)
370
+ const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
371
+ const added = []
372
+ const modified = []
373
+ const deleted = []
374
+ const remotePaths = new Set(Object.keys(remote.map))
375
+ const localPaths = new Set(Object.keys(local))
376
+ for (const p of localPaths) {
377
+ if (!remotePaths.has(p)) added.push(p)
378
+ else if (remote.map[p] !== local[p].id) modified.push(p)
379
+ }
380
+ for (const p of remotePaths) {
381
+ if (!localPaths.has(p)) deleted.push(p)
382
+ }
383
+ const out = { branch: meta.branch, head: remote.headCommitId, added, modified, deleted }
384
+ print(out, opts.json === 'true')
385
+ }
386
+
387
+ async function cmdRestore(opts) {
388
+ const dir = path.resolve(opts.dir || '.')
389
+ const filePath = opts.path
390
+ if (!filePath) throw new Error('Missing --path')
391
+ const meta = readRemoteMeta(dir)
392
+ const cfg = loadConfig()
393
+ const server = getServer(opts, cfg) || meta.server
394
+ const token = getToken(opts, cfg) || meta.token
395
+
396
+ const sourceCommit = opts.source || 'HEAD'
397
+ let sourceSnap
398
+ if (sourceCommit === 'HEAD') {
399
+ sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
400
+ } else {
401
+ sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
402
+ }
403
+
404
+ const sourceContent = sourceSnap.files[filePath]
405
+ if (sourceContent === undefined) {
406
+ // File doesn't exist in source - delete it
407
+ const fp = path.join(dir, filePath)
408
+ try {
409
+ await fs.promises.unlink(fp)
410
+ if (opts.json === 'true') {
411
+ print({ restored: filePath, action: 'deleted' }, true)
412
+ } else {
413
+ process.stdout.write(color(`Restored ${filePath} (deleted)\n`, 'green'))
414
+ }
415
+ } catch {
416
+ if (opts.json !== 'true') process.stdout.write(`File ${filePath} doesn't exist\n`)
417
+ else print({ restored: filePath, action: 'noop' }, true)
418
+ }
419
+ } else {
420
+ // Restore file from source
421
+ const fp = path.join(dir, filePath)
422
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
423
+ await fs.promises.writeFile(fp, String(sourceContent), 'utf8')
424
+ if (opts.json === 'true') {
425
+ print({ restored: filePath, from: sourceCommit }, true)
426
+ } else {
427
+ process.stdout.write(color(`Restored ${filePath} from ${sourceCommit}\n`, 'green'))
428
+ }
429
+ }
430
+ }
431
+
432
+ async function cmdDiff(opts) {
433
+ const dir = path.resolve(opts.dir || '.')
434
+ const meta = readRemoteMeta(dir)
435
+ const cfg = loadConfig()
436
+ const server = getServer(opts, cfg) || meta.server
437
+ const token = getToken(opts, cfg) || meta.token
438
+
439
+ const filePath = opts.path
440
+ const commitId = opts.commit
441
+
442
+ if (commitId) {
443
+ // Show diff for specific commit
444
+ const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
445
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
446
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
447
+ const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
448
+
449
+ const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
450
+ for (const p of files) {
451
+ const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
452
+ const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
453
+ if (oldContent !== newContent) {
454
+ if (opts.json === 'true') {
455
+ print({ path: p, old: oldContent, new: newContent }, true)
456
+ } else {
457
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
458
+ if (oldContent === null) {
459
+ process.stdout.write(color(`+++ b/${p}\n`, 'green'))
460
+ process.stdout.write(color(`+${newContent}\n`, 'green'))
461
+ } else if (newContent === null) {
462
+ process.stdout.write(color(`--- a/${p}\n`, 'red'))
463
+ process.stdout.write(color(`-${oldContent}\n`, 'red'))
464
+ } else {
465
+ const oldLines = oldContent.split(/\r?\n/)
466
+ const newLines = newContent.split(/\r?\n/)
467
+ const maxLen = Math.max(oldLines.length, newLines.length)
468
+ for (let i = 0; i < maxLen; i++) {
469
+ const oldLine = oldLines[i]
470
+ const newLine = newLines[i]
471
+ if (oldLine !== newLine) {
472
+ if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
473
+ if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
474
+ } else if (oldLine !== undefined) {
475
+ process.stdout.write(` ${oldLine}\n`)
476
+ }
477
+ }
478
+ }
479
+ }
480
+ }
481
+ }
482
+ return
483
+ }
484
+
485
+ // Show diff for working directory
486
+ const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
487
+ const local = await collectLocal(dir)
488
+ const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
489
+
490
+ for (const p of files) {
491
+ const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
492
+ const localContent = local[p]?.content || null
493
+ const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
494
+ const localId = local[p]?.id
495
+
496
+ if (remoteId !== localId) {
497
+ if (opts.json === 'true') {
498
+ print({ path: p, remote: remoteContent, local: localContent }, true)
499
+ } else {
500
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
501
+ if (localContent === null) {
502
+ process.stdout.write(color(`--- a/${p}\n`, 'red'))
503
+ process.stdout.write(color(`-${remoteContent || ''}\n`, 'red'))
504
+ } else if (remoteContent === null) {
505
+ process.stdout.write(color(`+++ b/${p}\n`, 'green'))
506
+ process.stdout.write(color(`+${localContent}\n`, 'green'))
507
+ } else {
508
+ const remoteLines = String(remoteContent).split(/\r?\n/)
509
+ const localLines = String(localContent).split(/\r?\n/)
510
+ const maxLen = Math.max(remoteLines.length, localLines.length)
511
+ for (let i = 0; i < maxLen; i++) {
512
+ const remoteLine = remoteLines[i]
513
+ const localLine = localLines[i]
514
+ if (remoteLine !== localLine) {
515
+ if (remoteLine !== undefined) process.stdout.write(color(`-${remoteLine}\n`, 'red'))
516
+ if (localLine !== undefined) process.stdout.write(color(`+${localLine}\n`, 'green'))
517
+ } else if (remoteLine !== undefined) {
518
+ process.stdout.write(` ${remoteLine}\n`)
519
+ }
520
+ }
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+
527
+ async function cmdRm(opts) {
528
+ const dir = path.resolve(opts.dir || '.')
529
+ const pathArg = opts.path
530
+ if (!pathArg) throw new Error('Missing --path')
531
+ const meta = readRemoteMeta(dir)
532
+ const cfg = loadConfig()
533
+ const server = getServer(opts, cfg) || meta.server
534
+ const token = getToken(opts, cfg) || meta.token
535
+ const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
536
+ u.searchParams.set('branch', meta.branch)
537
+ u.searchParams.set('path', pathArg)
538
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
539
+ const res = await fetch(u.toString(), { method: 'DELETE', headers })
540
+ if (!res.ok) {
541
+ const t = await res.text()
542
+ throw new Error(t || 'delete failed')
543
+ }
544
+ const data = await res.json()
545
+ print(data, opts.json === 'true')
546
+ }
547
+
548
+ async function cmdCommit(opts) {
549
+ const dir = path.resolve(opts.dir || '.')
550
+ const message = opts.message || ''
551
+ 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'))
563
+ }
564
+ process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
565
+ }
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
583
+ }
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
+ }
588
+
589
+ async function pullToDir(repo, branch, dir, server, token) {
590
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
591
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
592
+ url.searchParams.set('branch', branch)
593
+ const res = await fetch(url.toString(), { headers })
594
+ if (!res.ok) {
595
+ const text = await res.text()
596
+ throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
597
+ }
598
+ const data = await res.json()
599
+ const files = data.files || {}
600
+ const root = path.resolve(dir)
601
+ for (const [p, content] of Object.entries(files)) {
602
+ const fullPath = path.join(root, p)
603
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
604
+ await fs.promises.writeFile(fullPath, content, 'utf8')
605
+ }
606
+ const keep = new Set(Object.keys(files))
607
+ const localMap = await collectLocal(root)
608
+ for (const rel of Object.keys(localMap)) {
609
+ if (!keep.has(rel)) {
610
+ const fp = path.join(root, rel)
611
+ try { await fs.promises.unlink(fp) } catch {}
612
+ }
613
+ }
614
+ const pruneEmptyDirs = async (start) => {
615
+ const entries = await fs.promises.readdir(start).catch(() => [])
616
+ for (const name of entries) {
617
+ if (name === '.git' || name === '.vcs-next') continue
618
+ const p = path.join(start, name)
619
+ const st = await fs.promises.stat(p).catch(() => null)
620
+ if (!st) continue
621
+ if (st.isDirectory()) {
622
+ await pruneEmptyDirs(p)
623
+ const left = await fs.promises.readdir(p).catch(() => [])
624
+ if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
625
+ }
626
+ }
627
+ }
628
+ await pruneEmptyDirs(root)
629
+ const metaDir = path.join(root, '.vcs-next')
630
+ await fs.promises.mkdir(metaDir, { recursive: true })
631
+ const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
632
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
633
+ const gitDir = path.join(root, '.git')
634
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
635
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
636
+ const headContent = `ref: refs/heads/${branch}\n`
637
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
638
+ const commitId = data.commitId || ''
639
+ await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
640
+ const localMeta = { baseCommitId: commitId, baseFiles: files, pendingCommit: null }
641
+ await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
642
+ }
643
+
644
+ async function cmdPull(opts) {
645
+ const dir = path.resolve(opts.dir || '.')
646
+ const meta = readRemoteMeta(dir)
647
+ const cfg = loadConfig()
648
+ const server = getServer(opts, cfg) || meta.server
649
+ const token = getToken(opts, cfg) || meta.token
650
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
651
+ print('Pull complete', opts.json === 'true')
652
+ }
653
+
654
+ async function fetchRemoteSnapshot(server, repo, branch, token) {
655
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
656
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
657
+ if (branch) url.searchParams.set('branch', branch)
658
+ const res = await fetch(url.toString(), { headers })
659
+ if (!res.ok) {
660
+ const text = await res.text()
661
+ throw new Error(text || 'snapshot failed')
662
+ }
663
+ const data = await res.json()
664
+ return { files: data.files || {}, commitId: data.commitId || '' }
665
+ }
666
+
667
+ async function fetchSnapshotByCommit(server, repo, commitId, token) {
668
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
669
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
670
+ url.searchParams.set('commitId', commitId)
671
+ const res = await fetch(url.toString(), { headers })
672
+ if (!res.ok) {
673
+ const text = await res.text()
674
+ throw new Error(text || 'snapshot failed')
675
+ }
676
+ const data = await res.json()
677
+ return { files: data.files || {}, commitId: data.commitId || '' }
678
+ }
679
+
680
+ async function fetchCommitMeta(server, repo, commitId, token) {
681
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
682
+ const url = new URL(`/api/repositories/${repo}/commits/${commitId}`, server)
683
+ const res = await fetch(url.toString(), { headers })
684
+ if (!res.ok) {
685
+ const text = await res.text()
686
+ throw new Error(text || 'commit meta failed')
687
+ }
688
+ const data = await res.json()
689
+ return data
690
+ }
691
+
692
+ function applyCherry(base, next, localContent) {
693
+ const b = base === undefined ? null : String(base)
694
+ const n = next === undefined ? null : String(next)
695
+ const l = localContent === undefined ? null : String(localContent)
696
+ const changedLocal = l !== b
697
+ const changedRemote = n !== b
698
+ if (changedLocal && changedRemote && l !== n) {
699
+ return { conflict: true }
700
+ }
701
+ if (changedLocal && !changedRemote) return { content: l }
702
+ if (!changedLocal && changedRemote) return { content: n }
703
+ return { content: b }
704
+ }
705
+
706
+ async function cmdCherryPick(opts) {
707
+ const dir = path.resolve(opts.dir || '.')
708
+ const meta = readRemoteMeta(dir)
709
+ const cfg = loadConfig()
710
+ const server = getServer(opts, cfg) || meta.server
711
+ const token = getToken(opts, cfg) || meta.token
712
+ const commitId = opts.commit
713
+ if (!commitId) throw new Error('Missing --commit')
714
+ const branchesInfo = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
715
+ let targetBranch = opts.branch || ''
716
+ if (!targetBranch) {
717
+ let current = meta.branch
718
+ try {
719
+ const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
720
+ const m = head.match(/refs\/heads\/(.+)/)
721
+ if (m) current = m[1]
722
+ } catch {}
723
+ targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
724
+ }
725
+ const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
726
+ if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
727
+
728
+ // Get the current state of the target branch (what we're cherry-picking onto)
729
+ const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
730
+
731
+ // Get the commit being cherry-picked and its parent
732
+ const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
733
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
734
+ const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
735
+ const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
736
+
737
+ // Pull the target branch to ensure we're working with the latest state
738
+ await pullToDir(meta.repoId, targetBranch, dir, server, token)
739
+
740
+ // Re-collect local state after pull (now it matches the target branch)
741
+ const localAfterPull = await collectLocal(dir)
742
+
743
+ // Update local metadata to reflect the pulled state
744
+ const metaDirInit = path.join(dir, '.vcs-next')
745
+ await fs.promises.mkdir(metaDirInit, { recursive: true })
746
+ const localPathInit = path.join(metaDirInit, 'local.json')
747
+ let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
748
+ try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
749
+ localMetaInit.baseCommitId = currentSnap.commitId
750
+ localMetaInit.baseFiles = currentSnap.files
751
+ await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
752
+
753
+ // Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
754
+ const conflicts = []
755
+ const changes = []
756
+ const mergedFiles = {}
757
+ const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
758
+
759
+ for (const p of allPaths) {
760
+ const b = baseSnap.files[p] // base: parent of commit being cherry-picked
761
+ const n = targetSnap.files[p] // next: the commit being cherry-picked
762
+ const c = currentSnap.files[p] // current: current state of target branch (from remote)
763
+
764
+ // For cherry-pick, we need to apply the changes from the commit onto the current branch
765
+ // The logic: if the commit changed something from its parent, apply that change to current
766
+ const baseContent = b !== undefined ? String(b) : null
767
+ const commitContent = n !== undefined ? String(n) : null
768
+ const currentContent = c !== undefined ? String(c) : null
769
+
770
+ // Check if commit changed this file from its parent
771
+ const commitChanged = baseContent !== commitContent
772
+
773
+ if (commitChanged) {
774
+ // The commit modified this file - we want to apply that change
775
+ // Conflict detection:
776
+ // - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (just add it)
777
+ // - If file was modified in commit (base=content1, commit=content2) and current=content1: NO CONFLICT (apply change)
778
+ // - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
779
+ // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
780
+ // - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
781
+
782
+ const fileWasAdded = baseContent === null && commitContent !== null
783
+ const fileWasDeleted = baseContent !== null && commitContent === null
784
+ const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
785
+
786
+ // Check for conflicts: current branch changed the file differently than the commit
787
+ const currentChangedFromBase = currentContent !== baseContent
788
+ const currentDiffersFromCommit = currentContent !== commitContent
789
+
790
+ // Conflict detection:
791
+ // - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
792
+ // - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
793
+ // - If file was modified in commit (base=content1, commit=content2) and current=content1: NO CONFLICT (apply change)
794
+ // - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
795
+ // - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
796
+ // - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
797
+
798
+ // Conflict if:
799
+ // 1. Current branch changed the file from base (current !== base)
800
+ // 2. AND current differs from what commit wants (current !== commit)
801
+ // 3. AND it's not the case where file was added in commit AND doesn't exist in current (safe case)
802
+ //
803
+ // Special case: If file was added in commit (base=null, commit=content) and doesn't exist in current (current=null),
804
+ // this is always safe - no conflict. The file is simply being added.
805
+ const safeAddCase = fileWasAdded && currentContent === null
806
+ const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
807
+
808
+ // Only conflict if file exists in current AND was changed differently
809
+ if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
810
+ // Conflict: both changed the file differently
811
+ const line = firstDiffLine(currentContent || '', commitContent || '')
812
+ conflicts.push({ path: p, line, current: currentContent, incoming: commitContent })
813
+ // Don't set mergedFiles - will write conflict markers
814
+ } else {
815
+ // No conflict: apply the commit's version
816
+ if (commitContent !== null) {
817
+ mergedFiles[p] = commitContent
818
+ // Apply change if file is new or content differs
819
+ if (currentContent === null || currentContent !== commitContent) {
820
+ changes.push({ type: 'write', path: p, content: commitContent })
821
+ }
822
+ } else {
823
+ // File was deleted in the commit
824
+ if (currentContent !== null) {
825
+ changes.push({ type: 'delete', path: p })
826
+ // Don't add to mergedFiles (file is deleted)
827
+ }
828
+ }
829
+ }
830
+ } else {
831
+ // Commit didn't change this file - keep current state
832
+ if (currentContent !== null) {
833
+ mergedFiles[p] = currentContent
834
+ }
835
+ }
836
+ }
837
+
838
+ if (conflicts.length > 0) {
839
+ // Write conflict markers to files
840
+ for (const conflict of conflicts) {
841
+ const fp = path.join(dir, conflict.path)
842
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
843
+ const conflictContent = writeConflictMarkers(
844
+ conflict.current,
845
+ conflict.incoming,
846
+ meta.branch,
847
+ `cherry-pick-${commitId.slice(0,7)}`
848
+ )
849
+ await fs.promises.writeFile(fp, conflictContent, 'utf8')
850
+ }
851
+
852
+ // Store conflict state in metadata
853
+ const metaDir = path.join(dir, '.vcs-next')
854
+ await fs.promises.mkdir(metaDir, { recursive: true })
855
+ const localPath = path.join(metaDir, 'local.json')
856
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
857
+ try {
858
+ const s = await fs.promises.readFile(localPath, 'utf8')
859
+ localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
860
+ } catch {}
861
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
862
+
863
+ if (opts.json === 'true') {
864
+ print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
865
+ } else {
866
+ process.stdout.write(color(`\nCherry-pick conflict detected!\n`, 'red'))
867
+ process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
868
+ for (const c of conflicts) {
869
+ process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
870
+ }
871
+ process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
872
+ process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
873
+ process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
874
+ process.stdout.write(color(` =======\n`, 'dim'))
875
+ process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
876
+ }
877
+ return
878
+ }
879
+
880
+ // Apply the changes to the filesystem
881
+ for (const ch of changes) {
882
+ const fp = path.join(dir, ch.path)
883
+ if (ch.type === 'delete') {
884
+ try { await fs.promises.unlink(fp) } catch {}
885
+ } else if (ch.type === 'write') {
886
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
887
+ await fs.promises.writeFile(fp, ch.content, 'utf8')
888
+ }
889
+ }
890
+
891
+ // Update local metadata with the pending commit
892
+ const metaDir = path.join(dir, '.vcs-next')
893
+ const localPath = path.join(metaDir, 'local.json')
894
+ await fs.promises.mkdir(metaDir, { recursive: true })
895
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
896
+ try {
897
+ const s = await fs.promises.readFile(localPath, 'utf8')
898
+ localMeta = JSON.parse(s)
899
+ } catch {}
900
+
901
+ const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
902
+
903
+ // Collect final state after applying changes
904
+ const finalLocal = await collectLocal(dir)
905
+ const finalFiles = {}
906
+ for (const [p, v] of Object.entries(finalLocal)) {
907
+ finalFiles[p] = v.content
908
+ }
909
+
910
+ // Set pending commit with the actual files
911
+ localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
912
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
913
+
914
+ // Ensure remote.json has the correct target branch before pushing
915
+ const remoteMetaPath = path.join(metaDir, 'remote.json')
916
+ let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
917
+ try {
918
+ const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
919
+ remoteMeta = { ...JSON.parse(s), branch: targetBranch }
920
+ } catch {}
921
+ await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
922
+
923
+ // Also update .git metadata
924
+ const gitDir = path.join(dir, '.git')
925
+ const gitMetaPath = path.join(gitDir, 'vcs-next.json')
926
+ await fs.promises.mkdir(gitDir, { recursive: true })
927
+ await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
928
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
929
+
930
+ if (opts.json === 'true') {
931
+ print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
932
+ } else {
933
+ process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
934
+ }
935
+
936
+ if (opts.noPush !== 'true') {
937
+ await cmdPush({ dir, json: opts.json, message: cherryMsg })
938
+ }
939
+ }
940
+
941
+ function firstDiffLine(a, b) {
942
+ const la = String(a).split(/\r?\n/)
943
+ const lb = String(b).split(/\r?\n/)
944
+ const n = Math.max(la.length, lb.length)
945
+ for (let i = 0; i < n; i++) {
946
+ const va = la[i] || ''
947
+ const vb = lb[i] || ''
948
+ if (va !== vb) return i + 1
949
+ }
950
+ return 0
951
+ }
952
+
953
+ function writeConflictMarkers(currentContent, incomingContent, currentLabel, incomingLabel) {
954
+ const current = String(currentContent || '')
955
+ const incoming = String(incomingContent || '')
956
+ const currentLines = current.split(/\r?\n/)
957
+ const incomingLines = incoming.split(/\r?\n/)
958
+
959
+ // Simple conflict marker format
960
+ const markers = [
961
+ `<<<<<<< ${currentLabel || 'HEAD'}`,
962
+ ...currentLines,
963
+ '=======',
964
+ ...incomingLines,
965
+ `>>>>>>> ${incomingLabel || 'incoming'}`
966
+ ]
967
+
968
+ return markers.join('\n')
969
+ }
970
+
971
+ function hasConflictMarkers(content) {
972
+ const text = String(content)
973
+ return text.includes('<<<<<<<') && text.includes('=======') && text.includes('>>>>>>>')
974
+ }
975
+
976
+ async function checkForUnresolvedConflicts(dir) {
977
+ const local = await collectLocal(dir)
978
+ const conflicts = []
979
+ for (const [p, v] of Object.entries(local)) {
980
+ if (hasConflictMarkers(v.content)) {
981
+ conflicts.push(p)
982
+ }
983
+ }
984
+ return conflicts
985
+ }
986
+
987
+ async function cmdPush(opts) {
988
+ const dir = path.resolve(opts.dir || '.')
989
+ const remoteMeta = readRemoteMeta(dir)
990
+ const metaDir = path.join(dir, '.vcs-next')
991
+ const localPath = path.join(metaDir, 'local.json')
992
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
993
+ try {
994
+ const s = await fs.promises.readFile(localPath, 'utf8')
995
+ 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'))
1008
+ }
1009
+ process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1010
+ }
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
1038
+ }
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'))
1048
+ }
1049
+ }
1050
+ return
1051
+ }
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
+ }
1061
+
1062
+ async function cmdMerge(opts) {
1063
+ const dir = path.resolve(opts.dir || '.')
1064
+ const meta = readRemoteMeta(dir)
1065
+ const cfg = loadConfig()
1066
+ const server = getServer(opts, cfg) || meta.server
1067
+ const token = getToken(opts, cfg) || meta.token
1068
+ const sourceBranch = opts.branch || ''
1069
+ if (!sourceBranch) throw new Error('Missing --branch')
1070
+
1071
+ // Get current branch state
1072
+ const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1073
+
1074
+ // Get source branch state
1075
+ const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
1076
+
1077
+ // Find common ancestor (merge base)
1078
+ // For simplicity, we'll use the current branch's base commit as the merge base
1079
+ // In a full implementation, we'd find the actual common ancestor
1080
+ const branchesInfo = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
1081
+ const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
1082
+ const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
1083
+ if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
1084
+
1085
+ // Get base commit (for now, use current branch's commit as base)
1086
+ // In real Git, we'd find the merge base commit
1087
+ const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1088
+
1089
+ // Pull current branch to ensure we're up to date
1090
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
1091
+ const localAfterPull = await collectLocal(dir)
1092
+
1093
+ // Three-way merge: base, current (target), source
1094
+ const conflicts = []
1095
+ const changes = []
1096
+ const mergedFiles = {}
1097
+ const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
1098
+
1099
+ for (const p of allPaths) {
1100
+ const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
1101
+ const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1102
+ const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
1103
+
1104
+ const currentChanged = current !== base
1105
+ const sourceChanged = source !== base
1106
+
1107
+ if (currentChanged && sourceChanged && current !== source) {
1108
+ // Conflict: both branches changed the file differently
1109
+ const line = firstDiffLine(current || '', source || '')
1110
+ conflicts.push({ path: p, line, current, incoming: source })
1111
+ // Don't set mergedFiles - will write conflict markers
1112
+ } else if (sourceChanged && !currentChanged) {
1113
+ // Source changed, current didn't - take source
1114
+ if (source !== null) {
1115
+ mergedFiles[p] = source
1116
+ const localContent = localAfterPull[p]?.content || null
1117
+ if (localContent !== source) {
1118
+ changes.push({ type: 'write', path: p, content: source })
1119
+ }
1120
+ } else {
1121
+ // Source deleted the file
1122
+ if (current !== null) {
1123
+ changes.push({ type: 'delete', path: p })
1124
+ }
1125
+ }
1126
+ } else if (currentChanged && !sourceChanged) {
1127
+ // Current changed, source didn't - keep current
1128
+ if (current !== null) {
1129
+ mergedFiles[p] = current
1130
+ }
1131
+ } else {
1132
+ // Neither changed or both same - keep current/base
1133
+ if (current !== null) {
1134
+ mergedFiles[p] = current
1135
+ } else if (base !== null) {
1136
+ mergedFiles[p] = base
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ if (conflicts.length > 0) {
1142
+ // Write conflict markers to files
1143
+ for (const conflict of conflicts) {
1144
+ const fp = path.join(dir, conflict.path)
1145
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1146
+ const conflictContent = writeConflictMarkers(
1147
+ conflict.current,
1148
+ conflict.incoming,
1149
+ meta.branch,
1150
+ sourceBranch
1151
+ )
1152
+ await fs.promises.writeFile(fp, conflictContent, 'utf8')
1153
+ }
1154
+
1155
+ // Store conflict state in metadata
1156
+ const metaDir = path.join(dir, '.vcs-next')
1157
+ await fs.promises.mkdir(metaDir, { recursive: true })
1158
+ const localPath = path.join(metaDir, 'local.json')
1159
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
1160
+ try {
1161
+ const s = await fs.promises.readFile(localPath, 'utf8')
1162
+ localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1163
+ } catch {}
1164
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1165
+
1166
+ const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
1167
+ if (opts.json === 'true') {
1168
+ print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
1169
+ } else {
1170
+ process.stdout.write(color(`\nMerge conflict detected!\n`, 'red'))
1171
+ process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
1172
+ for (const c of conflicts) {
1173
+ process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
1174
+ }
1175
+ process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
1176
+ process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
1177
+ process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
1178
+ process.stdout.write(color(` =======\n`, 'dim'))
1179
+ process.stdout.write(color(` >>>>>>> ${sourceBranch}\n`, 'dim'))
1180
+ }
1181
+ return
1182
+ }
1183
+
1184
+ // Apply changes to filesystem
1185
+ for (const ch of changes) {
1186
+ const fp = path.join(dir, ch.path)
1187
+ if (ch.type === 'delete') {
1188
+ try { await fs.promises.unlink(fp) } catch {}
1189
+ } else if (ch.type === 'write') {
1190
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1191
+ await fs.promises.writeFile(fp, ch.content, 'utf8')
1192
+ }
1193
+ }
1194
+
1195
+ // Update local metadata
1196
+ const metaDir = path.join(dir, '.vcs-next')
1197
+ await fs.promises.mkdir(metaDir, { recursive: true })
1198
+ const localPath = path.join(metaDir, 'local.json')
1199
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
1200
+ try {
1201
+ const s = await fs.promises.readFile(localPath, 'utf8')
1202
+ localMeta = JSON.parse(s)
1203
+ } catch {}
1204
+
1205
+ const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
1206
+
1207
+ // Collect final state
1208
+ const finalLocal = await collectLocal(dir)
1209
+ const finalFiles = {}
1210
+ for (const [p, v] of Object.entries(finalLocal)) {
1211
+ finalFiles[p] = v.content
1212
+ }
1213
+
1214
+ // Set pending commit
1215
+ localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
1216
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1217
+
1218
+ if (opts.json === 'true') {
1219
+ print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
1220
+ } else {
1221
+ process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
1222
+ }
1223
+
1224
+ if (opts.noPush !== 'true') {
1225
+ await cmdPush({ dir, json: opts.json, message: mergeMsg })
1226
+ }
1227
+ }
1228
+
1229
+ async function cmdStash(sub, opts) {
1230
+ const dir = path.resolve(opts.dir || '.')
1231
+ const metaDir = path.join(dir, '.vcs-next')
1232
+ const stashDir = path.join(metaDir, 'stash')
1233
+ await fs.promises.mkdir(stashDir, { recursive: true })
1234
+
1235
+ if (sub === 'list' || sub === undefined) {
1236
+ try {
1237
+ const files = await fs.promises.readdir(stashDir)
1238
+ const stashes = []
1239
+ for (const f of files) {
1240
+ if (f.endsWith('.json')) {
1241
+ const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
1242
+ const stash = JSON.parse(content)
1243
+ stashes.push({ id: f.replace('.json', ''), message: stash.message || '', createdAt: stash.createdAt || 0 })
1244
+ }
1245
+ }
1246
+ stashes.sort((a, b) => b.createdAt - a.createdAt)
1247
+ if (opts.json === 'true') {
1248
+ print(stashes, true)
1249
+ } else {
1250
+ if (stashes.length === 0) {
1251
+ process.stdout.write('No stashes found.\n')
1252
+ } else {
1253
+ process.stdout.write(color('Stashes:\n', 'bold'))
1254
+ for (let i = 0; i < stashes.length; i++) {
1255
+ const s = stashes[i]
1256
+ const date = new Date(s.createdAt).toLocaleString()
1257
+ process.stdout.write(`stash@{${i}}: ${s.message || '(no message)'} (${date})\n`)
1258
+ }
1259
+ }
1260
+ }
1261
+ } catch {
1262
+ if (opts.json !== 'true') process.stdout.write('No stashes found.\n')
1263
+ else print([], true)
1264
+ }
1265
+ return
1266
+ }
1267
+
1268
+ if (sub === 'save' || (sub === undefined && !opts.list)) {
1269
+ const message = opts.message || 'WIP'
1270
+ const local = await collectLocal(dir)
1271
+ const files = {}
1272
+ for (const [p, v] of Object.entries(local)) {
1273
+ files[p] = v.content
1274
+ }
1275
+ const stashId = Date.now().toString()
1276
+ const stash = { message, files, createdAt: Date.now() }
1277
+ await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
1278
+
1279
+ // Restore to base state (discard local changes)
1280
+ const meta = readRemoteMeta(dir)
1281
+ const cfg = loadConfig()
1282
+ const server = getServer(opts, cfg) || meta.server
1283
+ const token = getToken(opts, cfg) || meta.token
1284
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
1285
+
1286
+ if (opts.json === 'true') {
1287
+ print({ stashId, message }, true)
1288
+ } else {
1289
+ process.stdout.write(color(`Saved stash: ${message}\n`, 'green'))
1290
+ }
1291
+ return
1292
+ }
1293
+
1294
+ if (sub === 'pop' || sub === 'apply') {
1295
+ const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1296
+ try {
1297
+ const files = await fs.promises.readdir(stashDir)
1298
+ const stashes = []
1299
+ for (const f of files) {
1300
+ if (f.endsWith('.json')) {
1301
+ const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
1302
+ const stash = JSON.parse(content)
1303
+ stashes.push({ id: f.replace('.json', ''), ...stash })
1304
+ }
1305
+ }
1306
+ stashes.sort((a, b) => b.createdAt - a.createdAt)
1307
+ if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1308
+ const stash = stashes[stashIndex]
1309
+
1310
+ // Apply stash files
1311
+ for (const [p, content] of Object.entries(stash.files || {})) {
1312
+ const fp = path.join(dir, p)
1313
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1314
+ await fs.promises.writeFile(fp, content, 'utf8')
1315
+ }
1316
+
1317
+ if (sub === 'pop') {
1318
+ // Remove stash
1319
+ await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1320
+ }
1321
+
1322
+ if (opts.json === 'true') {
1323
+ print({ applied: stash.id, message: stash.message }, true)
1324
+ } else {
1325
+ process.stdout.write(color(`${sub === 'pop' ? 'Popped' : 'Applied'} stash: ${stash.message || stash.id}\n`, 'green'))
1326
+ }
1327
+ } catch (err) {
1328
+ throw new Error(`Stash operation failed: ${err.message}`)
1329
+ }
1330
+ return
1331
+ }
1332
+
1333
+ if (sub === 'drop') {
1334
+ const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
1335
+ try {
1336
+ const files = await fs.promises.readdir(stashDir)
1337
+ const stashes = []
1338
+ for (const f of files) {
1339
+ if (f.endsWith('.json')) {
1340
+ const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
1341
+ const stash = JSON.parse(content)
1342
+ stashes.push({ id: f.replace('.json', ''), ...stash })
1343
+ }
1344
+ }
1345
+ stashes.sort((a, b) => b.createdAt - a.createdAt)
1346
+ if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
1347
+ const stash = stashes[stashIndex]
1348
+ await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
1349
+
1350
+ if (opts.json === 'true') {
1351
+ print({ dropped: stash.id }, true)
1352
+ } else {
1353
+ process.stdout.write(color(`Dropped stash: ${stash.message || stash.id}\n`, 'green'))
1354
+ }
1355
+ } catch (err) {
1356
+ throw new Error(`Drop stash failed: ${err.message}`)
1357
+ }
1358
+ return
1359
+ }
1360
+
1361
+ if (sub === 'clear') {
1362
+ try {
1363
+ const files = await fs.promises.readdir(stashDir)
1364
+ for (const f of files) {
1365
+ if (f.endsWith('.json')) {
1366
+ await fs.promises.unlink(path.join(stashDir, f))
1367
+ }
1368
+ }
1369
+ if (opts.json !== 'true') process.stdout.write(color('Cleared all stashes\n', 'green'))
1370
+ else print({ cleared: true }, true)
1371
+ } catch {
1372
+ if (opts.json !== 'true') process.stdout.write('No stashes to clear.\n')
1373
+ else print({ cleared: true }, true)
1374
+ }
1375
+ return
1376
+ }
1377
+
1378
+ throw new Error('Unknown stash subcommand')
1379
+ }
1380
+
1381
+ async function cmdBranch(sub, opts) {
1382
+ const dir = path.resolve(opts.dir || '.')
1383
+ const meta = opts.repo ? { repoId: opts.repo, branch: '', server: getServer(opts, loadConfig()), token: getToken(opts, loadConfig()) } : readRemoteMeta(dir)
1384
+ const cfg = loadConfig()
1385
+ const server = getServer(opts, cfg) || meta.server
1386
+ const token = getToken(opts, cfg) || meta.token
1387
+
1388
+ if (sub === 'list' || sub === undefined) {
1389
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1390
+ const data = await request('GET', url, null, token)
1391
+ if (opts.json === 'true') {
1392
+ print(data, true)
1393
+ } else {
1394
+ let current = meta.branch
1395
+ try {
1396
+ const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
1397
+ const m = head.match(/refs\/heads\/(.+)/)
1398
+ if (m) current = m[1]
1399
+ } catch {}
1400
+ process.stdout.write(color('Branches:\n', 'bold'))
1401
+ const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1402
+ for (const b of list) {
1403
+ const isCur = b.name === current
1404
+ const mark = isCur ? color('*', 'green') : ' '
1405
+ const nameStr = isCur ? color(b.name, 'green') : color(b.name, 'cyan')
1406
+ const idStr = color((b.commitId || '').slice(0, 7), 'dim')
1407
+ process.stdout.write(` ${mark} ${nameStr} ${idStr}\n`)
1408
+ }
1409
+ }
1410
+ return
1411
+ }
1412
+ if (sub === 'create') {
1413
+ const name = opts.name
1414
+ if (!name) throw new Error('Missing --name')
1415
+ const baseBranch = opts.base || meta.branch
1416
+ const body = { name, baseBranch }
1417
+ const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
1418
+ const data = await request('POST', url, body, token)
1419
+ print(data, opts.json === 'true')
1420
+ return
1421
+ }
1422
+ if (sub === 'delete') {
1423
+ const name = opts.name
1424
+ if (!name) throw new Error('Missing --name')
1425
+ const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1426
+ u.searchParams.set('name', name)
1427
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
1428
+ const res = await fetch(u.toString(), { method: 'DELETE', headers })
1429
+ if (!res.ok) {
1430
+ const body = await res.text().catch(() => '')
1431
+ throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
1432
+ }
1433
+ const data = await res.json()
1434
+ print(data, opts.json === 'true')
1435
+ return
1436
+ }
1437
+
1438
+ throw new Error('Unknown branch subcommand')
1439
+ }
1440
+
1441
+ async function cmdSwitch(opts) {
1442
+ const dir = path.resolve(opts.dir || '.')
1443
+ const meta = readRemoteMeta(dir)
1444
+ const branch = opts.branch
1445
+ if (!branch) throw new Error('Missing --branch')
1446
+ const cfg = loadConfig()
1447
+ const server = getServer(opts, cfg) || meta.server
1448
+ const token = getToken(opts, cfg) || meta.token
1449
+ await pullToDir(meta.repoId, branch, dir, server, token)
1450
+ print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1451
+ }
1452
+
1453
+ async function checkoutCommit(meta, dir, commitId, server, token) {
1454
+ const snap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1455
+ const files = snap.files || {}
1456
+ const root = path.resolve(dir)
1457
+ for (const [p, content] of Object.entries(files)) {
1458
+ const fullPath = path.join(root, p)
1459
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
1460
+ await fs.promises.writeFile(fullPath, content, 'utf8')
1461
+ }
1462
+ const keep = new Set(Object.keys(files))
1463
+ const localMap = await collectLocal(root)
1464
+ for (const rel of Object.keys(localMap)) {
1465
+ if (!keep.has(rel)) {
1466
+ const fp = path.join(root, rel)
1467
+ try { await fs.promises.unlink(fp) } catch {}
1468
+ }
1469
+ }
1470
+ const metaDir = path.join(root, '.vcs-next')
1471
+ await fs.promises.mkdir(metaDir, { recursive: true })
1472
+ const remoteMeta = { repoId: meta.repoId, branch: meta.branch, commitId: snap.commitId || commitId, server, token: token || '' }
1473
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
1474
+ const gitDir = path.join(root, '.git')
1475
+ await fs.promises.mkdir(gitDir, { recursive: true })
1476
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), commitId + '\n', 'utf8')
1477
+ const localState = { baseCommitId: snap.commitId || commitId, baseFiles: files, pendingCommit: null }
1478
+ await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localState, null, 2))
1479
+ print({ repoId: meta.repoId, commit: commitId, dir }, token ? (false) : (opts && opts.json === 'true'))
1480
+ }
1481
+
1482
+ async function cmdCheckout(opts) {
1483
+ const dir = path.resolve(opts.dir || '.')
1484
+ const meta = readRemoteMeta(dir)
1485
+ const cfg = loadConfig()
1486
+ const server = getServer(opts, cfg) || meta.server
1487
+ const token = getToken(opts, cfg) || meta.token
1488
+ const branch = opts.branch
1489
+ const commitId = opts.commit
1490
+ if (commitId) {
1491
+ await checkoutCommit(meta, dir, commitId, server, token)
1492
+ return
1493
+ }
1494
+ if (!branch) throw new Error('Missing --branch or --commit')
1495
+ await pullToDir(meta.repoId, branch, dir, server, token)
1496
+ print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1497
+ }
1498
+
1499
+ async function cmdTag(sub, opts) {
1500
+ const dir = path.resolve(opts.dir || '.')
1501
+ const meta = readRemoteMeta(dir)
1502
+ const cfg = loadConfig()
1503
+ const server = getServer(opts, cfg) || meta.server
1504
+ const token = getToken(opts, cfg) || meta.token
1505
+ if (sub === 'list') {
1506
+ const url = new URL(`/api/repositories/${meta.repoId}/tags`, server).toString()
1507
+ const data = await request('GET', url, null, token)
1508
+ print(data, opts.json === 'true')
1509
+ return
1510
+ }
1511
+ if (sub === 'create') {
1512
+ const name = opts.name
1513
+ if (!name) throw new Error('Missing --name')
1514
+ const body = { name, branchName: opts.branch || meta.branch }
1515
+ const url = new URL(`/api/repositories/${meta.repoId}/tags`, server).toString()
1516
+ const data = await request('POST', url, body, token)
1517
+ print(data, opts.json === 'true')
1518
+ return
1519
+ }
1520
+ if (sub === 'delete') {
1521
+ const name = opts.name
1522
+ if (!name) throw new Error('Missing --name')
1523
+ const u = new URL(`/api/repositories/${meta.repoId}/tags`, server)
1524
+ u.searchParams.set('name', name)
1525
+ const headers = token ? { Authorization: `Bearer ${token}` } : {}
1526
+ const res = await fetch(u.toString(), { method: 'DELETE', headers })
1527
+ if (!res.ok) throw new Error('delete failed')
1528
+ const data = await res.json()
1529
+ print(data, opts.json === 'true')
1530
+ return
1531
+ }
1532
+ throw new Error('Unknown tag subcommand')
1533
+ }
1534
+
1535
+ async function cmdPr(sub, opts) {
1536
+ const dir = path.resolve(opts.dir || '.')
1537
+ const meta = readRemoteMeta(dir)
1538
+ const cfg = loadConfig()
1539
+ const server = getServer(opts, cfg) || meta.server
1540
+ const token = getToken(opts, cfg) || meta.token
1541
+ if (sub === 'list') {
1542
+ const url = new URL(`/api/repositories/${meta.repoId}/pull-requests`, server).toString()
1543
+ const data = await request('GET', url, null, token)
1544
+ print(data, opts.json === 'true')
1545
+ return
1546
+ }
1547
+ if (sub === 'create') {
1548
+ const title = opts.title
1549
+ const sourceBranch = opts.source || meta.branch
1550
+ const targetBranch = opts.target || meta.branch
1551
+ if (!title) throw new Error('Missing --title')
1552
+ const body = { title, description: opts.description || '', sourceBranch, targetBranch }
1553
+ const url = new URL(`/api/repositories/${meta.repoId}/pull-requests`, server).toString()
1554
+ const data = await request('POST', url, body, token)
1555
+ print(data, opts.json === 'true')
1556
+ return
1557
+ }
1558
+ if (sub === 'merge') {
1559
+ const prId = opts.id
1560
+ if (!prId) throw new Error('Missing --id')
1561
+ const url = new URL(`/api/repositories/${meta.repoId}/pull-requests/${prId}/merge`, server).toString()
1562
+ const data = await request('POST', url, {}, token)
1563
+ print(data, opts.json === 'true')
1564
+ return
1565
+ }
1566
+ throw new Error('Unknown pr subcommand')
1567
+ }
1568
+
1569
+ async function cmdCurrent(opts) {
1570
+ const dir = path.resolve(opts.dir || '.')
1571
+ const meta = readRemoteMeta(dir)
1572
+ print({ repoId: meta.repoId, branch: meta.branch, server: meta.server, dir }, opts.json === 'true')
1573
+ }
1574
+
1575
+ async function cmdHead(opts) {
1576
+ const dir = path.resolve(opts.dir || '.')
1577
+ const meta = readRemoteMeta(dir)
1578
+ let commitId = ''
1579
+ try {
1580
+ const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
1581
+ const m = head.match(/refs\/heads\/(.+)/)
1582
+ if (m) {
1583
+ const ref = path.join(dir, '.git', 'refs', 'heads', m[1])
1584
+ commitId = (await fs.promises.readFile(ref, 'utf8')).trim()
1585
+ } else {
1586
+ commitId = head.trim()
1587
+ }
1588
+ } catch {}
1589
+ if (!commitId) {
1590
+ const cfg = loadConfig()
1591
+ const server = getServer(opts, cfg) || meta.server
1592
+ const token = getToken(opts, cfg) || meta.token
1593
+ const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
1594
+ const found = (info.branches || []).find(b => b.name === meta.branch)
1595
+ commitId = found ? (found.commitId || '') : ''
1596
+ }
1597
+ const out = { branch: meta.branch, commitId }
1598
+ print(opts.json === 'true' ? out : (commitId || ''), opts.json === 'true')
1599
+ }
1600
+
1601
+ async function cmdShow(opts) {
1602
+ const dir = path.resolve(opts.dir || '.')
1603
+ const meta = readRemoteMeta(dir)
1604
+ const cfg = loadConfig()
1605
+ const server = getServer(opts, cfg) || meta.server
1606
+ const token = getToken(opts, cfg) || meta.token
1607
+ const commitId = opts.commit
1608
+ if (!commitId) throw new Error('Missing --commit')
1609
+
1610
+ const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1611
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1612
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1613
+ const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1614
+
1615
+ if (opts.json === 'true') {
1616
+ print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
1617
+ } else {
1618
+ process.stdout.write(color(`commit ${commitId}\n`, 'yellow'))
1619
+ if (commit.author) {
1620
+ process.stdout.write(`Author: ${commit.author.name || ''} <${commit.author.email || ''}>\n`)
1621
+ }
1622
+ if (commit.committer) {
1623
+ process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
1624
+ }
1625
+ process.stdout.write(`\n${commit.message || ''}\n\n`)
1626
+
1627
+ // Show file changes
1628
+ const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
1629
+ const changed = []
1630
+ for (const p of allPaths) {
1631
+ const old = parentSnap.files[p]
1632
+ const new_ = commitSnap.files[p]
1633
+ if (old !== new_) {
1634
+ if (old === undefined) changed.push({ path: p, type: 'added' })
1635
+ else if (new_ === undefined) changed.push({ path: p, type: 'deleted' })
1636
+ else changed.push({ path: p, type: 'modified' })
1637
+ }
1638
+ }
1639
+ if (changed.length > 0) {
1640
+ process.stdout.write(color(`\nFiles changed (${changed.length}):\n`, 'bold'))
1641
+ for (const ch of changed) {
1642
+ const colorCode = ch.type === 'added' ? 'green' : ch.type === 'deleted' ? 'red' : 'yellow'
1643
+ process.stdout.write(color(`${ch.type === 'added' ? '+' : ch.type === 'deleted' ? '-' : 'M'} ${ch.path}\n`, colorCode))
1644
+ }
1645
+ }
1646
+ }
1647
+ }
1648
+
1649
+ async function cmdRevert(opts) {
1650
+ const dir = path.resolve(opts.dir || '.')
1651
+ const meta = readRemoteMeta(dir)
1652
+ const cfg = loadConfig()
1653
+ const server = getServer(opts, cfg) || meta.server
1654
+ const token = getToken(opts, cfg) || meta.token
1655
+ const commitId = opts.commit
1656
+ if (!commitId) throw new Error('Missing --commit')
1657
+
1658
+ // Get the commit to revert
1659
+ const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
1660
+ const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1661
+ const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
1662
+ const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
1663
+
1664
+ // Get current state
1665
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
1666
+ const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1667
+ const localAfterPull = await collectLocal(dir)
1668
+
1669
+ // Revert: apply inverse of commit changes
1670
+ const conflicts = []
1671
+ const changes = []
1672
+ const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
1673
+
1674
+ for (const p of allPaths) {
1675
+ const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
1676
+ const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
1677
+ const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
1678
+ const local = localAfterPull[p]?.content || null
1679
+
1680
+ // Revert: we want to go from commit state back to parent state
1681
+ // But check if current branch has changed this file
1682
+ const commitChanged = base !== commitContent
1683
+ const currentChanged = current !== base
1684
+
1685
+ if (commitChanged) {
1686
+ // Commit changed this file - we want to revert it
1687
+ if (currentChanged && current !== base) {
1688
+ // Conflict: current branch also changed this file
1689
+ const line = firstDiffLine(String(local || ''), String(base || ''))
1690
+ conflicts.push({ path: p, line, current: local, incoming: base })
1691
+ } else {
1692
+ // No conflict: revert to base
1693
+ if (base !== null) {
1694
+ if (local !== base) {
1695
+ changes.push({ type: 'write', path: p, content: base })
1696
+ }
1697
+ } else {
1698
+ // Base was null, commit added file - delete it
1699
+ if (local !== null) {
1700
+ changes.push({ type: 'delete', path: p })
1701
+ }
1702
+ }
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ if (conflicts.length > 0) {
1708
+ // Write conflict markers to files
1709
+ for (const conflict of conflicts) {
1710
+ const fp = path.join(dir, conflict.path)
1711
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1712
+ const conflictContent = writeConflictMarkers(
1713
+ conflict.current,
1714
+ conflict.incoming,
1715
+ meta.branch,
1716
+ `revert-${commitId.slice(0,7)}`
1717
+ )
1718
+ await fs.promises.writeFile(fp, conflictContent, 'utf8')
1719
+ }
1720
+
1721
+ // Store conflict state in metadata
1722
+ const metaDir = path.join(dir, '.vcs-next')
1723
+ await fs.promises.mkdir(metaDir, { recursive: true })
1724
+ const localPath = path.join(metaDir, 'local.json')
1725
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
1726
+ try {
1727
+ const s = await fs.promises.readFile(localPath, 'utf8')
1728
+ localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
1729
+ } catch {}
1730
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1731
+
1732
+ if (opts.json === 'true') {
1733
+ print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
1734
+ } else {
1735
+ process.stdout.write(color(`\nRevert conflict detected!\n`, 'red'))
1736
+ process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
1737
+ for (const c of conflicts) {
1738
+ process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
1739
+ }
1740
+ process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
1741
+ process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
1742
+ process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
1743
+ process.stdout.write(color(` =======\n`, 'dim'))
1744
+ process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
1745
+ }
1746
+ return
1747
+ }
1748
+
1749
+ // Apply changes
1750
+ for (const ch of changes) {
1751
+ const fp = path.join(dir, ch.path)
1752
+ if (ch.type === 'delete') {
1753
+ try { await fs.promises.unlink(fp) } catch {}
1754
+ } else if (ch.type === 'write') {
1755
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1756
+ await fs.promises.writeFile(fp, ch.content, 'utf8')
1757
+ }
1758
+ }
1759
+
1760
+ // Update metadata
1761
+ const metaDir = path.join(dir, '.vcs-next')
1762
+ const localPath = path.join(metaDir, 'local.json')
1763
+ await fs.promises.mkdir(metaDir, { recursive: true })
1764
+ let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
1765
+ try {
1766
+ const s = await fs.promises.readFile(localPath, 'utf8')
1767
+ localMeta = JSON.parse(s)
1768
+ } catch {}
1769
+
1770
+ const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
1771
+ const finalLocal = await collectLocal(dir)
1772
+ const finalFiles = {}
1773
+ for (const [p, v] of Object.entries(finalLocal)) {
1774
+ finalFiles[p] = v.content
1775
+ }
1776
+
1777
+ localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
1778
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1779
+
1780
+ if (opts.json === 'true') {
1781
+ print({ reverted: commitId, changes: changes.length }, true)
1782
+ } else {
1783
+ process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
1784
+ }
1785
+
1786
+ if (opts.noPush !== 'true') {
1787
+ await cmdPush({ dir, json: opts.json, message: revertMsg })
1788
+ }
1789
+ }
1790
+
1791
+ async function cmdReset(opts) {
1792
+ const dir = path.resolve(opts.dir || '.')
1793
+ const meta = readRemoteMeta(dir)
1794
+ const cfg = loadConfig()
1795
+ const server = getServer(opts, cfg) || meta.server
1796
+ const token = getToken(opts, cfg) || meta.token
1797
+
1798
+ const commitId = opts.commit || 'HEAD'
1799
+ const mode = opts.mode || 'mixed' // soft, mixed, hard
1800
+ const filePath = opts.path
1801
+
1802
+ if (filePath) {
1803
+ // Reset specific file (unstage)
1804
+ const metaDir = path.join(dir, '.vcs-next')
1805
+ const localPath = path.join(metaDir, 'local.json')
1806
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1807
+ try {
1808
+ const s = await fs.promises.readFile(localPath, 'utf8')
1809
+ localMeta = JSON.parse(s)
1810
+ } catch {}
1811
+
1812
+ // Restore file from base
1813
+ const baseContent = localMeta.baseFiles[filePath]
1814
+ if (baseContent !== undefined) {
1815
+ const fp = path.join(dir, filePath)
1816
+ await fs.promises.mkdir(path.dirname(fp), { recursive: true })
1817
+ await fs.promises.writeFile(fp, String(baseContent), 'utf8')
1818
+ }
1819
+
1820
+ if (opts.json === 'true') {
1821
+ print({ reset: filePath }, true)
1822
+ } else {
1823
+ process.stdout.write(color(`Reset ${filePath}\n`, 'green'))
1824
+ }
1825
+ return
1826
+ }
1827
+
1828
+ // Reset to commit
1829
+ let targetSnap
1830
+ if (commitId === 'HEAD') {
1831
+ targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
1832
+ } else {
1833
+ targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
1834
+ }
1835
+
1836
+ if (mode === 'hard') {
1837
+ // Hard reset: discard all changes, reset to commit
1838
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
1839
+ // Update to target commit if different
1840
+ if (commitId !== 'HEAD') {
1841
+ const files = targetSnap.files || {}
1842
+ const root = path.resolve(dir)
1843
+ for (const [p, content] of Object.entries(files)) {
1844
+ const fullPath = path.join(root, p)
1845
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
1846
+ await fs.promises.writeFile(fullPath, String(content), 'utf8')
1847
+ }
1848
+ }
1849
+ } else if (mode === 'soft') {
1850
+ // Soft reset: keep changes staged
1851
+ // Just update base reference
1852
+ } else {
1853
+ // Mixed reset: keep changes unstaged (default)
1854
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
1855
+ }
1856
+
1857
+ // Update metadata
1858
+ const metaDir = path.join(dir, '.vcs-next')
1859
+ const localPath = path.join(metaDir, 'local.json')
1860
+ await fs.promises.mkdir(metaDir, { recursive: true })
1861
+ const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
1862
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1863
+
1864
+ if (opts.json === 'true') {
1865
+ print({ reset: commitId, mode }, true)
1866
+ } else {
1867
+ process.stdout.write(color(`Reset to ${commitId} (${mode})\n`, 'green'))
1868
+ }
1869
+ }
1870
+
1871
+ async function cmdMv(opts) {
1872
+ const dir = path.resolve(opts.dir || '.')
1873
+ const from = opts.from
1874
+ const to = opts.to
1875
+ if (!from || !to) throw new Error('Missing --from and --to')
1876
+
1877
+ const fromPath = path.join(dir, from)
1878
+ const toPath = path.join(dir, to)
1879
+
1880
+ // Check if source exists
1881
+ const stat = await fs.promises.stat(fromPath).catch(() => null)
1882
+ if (!stat) throw new Error(`Source file not found: ${from}`)
1883
+
1884
+ // Move file
1885
+ await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
1886
+ await fs.promises.rename(fromPath, toPath)
1887
+
1888
+ // If it's a tracked file, we need to update it in the next commit
1889
+ // For now, just move it - the next commit will track the change
1890
+ if (opts.json === 'true') {
1891
+ print({ moved: { from, to } }, true)
1892
+ } else {
1893
+ process.stdout.write(color(`Moved ${from} → ${to}\n`, 'green'))
1894
+ }
1895
+ }
1896
+
1897
+ async function cmdAdd(opts) {
1898
+ const dir = path.resolve(opts.dir || '.')
1899
+
1900
+ // Handle --all flag
1901
+ if (opts.all === 'true' || opts.all === true) {
1902
+ const meta = readRemoteMeta(dir)
1903
+ const cfg = loadConfig()
1904
+ const server = getServer(opts, cfg) || meta.server
1905
+ const token = getToken(opts, cfg) || meta.token
1906
+ const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
1907
+ const local = await collectLocal(dir)
1908
+
1909
+ // Stage all changes (add new, modified, delete removed)
1910
+ const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
1911
+ let stagedCount = 0
1912
+ for (const p of allPaths) {
1913
+ const remoteId = remote.map[p]
1914
+ const localId = local[p]?.id
1915
+ if (remoteId !== localId) {
1916
+ stagedCount++
1917
+ }
1918
+ }
1919
+
1920
+ if (opts.json === 'true') {
1921
+ print({ staged: stagedCount }, true)
1922
+ } else {
1923
+ process.stdout.write(color(`Staged ${stagedCount} changes\n`, 'green'))
1924
+ }
1925
+ return
1926
+ }
1927
+
1928
+ const p = opts.path
1929
+ if (!p) throw new Error('Missing --path')
1930
+ const abs = path.join(dir, p)
1931
+ const exists = await fs.promises.stat(abs).catch(() => null)
1932
+ if (opts.from) {
1933
+ const src = path.resolve(opts.from)
1934
+ const srcStat = await fs.promises.stat(src)
1935
+ if (srcStat.isDirectory()) {
1936
+ await copyDir(src, abs)
1937
+ if (opts['commit-message']) {
1938
+ await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
1939
+ return
1940
+ }
1941
+ print({ addedDir: p, dir }, opts.json === 'true')
1942
+ return
1943
+ } else {
1944
+ const targetStat = exists
1945
+ const target = targetStat && targetStat.isDirectory() ? path.join(abs, path.basename(src)) : abs
1946
+ await fs.promises.mkdir(path.dirname(target), { recursive: true })
1947
+ await fs.promises.copyFile(src, target)
1948
+ if (opts['commit-message']) {
1949
+ await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
1950
+ return
1951
+ }
1952
+ print({ added: path.relative(dir, target), dir }, opts.json === 'true')
1953
+ return
1954
+ }
1955
+ }
1956
+ if (p === '.' || (exists && exists.isDirectory())) {
1957
+ if (!exists) await fs.promises.mkdir(abs, { recursive: true })
1958
+ if (opts['commit-message']) {
1959
+ await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
1960
+ return
1961
+ }
1962
+ print({ addedDir: p, dir }, opts.json === 'true')
1963
+ return
1964
+ }
1965
+ await fs.promises.mkdir(path.dirname(abs), { recursive: true })
1966
+ const content = opts.content || ''
1967
+ await fs.promises.writeFile(abs, content, 'utf8')
1968
+ if (opts['commit-message']) {
1969
+ await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
1970
+ return
1971
+ }
1972
+ print({ added: p, dir }, opts.json === 'true')
1973
+ }
1974
+
1975
+ function help() {
1976
+ const h = [
1977
+ 'Usage: resulgit <group> <command> [options]',
1978
+ '',
1979
+ 'Groups:',
1980
+ ' auth set-token --token <token>',
1981
+ ' auth set-server --server <url>',
1982
+ ' auth login --email <email> --password <password> [--server <url>]',
1983
+ ' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
1984
+ ' repo list [--json]',
1985
+ ' 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>]',
1990
+ ' switch --branch <name> [--dir <path>]',
1991
+ ' current [--dir <path>] (show active repo/branch)',
1992
+ ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
1993
+ ' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
1994
+ ' status [--dir <path>] [--json]',
1995
+ ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
1996
+ ' commit --message <text> [--dir <path>] [--json]',
1997
+ ' push [--dir <path>] [--json]',
1998
+ ' head [--dir <path>] [--json]',
1999
+ ' rm --path <file> [--dir <path>] [--json]',
2000
+ ' 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>]',
2020
+ '',
2021
+ 'Global options:',
2022
+ ' --server <url> Override default server',
2023
+ ' --token <tok> Override stored token',
2024
+ ' --json JSON output'
2025
+ ].join('\n')
2026
+ process.stdout.write(h + '\n')
2027
+ }
2028
+
2029
+ async function main() {
2030
+ const { cmd, opts } = parseArgs(process.argv)
2031
+ const cfg = loadConfig()
2032
+ if (cmd.length === 0 || (cmd.length === 1 && (cmd[0] === 'help' || cmd[0] === '--help'))) {
2033
+ help()
2034
+ return
2035
+ }
2036
+ if (cmd[0] === 'auth') {
2037
+ await cmdAuth(cmd[1], opts)
2038
+ return
2039
+ }
2040
+ if (cmd[0] === 'repo') {
2041
+ await cmdRepo(cmd[1], opts, cfg)
2042
+ return
2043
+ }
2044
+ if (cmd[0] === 'clone') {
2045
+ await cmdClone(opts, cfg)
2046
+ return
2047
+ }
2048
+ if (cmd[0] === 'status') {
2049
+ await cmdStatus(opts)
2050
+ return
2051
+ }
2052
+ if (cmd[0] === 'diff') {
2053
+ await cmdDiff(opts)
2054
+ return
2055
+ }
2056
+ if (cmd[0] === 'commit') {
2057
+ await cmdCommit(opts)
2058
+ return
2059
+ }
2060
+ if (cmd[0] === 'push') {
2061
+ await cmdPush(opts)
2062
+ return
2063
+ }
2064
+ if (cmd[0] === 'rm') {
2065
+ await cmdRm(opts)
2066
+ return
2067
+ }
2068
+ if (cmd[0] === 'pull') {
2069
+ await cmdPull(opts)
2070
+ return
2071
+ }
2072
+ if (cmd[0] === 'branch') {
2073
+ await cmdBranch(cmd[1], opts)
2074
+ return
2075
+ }
2076
+ if (cmd[0] === 'switch') {
2077
+ await cmdSwitch(opts)
2078
+ return
2079
+ }
2080
+ if (cmd[0] === 'tag') {
2081
+ await cmdTag(cmd[1], opts)
2082
+ return
2083
+ }
2084
+ if (cmd[0] === 'pr') {
2085
+ await cmdPr(cmd[1], opts)
2086
+ return
2087
+ }
2088
+ if (cmd[0] === 'merge') {
2089
+ await cmdMerge(opts)
2090
+ return
2091
+ }
2092
+ if (cmd[0] === 'stash') {
2093
+ await cmdStash(cmd[1], opts)
2094
+ return
2095
+ }
2096
+ if (cmd[0] === 'restore') {
2097
+ await cmdRestore(opts)
2098
+ return
2099
+ }
2100
+ if (cmd[0] === 'revert') {
2101
+ await cmdRevert(opts)
2102
+ return
2103
+ }
2104
+ if (cmd[0] === 'reset') {
2105
+ await cmdReset(opts)
2106
+ return
2107
+ }
2108
+ if (cmd[0] === 'show') {
2109
+ await cmdShow(opts)
2110
+ return
2111
+ }
2112
+ if (cmd[0] === 'mv') {
2113
+ await cmdMv(opts)
2114
+ return
2115
+ }
2116
+ if (cmd[0] === 'cherry-pick') {
2117
+ await cmdCherryPick(opts)
2118
+ return
2119
+ }
2120
+ if (cmd[0] === 'checkout') {
2121
+ if (!opts.branch && !opts.commit && cmd[1]) opts.branch = cmd[1]
2122
+ await cmdCheckout(opts)
2123
+ return
2124
+ }
2125
+ if (cmd[0] === 'head') {
2126
+ await cmdHead(opts)
2127
+ return
2128
+ }
2129
+ if (cmd[0] === 'workspace') {
2130
+ if (cmd[1] === 'set-root') {
2131
+ const p = opts.path
2132
+ if (!p) throw new Error('Missing --path')
2133
+ const cfg2 = saveConfig({ workspaceRoot: path.resolve(p) })
2134
+ print({ workspaceRoot: cfg2.workspaceRoot }, opts.json === 'true')
2135
+ return
2136
+ }
2137
+ throw new Error('Unknown workspace subcommand')
2138
+ }
2139
+ if (cmd[0] === 'current' || cmd[0] === 'which') {
2140
+ await cmdCurrent(opts)
2141
+ return
2142
+ }
2143
+ if (cmd[0] === 'add') {
2144
+ if (!opts.path && cmd[1]) opts.path = cmd[1]
2145
+ await cmdAdd(opts)
2146
+ return
2147
+ }
2148
+ throw new Error('Unknown command')
2149
+ }
2150
+
2151
+ function enableMouse() {
2152
+ process.stdout.write('\x1b[?1000h')
2153
+ process.stdout.write('\x1b[?1006h')
2154
+ }
2155
+
2156
+ function disableMouse() {
2157
+ process.stdout.write('\x1b[?1000l')
2158
+ process.stdout.write('\x1b[?1006l')
2159
+ }
2160
+
2161
+ async function tuiSelectRepo(repos, cfg, opts) {
2162
+ const wsRoot = (opts.workspace ? process.cwd() : (cfg.workspaceRoot || path.join(os.homedir(), 'resulgit-workspace')))
2163
+ await fs.promises.mkdir(wsRoot, { recursive: true })
2164
+ const items = repos.map(r => ({ id: String(r.id || r._id || ''), name: r.name || '', defaultBranch: r.defaultBranch || 'main' }))
2165
+ if (items.length === 0) {
2166
+ print('No repositories available', false)
2167
+ return
2168
+ }
2169
+ const lines = process.stdout.rows || 24
2170
+ let idx = 0
2171
+ const render = () => {
2172
+ process.stdout.write('\x1b[2J\x1b[H')
2173
+ process.stdout.write('Select a repository (↑/↓, Enter to open, q to quit)\n')
2174
+ process.stdout.write(`Workspace: ${wsRoot}\n\n`)
2175
+ const max = Math.max(0, lines - 5)
2176
+ const start = Math.max(0, Math.min(idx - Math.floor(max / 2), items.length - max))
2177
+ const end = Math.min(items.length, start + max)
2178
+ for (let i = start; i < end; i++) {
2179
+ const mark = i === idx ? '> ' : ' '
2180
+ process.stdout.write(`${mark}${items[i].name} (${items[i].id})\n`)
2181
+ }
2182
+ process.stdout.write('\nMouse: click an item to select.\n')
2183
+ }
2184
+ const cleanup = () => {
2185
+ disableMouse()
2186
+ process.stdin.setRawMode(false)
2187
+ process.stdin.pause()
2188
+ process.stdout.write('\x1b[?25h')
2189
+ }
2190
+ process.stdout.write('\x1b[?25l')
2191
+ enableMouse()
2192
+ render()
2193
+ process.stdin.setRawMode(true)
2194
+ process.stdin.resume()
2195
+ process.stdin.on('data', async (buf) => {
2196
+ const s = buf.toString('utf8')
2197
+ if (s === '\u0003') { // Ctrl-C
2198
+ cleanup()
2199
+ return
2200
+ }
2201
+ if (s === '\u001b[A') { // Up
2202
+ idx = (idx + items.length - 1) % items.length
2203
+ render()
2204
+ return
2205
+ }
2206
+ if (s === '\u001b[B') { // Down
2207
+ idx = (idx + 1) % items.length
2208
+ render()
2209
+ return
2210
+ }
2211
+ if (s === '\r') { // Enter
2212
+ cleanup()
2213
+ await openRepo(items[idx], wsRoot, cfg, opts)
2214
+ return
2215
+ }
2216
+ if (s === 'q') {
2217
+ cleanup()
2218
+ return
2219
+ }
2220
+ if (s.startsWith('\u001b[')) {
2221
+ const m = s.match(/\u001b\[<(?<btn>\d+);(?<x>\d+);(?<y>\d+)(?<type>[mM])/)
2222
+ if (m && m.groups) {
2223
+ const y = parseInt(m.groups.y, 10)
2224
+ const headerLines = 3
2225
+ const itemIdx = y - headerLines - 1
2226
+ if (itemIdx >= 0 && itemIdx < items.length) {
2227
+ idx = itemIdx
2228
+ render()
2229
+ if (m.groups.type === 'M') { // press
2230
+ cleanup()
2231
+ await openRepo(items[idx], wsRoot, cfg, opts)
2232
+ }
2233
+ }
2234
+ }
2235
+ }
2236
+ })
2237
+ }
2238
+
2239
+ async function openRepo(item, wsRoot, cfg, opts) {
2240
+ const server = getServer(opts, cfg)
2241
+ const token = getToken(opts, cfg)
2242
+ const dest = path.join(wsRoot, item.name || item.id)
2243
+ await pullToDir(item.id, item.defaultBranch || 'main', dest, server, token)
2244
+ print({ opened: item.name, id: item.id, path: dest }, opts.json === 'true')
2245
+ }
2246
+
2247
+ async function copyDir(src, dest) {
2248
+ await fs.promises.mkdir(dest, { recursive: true })
2249
+ const entries = await fs.promises.readdir(src, { withFileTypes: true })
2250
+ for (const e of entries) {
2251
+ if (e.name === '.git' || e.name === '.vcs-next') continue
2252
+ const s = path.join(src, e.name)
2253
+ const d = path.join(dest, e.name)
2254
+ if (e.isDirectory()) {
2255
+ await copyDir(s, d)
2256
+ } else if (e.isFile()) {
2257
+ await fs.promises.copyFile(s, d)
2258
+ }
2259
+ }
2260
+ }
2261
+
2262
+ async function tuiSelectBranch(branches, current) {
2263
+ const items = (branches || []).map((b) => ({ name: b.name || '', commitId: b.commitId || '' })).filter((b) => b.name)
2264
+ if (items.length === 0) throw new Error('No branches available')
2265
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2266
+ const found = items.find((i) => i.name === current)
2267
+ return (found ? found.name : items[0].name)
2268
+ }
2269
+ let idx = Math.max(0, items.findIndex((i) => i.name === current))
2270
+ const render = () => {
2271
+ process.stdout.write('\x1b[2J\x1b[H')
2272
+ process.stdout.write(color('Select target branch for cherry-pick\n\n', 'bold'))
2273
+ for (let i = 0; i < items.length; i++) {
2274
+ const isCur = items[i].name === current
2275
+ const mark = i === idx ? color('> ', 'yellow') : ' '
2276
+ const star = isCur ? color('* ', 'green') : ' '
2277
+ const nameStr = isCur ? color(items[i].name, 'green') : color(items[i].name, 'cyan')
2278
+ const idStr = color((items[i].commitId || '').slice(0, 7), 'dim')
2279
+ process.stdout.write(`${mark}${star}${nameStr} ${idStr}\n`)
2280
+ }
2281
+ process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
2282
+ }
2283
+ const cleanup = (listener) => {
2284
+ try { process.stdin.removeListener('data', listener) } catch {}
2285
+ process.stdin.setRawMode(false)
2286
+ process.stdin.pause()
2287
+ process.stdout.write('\x1b[?25h')
2288
+ }
2289
+ process.stdout.write('\x1b[?25l')
2290
+ render()
2291
+ process.stdin.setRawMode(true)
2292
+ process.stdin.resume()
2293
+ return await new Promise((resolve, reject) => {
2294
+ const onData = (buf) => {
2295
+ const s = buf.toString('utf8')
2296
+ if (s === '\u0003') { cleanup(onData); reject(new Error('Cancelled')); return }
2297
+ if (s === '\u001b[A') { idx = (idx + items.length - 1) % items.length; render(); return }
2298
+ if (s === '\u001b[B') { idx = (idx + 1) % items.length; render(); return }
2299
+ if (s === 'q') { cleanup(onData); reject(new Error('Cancelled')); return }
2300
+ if (s === '\r') { const name = items[idx].name; cleanup(onData); resolve(name); return }
2301
+ }
2302
+ process.stdin.on('data', onData)
2303
+ })
2304
+ }
2305
+
2306
+ main().catch((err) => {
2307
+ process.stderr.write((err && err.message) ? err.message + '\n' : String(err) + '\n')
2308
+ process.exit(1)
2309
+ })