resulgit 1.0.18 → 1.0.19

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/package.json +1 -1
  2. package/resulgit.js +214 -1248
  3. package/README.md +0 -194
package/resulgit.js CHANGED
@@ -3,32 +3,9 @@ const fs = require('fs')
3
3
  const path = require('path')
4
4
  const os = require('os')
5
5
  const crypto = require('crypto')
6
- const ora = require('ora')
7
- const validation = require('./lib/validation')
8
- const errors = require('./lib/errors')
9
- const { parseBlame, formatBlameOutput, formatBlameJson } = require('./lib/blame')
10
- const { generateLogGraph, formatCompactLog, generateCommitStats } = require('./lib/log-viz')
11
- const hooks = require('./lib/hooks')
12
6
  const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
13
7
  function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
14
8
 
15
- function createSpinner(text, jsonMode) {
16
- if (jsonMode === 'true' || !process.stdout.isTTY) return null
17
- return ora(text).start()
18
- }
19
-
20
- function spinnerSuccess(spinner, text) {
21
- if (spinner) spinner.succeed(text)
22
- }
23
-
24
- function spinnerFail(spinner, text) {
25
- if (spinner) spinner.fail(text)
26
- }
27
-
28
- function spinnerUpdate(spinner, text) {
29
- if (spinner) spinner.text = text
30
- }
31
-
32
9
  function parseArgs(argv) {
33
10
  const tokens = argv.slice(2)
34
11
  const cmd = []
@@ -37,22 +14,12 @@ function parseArgs(argv) {
37
14
  const t = tokens[i]
38
15
  if (t.startsWith('--')) {
39
16
  const key = t.slice(2)
40
- const val = tokens[i + 1] && !tokens[i + 1].startsWith('-') ? tokens[++i] : 'true'
17
+ const val = tokens[i + 1] && !tokens[i + 1].startsWith('--') ? tokens[++i] : 'true'
41
18
  opts[key] = val
42
- } else if (t.startsWith('-') && t.length === 2) {
43
- // Short flag like -m, -a
44
- const key = t.slice(1)
45
- const val = tokens[i + 1] && !tokens[i + 1].startsWith('-') ? tokens[++i] : 'true'
46
- opts[key] = val
47
- } else if (cmd.length < 3) {
48
- // Allow up to 3 positional args: e.g. 'branch create DevBranch'
19
+ } else if (cmd.length < 2) {
49
20
  cmd.push(t)
50
21
  }
51
22
  }
52
- // Map short flags to long flags
53
- if (opts.m && !opts.message) { opts.message = opts.m; delete opts.m }
54
- if (opts.a && !opts.all) { opts.all = opts.a; delete opts.a }
55
- if (opts.b && !opts.branch) { opts.branch = opts.b; delete opts.b }
56
23
  return { cmd, opts }
57
24
  }
58
25
 
@@ -102,43 +69,6 @@ async function request(method, url, body, token) {
102
69
  return res.text()
103
70
  }
104
71
 
105
- /**
106
- * Upload files as blobs via multipart form data
107
- * @param {string} server - Server URL
108
- * @param {string} repoId - Repository ID
109
- * @param {Record<string, string>} files - Map of file paths to content
110
- * @param {string} token - Auth token
111
- * @returns {Promise<Record<string, string>>} Map of file paths to blob IDs
112
- */
113
- async function uploadBlobs(server, repoId, files, token) {
114
- const FormData = (await import('node:buffer')).File ? globalThis.FormData : (await import('undici')).FormData
115
- const formData = new FormData()
116
-
117
- for (const [filePath, content] of Object.entries(files)) {
118
- // Create a Blob/File from the content
119
- const blob = new Blob([content], { type: 'text/plain' })
120
- formData.append('files', blob, filePath)
121
- }
122
-
123
- const url = new URL(`/api/repositories/${repoId}/blobs`, server).toString()
124
- const headers = {}
125
- if (token) headers['Authorization'] = `Bearer ${token}`
126
-
127
- const res = await fetch(url, {
128
- method: 'POST',
129
- headers,
130
- body: formData
131
- })
132
-
133
- if (!res.ok) {
134
- const text = await res.text()
135
- throw new Error(`Blob upload failed: ${res.status} ${res.statusText} ${text}`)
136
- }
137
-
138
- const data = await res.json()
139
- return data.blobs || {}
140
- }
141
-
142
72
  function print(obj, json) {
143
73
  if (json) {
144
74
  process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
@@ -176,17 +106,11 @@ async function cmdAuth(sub, opts) {
176
106
  const password = opts.password
177
107
  if (!email || !password) throw new Error('Missing --email and --password')
178
108
  const url = new URL('/api/auth/login', server).toString()
179
- const spinner = createSpinner('Logging in...', opts.json)
180
- try {
181
- const res = await request('POST', url, { email, password }, '')
182
- const token = res.token || ''
183
- if (token) saveConfig({ token })
184
- spinnerSuccess(spinner, `Logged in as ${email}`)
185
- print(res, opts.json === 'true')
186
- } catch (err) {
187
- spinnerFail(spinner, 'Login failed')
188
- throw err
189
- }
109
+ print({ server, email, password, url }, opts.json === 'true')
110
+ const res = await request('POST', url, { email, password }, '')
111
+ const token = res.token || ''
112
+ if (token) saveConfig({ token })
113
+ print(res, opts.json === 'true')
190
114
  return
191
115
  }
192
116
  if (sub === 'register') {
@@ -197,17 +121,10 @@ async function cmdAuth(sub, opts) {
197
121
  const displayName = opts.displayName || username
198
122
  if (!username || !email || !password) throw new Error('Missing --username --email --password')
199
123
  const url = new URL('/api/auth/register', server).toString()
200
- const spinner = createSpinner('Registering account...', opts.json)
201
- try {
202
- const res = await request('POST', url, { username, email, password, displayName }, '')
203
- const token = res.token || ''
204
- if (token) saveConfig({ token })
205
- spinnerSuccess(spinner, `Registered as ${username}`)
206
- print(res, opts.json === 'true')
207
- } catch (err) {
208
- spinnerFail(spinner, 'Registration failed')
209
- throw err
210
- }
124
+ const res = await request('POST', url, { username, email, password, displayName }, '')
125
+ const token = res.token || ''
126
+ if (token) saveConfig({ token })
127
+ print(res, opts.json === 'true')
211
128
  return
212
129
  }
213
130
  throw new Error('Unknown auth subcommand')
@@ -217,17 +134,10 @@ async function cmdRepo(sub, opts, cfg) {
217
134
  const server = getServer(opts, cfg)
218
135
  const token = getToken(opts, cfg)
219
136
  if (sub === 'list') {
220
- const spinner = createSpinner('Fetching repositories...', opts.json)
221
- try {
222
- const url = new URL('/api/repositories', server).toString()
223
- const data = await request('GET', url, null, token)
224
- spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
225
- print(data, opts.json === 'true')
226
- await tuiSelectRepo(data || [], cfg, opts)
227
- } catch (err) {
228
- spinnerFail(spinner, 'Failed to fetch repositories')
229
- throw err
230
- }
137
+ const url = new URL('/api/repositories', server).toString()
138
+ const data = await request('GET', url, null, token)
139
+ print(data, opts.json === 'true')
140
+ await tuiSelectRepo(data || [], cfg, opts)
231
141
  return
232
142
  }
233
143
  if (sub === 'create') {
@@ -238,21 +148,14 @@ async function cmdRepo(sub, opts, cfg) {
238
148
  initializeWithReadme: opts.init === 'true'
239
149
  }
240
150
  if (!body.name) throw new Error('Missing --name')
241
- const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
151
+ const url = new URL('/api/repositories', server).toString()
152
+ const data = await request('POST', url, body, token)
153
+ print(data, opts.json === 'true')
242
154
  try {
243
- const url = new URL('/api/repositories', server).toString()
244
- const data = await request('POST', url, body, token)
245
- spinnerSuccess(spinner, `Repository '${body.name}' created`)
246
- print(data, opts.json === 'true')
247
- try {
248
- const repoId = String(data.id || '')
249
- const branch = String(data.defaultBranch || 'main')
250
- if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
251
- } catch { }
252
- } catch (err) {
253
- spinnerFail(spinner, `Failed to create repository '${body.name}'`)
254
- throw err
255
- }
155
+ const repoId = String(data.id || '')
156
+ const branch = String(data.defaultBranch || 'main')
157
+ if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
158
+ } catch { }
256
159
  return
257
160
  }
258
161
  if (sub === 'log') {
@@ -318,108 +221,94 @@ async function cmdClone(opts, cfg) {
318
221
  const server = getServer(opts, cfg)
319
222
  const token = getToken(opts, cfg)
320
223
  const repo = opts.repo
321
- const branch = opts.branch || 'main'
224
+ const branch = opts.branch
322
225
  let dest = opts.dest
323
226
  if (!repo || !branch) throw new Error('Missing --repo and --branch')
324
- const spinner = createSpinner('Initializing clone...', opts.json)
325
227
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
326
- try {
327
- if (!dest) {
328
- spinnerUpdate(spinner, 'Fetching repository info...')
329
- try {
330
- const infoUrl = new URL(`/api/repositories/${repo}`, server)
331
- const infoRes = await fetch(infoUrl.toString(), { headers })
332
- if (infoRes.ok) {
333
- const info = await infoRes.json()
334
- const name = info.name || String(repo)
335
- dest = name
336
- } else {
337
- dest = String(repo)
338
- }
339
- } catch {
228
+ if (!dest) {
229
+ try {
230
+ const infoUrl = new URL(`/api/repositories/${repo}`, server)
231
+ const infoRes = await fetch(infoUrl.toString(), { headers })
232
+ if (infoRes.ok) {
233
+ const info = await infoRes.json()
234
+ const name = info.name || String(repo)
235
+ dest = name
236
+ } else {
340
237
  dest = String(repo)
341
238
  }
239
+ } catch {
240
+ dest = String(repo)
342
241
  }
343
- dest = path.resolve(dest)
344
- spinnerUpdate(spinner, `Downloading snapshot from branch '${branch}'...`)
345
- const url = new URL(`/api/repositories/${repo}/snapshot`, server)
346
- url.searchParams.set('branch', branch)
347
- const res = await fetch(url.toString(), { headers })
348
- if (!res.ok) {
349
- const text = await res.text()
350
- throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
242
+ }
243
+ dest = path.resolve(dest)
244
+ const url = new URL(`/api/repositories/${repo}/snapshot`, server)
245
+ url.searchParams.set('branch', branch)
246
+ const res = await fetch(url.toString(), { headers })
247
+ if (!res.ok) {
248
+ const text = await res.text()
249
+ throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
250
+ }
251
+ const data = await res.json()
252
+ const files = data.files || {}
253
+ const root = dest
254
+ for (const [p, content] of Object.entries(files)) {
255
+ const fullPath = path.join(root, p)
256
+ await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
257
+ await fs.promises.writeFile(fullPath, content, 'utf8')
258
+ }
259
+ const metaDir = path.join(root, '.vcs-next')
260
+ await fs.promises.mkdir(metaDir, { recursive: true })
261
+ const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
262
+ await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
263
+ const gitDir = path.join(root, '.git')
264
+ const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
265
+ await fs.promises.mkdir(refsHeadsDir, { recursive: true })
266
+ const headContent = `ref: refs/heads/${branch}\n`
267
+ await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
268
+ const commitId = data.commitId || ''
269
+ await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
270
+ const gitConfig = [
271
+ '[core]',
272
+ '\trepositoryformatversion = 0',
273
+ '\tfilemode = true',
274
+ '\tbare = false',
275
+ '\tlogallrefupdates = true',
276
+ '',
277
+ '[vcs-next]',
278
+ `\tserver = ${server}`,
279
+ `\trepoId = ${repo}`,
280
+ `\tbranch = ${branch}`,
281
+ `\ttoken = ${token || ''}`
282
+ ].join('\n')
283
+ await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
284
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
285
+ try {
286
+ const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
287
+ const branchesRes = await fetch(branchesUrl.toString(), { headers })
288
+ if (branchesRes.ok) {
289
+ const branchesData = await branchesRes.json()
290
+ const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
291
+ const allRefs = {}
292
+ for (const b of branches) {
293
+ const name = b.name || ''
294
+ const id = b.commitId || ''
295
+ if (!name) continue
296
+ allRefs[name] = id
297
+ await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
298
+ }
299
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
351
300
  }
352
- const data = await res.json()
353
- const files = data.files || {}
354
- const fileCount = Object.keys(files).length
355
- spinnerUpdate(spinner, `Writing ${fileCount} files to ${dest}...`)
356
- const root = dest
357
- for (const [p, content] of Object.entries(files)) {
358
- const fullPath = path.join(root, p)
359
- await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
360
- await fs.promises.writeFile(fullPath, content, 'utf8')
301
+ } catch { }
302
+ try {
303
+ const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
304
+ commitsUrl.searchParams.set('branch', branch)
305
+ const commitsRes = await fetch(commitsUrl.toString(), { headers })
306
+ if (commitsRes.ok) {
307
+ const commitsList = await commitsRes.json()
308
+ await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
361
309
  }
362
- spinnerUpdate(spinner, 'Setting up repository metadata...')
363
- const metaDir = path.join(root, '.vcs-next')
364
- await fs.promises.mkdir(metaDir, { recursive: true })
365
- const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
366
- await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
367
- const gitDir = path.join(root, '.git')
368
- const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
369
- await fs.promises.mkdir(refsHeadsDir, { recursive: true })
370
- const headContent = `ref: refs/heads/${branch}\\n`
371
- await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
372
- const commitId = data.commitId || ''
373
- await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
374
- const gitConfig = [
375
- '[core]',
376
- '\\trepositoryformatversion = 0',
377
- '\\tfilemode = true',
378
- '\\tbare = false',
379
- '\\tlogallrefupdates = true',
380
- '',
381
- '[vcs-next]',
382
- `\\tserver = ${server}`,
383
- `\\trepoId = ${repo}`,
384
- `\\tbranch = ${branch}`,
385
- `\\ttoken = ${token || ''}`
386
- ].join('\\n')
387
- await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
388
- await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
389
- spinnerUpdate(spinner, 'Fetching branch information...')
390
- try {
391
- const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
392
- const branchesRes = await fetch(branchesUrl.toString(), { headers })
393
- if (branchesRes.ok) {
394
- const branchesData = await branchesRes.json()
395
- const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
396
- const allRefs = {}
397
- for (const b of branches) {
398
- const name = b.name || ''
399
- const id = b.commitId || ''
400
- if (!name) continue
401
- allRefs[name] = id
402
- await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
403
- }
404
- await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
405
- }
406
- } catch { }
407
- spinnerUpdate(spinner, 'Fetching commit history...')
408
- try {
409
- const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
410
- commitsUrl.searchParams.set('branch', branch)
411
- const commitsRes = await fetch(commitsUrl.toString(), { headers })
412
- if (commitsRes.ok) {
413
- const commitsList = await commitsRes.json()
414
- await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
415
- }
416
- } catch { }
417
- spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
418
- print('Clone complete', opts.json === 'true')
419
- } catch (err) {
420
- spinnerFail(spinner, 'Clone failed')
421
- throw err
422
- }
310
+ } catch { }
311
+ print('Clone complete', opts.json === 'true')
423
312
  }
424
313
 
425
314
  function readRemoteMeta(dir) {
@@ -578,73 +467,6 @@ async function cmdDiff(opts) {
578
467
 
579
468
  const filePath = opts.path
580
469
  const commitId = opts.commit
581
- const commit1 = opts.commit1
582
- const commit2 = opts.commit2
583
- const showStat = opts.stat === 'true'
584
-
585
- // Handle diff between two commits: git diff <commit1> <commit2>
586
- if (commit1 && commit2) {
587
- const snap1 = await fetchSnapshotByCommit(server, meta.repoId, commit1, token)
588
- const snap2 = await fetchSnapshotByCommit(server, meta.repoId, commit2, token)
589
- const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(snap1.files), ...Object.keys(snap2.files)]))
590
-
591
- let added = 0, deleted = 0, modified = 0
592
- const stats = []
593
-
594
- for (const p of files) {
595
- const content1 = snap1.files[p] !== undefined ? String(snap1.files[p]) : null
596
- const content2 = snap2.files[p] !== undefined ? String(snap2.files[p]) : null
597
- if (content1 !== content2) {
598
- if (content1 === null) {
599
- added++
600
- if (showStat) stats.push({ path: p, added: content2.split(/\r?\n/).length, deleted: 0 })
601
- } else if (content2 === null) {
602
- deleted++
603
- if (showStat) stats.push({ path: p, added: 0, deleted: content1.split(/\r?\n/).length })
604
- } else {
605
- modified++
606
- const lines1 = content1.split(/\r?\n/)
607
- const lines2 = content2.split(/\r?\n/)
608
- const diff = Math.abs(lines2.length - lines1.length)
609
- if (showStat) stats.push({ path: p, added: lines2.length > lines1.length ? diff : 0, deleted: lines1.length > lines2.length ? diff : 0 })
610
- }
611
-
612
- if (!showStat) {
613
- if (opts.json === 'true') {
614
- print({ path: p, old: content1, new: content2 }, true)
615
- } else {
616
- process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
617
- // Show full diff (same as before)
618
- const oldLines = content1 ? content1.split(/\r?\n/) : []
619
- const newLines = content2 ? content2.split(/\r?\n/) : []
620
- const maxLen = Math.max(oldLines.length, newLines.length)
621
- for (let i = 0; i < maxLen; i++) {
622
- const oldLine = oldLines[i]
623
- const newLine = newLines[i]
624
- if (oldLine !== newLine) {
625
- if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
626
- if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
627
- } else if (oldLine !== undefined) {
628
- process.stdout.write(` ${oldLine}\n`)
629
- }
630
- }
631
- }
632
- }
633
- }
634
- }
635
-
636
- if (showStat) {
637
- if (opts.json === 'true') {
638
- print({ added, deleted, modified, files: stats }, true)
639
- } else {
640
- for (const stat of stats) {
641
- process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
642
- }
643
- process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
644
- }
645
- }
646
- return
647
- }
648
470
 
649
471
  if (commitId) {
650
472
  // Show diff for specific commit
@@ -654,40 +476,6 @@ async function cmdDiff(opts) {
654
476
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
655
477
 
656
478
  const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
657
-
658
- if (showStat) {
659
- let added = 0, deleted = 0, modified = 0
660
- const stats = []
661
- for (const p of files) {
662
- const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
663
- const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
664
- if (oldContent !== newContent) {
665
- if (oldContent === null) {
666
- added++
667
- if (showStat) stats.push({ path: p, added: newContent.split(/\r?\n/).length, deleted: 0 })
668
- } else if (newContent === null) {
669
- deleted++
670
- if (showStat) stats.push({ path: p, added: 0, deleted: oldContent.split(/\r?\n/).length })
671
- } else {
672
- modified++
673
- const oldLines = oldContent.split(/\r?\n/)
674
- const newLines = newContent.split(/\r?\n/)
675
- const diff = Math.abs(newLines.length - oldLines.length)
676
- if (showStat) stats.push({ path: p, added: newLines.length > oldLines.length ? diff : 0, deleted: oldLines.length > newLines.length ? diff : 0 })
677
- }
678
- }
679
- }
680
- if (opts.json === 'true') {
681
- print({ added, deleted, modified, files: stats }, true)
682
- } else {
683
- for (const stat of stats) {
684
- process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
685
- }
686
- process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
687
- }
688
- return
689
- }
690
-
691
479
  for (const p of files) {
692
480
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
693
481
  const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
@@ -773,31 +561,6 @@ async function cmdRm(opts) {
773
561
  const cfg = loadConfig()
774
562
  const server = getServer(opts, cfg) || meta.server
775
563
  const token = getToken(opts, cfg) || meta.token
776
-
777
- // Handle --cached flag (remove from index but keep file)
778
- if (opts.cached === 'true') {
779
- // Mark file for deletion in next commit but don't delete from filesystem
780
- const metaDir = path.join(dir, '.vcs-next')
781
- const localPath = path.join(metaDir, 'local.json')
782
- let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null, removedFiles: [] }
783
- try {
784
- const s = await fs.promises.readFile(localPath, 'utf8')
785
- localMeta = JSON.parse(s)
786
- } catch { }
787
- if (!localMeta.removedFiles) localMeta.removedFiles = []
788
- if (!localMeta.removedFiles.includes(pathArg)) {
789
- localMeta.removedFiles.push(pathArg)
790
- }
791
- await fs.promises.mkdir(metaDir, { recursive: true })
792
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
793
- if (opts.json === 'true') {
794
- print({ removed: pathArg, cached: true }, true)
795
- } else {
796
- process.stdout.write(color(`Removed '${pathArg}' from index (file kept in working directory)\n`, 'green'))
797
- }
798
- return
799
- }
800
-
801
564
  const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
802
565
  u.searchParams.set('branch', meta.branch)
803
566
  u.searchParams.set('path', pathArg)
@@ -816,93 +579,40 @@ async function cmdCommit(opts) {
816
579
  const message = opts.message || ''
817
580
  if (!message) throw new Error('Missing --message')
818
581
 
819
- const spinner = createSpinner('Preparing commit...', opts.json)
820
- try {
821
- // Handle -am flag (add all and commit)
822
- if (opts.all === 'true' || opts.a === 'true') {
823
- spinnerUpdate(spinner, 'Staging all changes...')
824
- await cmdAdd({ dir, all: 'true', json: opts.json })
825
- }
826
-
827
- // Handle --amend flag
828
- if (opts.amend === 'true') {
829
- spinnerUpdate(spinner, 'Amending last commit...')
830
- // Get the last commit and use its message if no new message provided
831
- const meta = readRemoteMeta(dir)
832
- const cfg = loadConfig()
833
- const server = getServer(opts, cfg) || meta.server
834
- const token = getToken(opts, cfg) || meta.token
835
- const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
836
- url.searchParams.set('branch', meta.branch)
837
- url.searchParams.set('limit', '1')
838
- const commits = await request('GET', url.toString(), null, token)
839
- if (Array.isArray(commits) && commits.length > 0 && !message) {
840
- opts.message = commits[0].message || 'Amended commit'
841
- }
842
- }
843
-
844
- // Check for unresolved conflicts
845
- spinnerUpdate(spinner, 'Checking for conflicts...')
846
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
847
- if (unresolvedConflicts.length > 0) {
848
- spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
849
- if (opts.json === 'true') {
850
- print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
851
- } else {
852
- process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
853
- process.stderr.write(color('Conflicts in files:\n', 'yellow'))
854
- for (const p of unresolvedConflicts) {
855
- process.stderr.write(color(` ${p}\n`, 'red'))
856
- }
857
- process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
858
- }
859
- return
860
- }
861
-
862
- spinnerUpdate(spinner, 'Collecting changes...')
863
- const metaDir = path.join(dir, '.vcs-next')
864
- const localPath = path.join(metaDir, 'local.json')
865
- let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
866
- try {
867
- const s = await fs.promises.readFile(localPath, 'utf8')
868
- localMeta = JSON.parse(s)
869
- } catch { }
870
- const local = await collectLocal(dir)
871
- const files = {}
872
- for (const [p, v] of Object.entries(local)) files[p] = v.content
873
-
874
- // Execute pre-commit hook
875
- spinnerUpdate(spinner, 'Running pre-commit hook...')
876
- try {
877
- const hookResult = await hooks.executeHook(dir, 'pre-commit', { message, files: Object.keys(files) })
878
- if (hookResult.executed && hookResult.exitCode !== 0) {
879
- spinnerFail(spinner, 'Pre-commit hook failed')
880
- throw new Error('pre-commit hook failed')
582
+ // Check for unresolved conflicts
583
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
584
+ if (unresolvedConflicts.length > 0) {
585
+ if (opts.json === 'true') {
586
+ print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
587
+ } else {
588
+ process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
589
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
590
+ for (const p of unresolvedConflicts) {
591
+ process.stderr.write(color(` ${p}\n`, 'red'))
881
592
  }
882
- } catch (err) {
883
- if (err.message === 'pre-commit hook failed') throw err
884
- // Hook doesn't exist or other error, continue
885
- }
886
-
887
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
888
- // Clear conflicts if they were resolved
889
- if (localMeta.conflicts) {
890
- delete localMeta.conflicts
593
+ process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
891
594
  }
892
- await fs.promises.mkdir(metaDir, { recursive: true })
893
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
894
-
895
- // Execute post-commit hook
896
- try {
897
- await hooks.executeHook(dir, 'post-commit', { message, files: Object.keys(files) })
898
- } catch { }
595
+ return
596
+ }
899
597
 
900
- spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
901
- print({ pendingCommit: message }, opts.json === 'true')
902
- } catch (err) {
903
- spinnerFail(spinner, 'Commit failed')
904
- throw err
598
+ const metaDir = path.join(dir, '.vcs-next')
599
+ const localPath = path.join(metaDir, 'local.json')
600
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
601
+ try {
602
+ const s = await fs.promises.readFile(localPath, 'utf8')
603
+ localMeta = JSON.parse(s)
604
+ } catch { }
605
+ const local = await collectLocal(dir)
606
+ const files = {}
607
+ for (const [p, v] of Object.entries(local)) files[p] = v.content
608
+ localMeta.pendingCommit = { message, files, createdAt: Date.now() }
609
+ // Clear conflicts if they were resolved
610
+ if (localMeta.conflicts) {
611
+ delete localMeta.conflicts
905
612
  }
613
+ await fs.promises.mkdir(metaDir, { recursive: true })
614
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
615
+ print({ pendingCommit: message }, opts.json === 'true')
906
616
  }
907
617
 
908
618
  async function pullToDir(repo, branch, dir, server, token) {
@@ -966,15 +676,8 @@ async function cmdPull(opts) {
966
676
  const cfg = loadConfig()
967
677
  const server = getServer(opts, cfg) || meta.server
968
678
  const token = getToken(opts, cfg) || meta.token
969
- const spinner = createSpinner(`Pulling from branch '${meta.branch}'...`, opts.json)
970
- try {
971
- await pullToDir(meta.repoId, meta.branch, dir, server, token)
972
- spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
973
- print('Pull complete', opts.json === 'true')
974
- } catch (err) {
975
- spinnerFail(spinner, 'Pull failed')
976
- throw err
977
- }
679
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
680
+ print('Pull complete', opts.json === 'true')
978
681
  }
979
682
 
980
683
  async function cmdFetch(opts) {
@@ -1030,14 +733,6 @@ async function fetchRemoteSnapshot(server, repo, branch, token) {
1030
733
  if (branch) url.searchParams.set('branch', branch)
1031
734
  const res = await fetch(url.toString(), { headers })
1032
735
  if (!res.ok) {
1033
- // For new/empty repos, return empty snapshot instead of throwing
1034
- if (res.status === 404 || res.status === 500) {
1035
- // Check if this is a "new repo" scenario
1036
- const text = await res.text()
1037
- if (text.includes('not found') || text.includes('empty') || text.includes('no commits') || res.status === 500) {
1038
- return { files: {}, commitId: '' }
1039
- }
1040
- }
1041
736
  const text = await res.text()
1042
737
  throw new Error(text || 'snapshot failed')
1043
738
  }
@@ -1371,190 +1066,73 @@ async function cmdPush(opts) {
1371
1066
  const metaDir = path.join(dir, '.vcs-next')
1372
1067
  const localPath = path.join(metaDir, 'local.json')
1373
1068
  let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1374
- const spinner = createSpinner('Preparing to push...', opts.json)
1375
1069
  try {
1376
1070
  const s = await fs.promises.readFile(localPath, 'utf8')
1377
1071
  localMeta = JSON.parse(s)
1378
1072
  } catch { }
1379
1073
 
1380
- try {
1381
- // Check for unresolved conflicts in files
1382
- spinnerUpdate(spinner, 'Checking for conflicts...')
1383
- const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1384
- if (unresolvedConflicts.length > 0) {
1385
- spinnerFail(spinner, 'Cannot push with unresolved conflicts')
1386
- if (opts.json === 'true') {
1387
- print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1388
- } else {
1389
- process.stderr.write(color('Error: Cannot push with unresolved conflicts\\n', 'red'))
1390
- process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
1391
- for (const p of unresolvedConflicts) {
1392
- process.stderr.write(color(` ${p}\\n`, 'red'))
1393
- }
1394
- process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
1074
+ // Check for unresolved conflicts in files
1075
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1076
+ if (unresolvedConflicts.length > 0) {
1077
+ if (opts.json === 'true') {
1078
+ print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1079
+ } else {
1080
+ process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
1081
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
1082
+ for (const p of unresolvedConflicts) {
1083
+ process.stderr.write(color(` ${p}\n`, 'red'))
1395
1084
  }
1396
- return
1085
+ process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1397
1086
  }
1087
+ return
1088
+ }
1398
1089
 
1399
- const cfg = loadConfig()
1400
- const server = getServer(opts, cfg) || remoteMeta.server
1401
- const token = getToken(opts, cfg) || remoteMeta.token
1402
- spinnerUpdate(spinner, 'Fetching remote state...')
1403
- const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1404
- const base = localMeta.baseFiles || {}
1405
- spinnerUpdate(spinner, 'Collecting local changes...')
1406
- const local = await collectLocal(dir)
1407
- const conflicts = []
1408
- const merged = {}
1409
- const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1410
- spinnerUpdate(spinner, 'Merging changes...')
1411
-
1412
- // Helper to check if content has unresolved conflict markers
1413
- const hasConflictMarkers = (content) => {
1414
- if (!content) return false
1415
- return content.includes('<<<<<<<') && content.includes('=======') && content.includes('>>>>>>>')
1416
- }
1417
-
1418
- for (const p of paths) {
1419
- const b = p in base ? base[p] : null
1420
- const r = p in remote.files ? remote.files[p] : null
1421
- const l = p in local ? local[p].content : null
1422
- const changedLocal = String(l) !== String(b)
1423
- const changedRemote = String(r) !== String(b)
1424
-
1425
- // Check if local file has conflict markers (unresolved)
1426
- if (l && hasConflictMarkers(l)) {
1427
- const line = firstDiffLine(l || '', r || '')
1428
- conflicts.push({ path: p, line, reason: 'Unresolved conflict markers in file' })
1429
- } else if (changedLocal && changedRemote && String(l) !== String(r)) {
1430
- // Both changed and different - but if local is the "resolved" version, use it
1431
- // This happens when user resolved a conflict and is pushing the resolution
1432
- // The local version wins if it doesn't have conflict markers
1433
- if (l !== null) merged[p] = l
1434
- } else if (changedLocal && !changedRemote) {
1435
- // Local changed, use local version
1436
- if (l !== null) merged[p] = l
1437
- } else if (!changedLocal && changedRemote) {
1438
- // Remote changed, use remote version
1439
- if (r !== null) merged[p] = r
1440
- } else {
1441
- // No changes - include the file from whatever source has it
1442
- if (l !== null) merged[p] = l
1443
- else if (r !== null) merged[p] = r
1444
- else if (b !== null) merged[p] = b
1445
- }
1090
+ const cfg = loadConfig()
1091
+ const server = getServer(opts, cfg) || remoteMeta.server
1092
+ const token = getToken(opts, cfg) || remoteMeta.token
1093
+ const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1094
+ const base = localMeta.baseFiles || {}
1095
+ const local = await collectLocal(dir)
1096
+ const conflicts = []
1097
+ const merged = {}
1098
+ const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1099
+ for (const p of paths) {
1100
+ const b = p in base ? base[p] : null
1101
+ const r = p in remote.files ? remote.files[p] : null
1102
+ const l = p in local ? local[p].content : null
1103
+ const changedLocal = String(l) !== String(b)
1104
+ const changedRemote = String(r) !== String(b)
1105
+ if (changedLocal && changedRemote && String(l) !== String(r)) {
1106
+ const line = firstDiffLine(l || '', r || '')
1107
+ conflicts.push({ path: p, line })
1108
+ } else if (changedLocal && !changedRemote) {
1109
+ if (l !== null) merged[p] = l
1110
+ } else if (!changedLocal && changedRemote) {
1111
+ if (r !== null) merged[p] = r
1112
+ } else {
1113
+ if (b !== null) merged[p] = b
1446
1114
  }
1447
- if (conflicts.length > 0) {
1448
- spinnerFail(spinner, 'Push blocked by conflicts')
1449
-
1450
- // Write conflict markers to local files
1115
+ }
1116
+ if (conflicts.length > 0) {
1117
+ if (opts.json === 'true') {
1118
+ print({ conflicts }, true)
1119
+ } else {
1120
+ process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1121
+ process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1451
1122
  for (const c of conflicts) {
1452
- const localContent = local[c.path]?.content || ''
1453
- const remoteContent = remote.files[c.path] || ''
1454
-
1455
- // Create conflict-marked content
1456
- const conflictContent = [
1457
- '<<<<<<< LOCAL (your changes)',
1458
- localContent,
1459
- '=======',
1460
- remoteContent,
1461
- '>>>>>>> REMOTE (server changes)'
1462
- ].join('\n')
1463
-
1464
- // Write to local file
1465
- const conflictPath = path.join(dir, c.path)
1466
- await fs.promises.mkdir(path.dirname(conflictPath), { recursive: true })
1467
- await fs.promises.writeFile(conflictPath, conflictContent, 'utf8')
1123
+ process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
1468
1124
  }
1469
-
1470
- if (opts.json === 'true') {
1471
- print({ conflicts, message: 'Conflict markers written to files. Resolve them and try again.' }, true)
1472
- } else {
1473
- process.stderr.write(color('\nConflicts detected! The following files have been updated with conflict markers:\n', 'red'))
1474
- for (const c of conflicts) {
1475
- process.stderr.write(color(` ${c.path}\n`, 'yellow'))
1476
- }
1477
- process.stderr.write(color('\nTo resolve:\n', 'cyan'))
1478
- process.stderr.write(color(' 1. Open the files above and look for conflict markers:\n', 'dim'))
1479
- process.stderr.write(color(' <<<<<<< LOCAL (your changes)\n', 'dim'))
1480
- process.stderr.write(color(' ... your version ...\n', 'dim'))
1481
- process.stderr.write(color(' =======\n', 'dim'))
1482
- process.stderr.write(color(' ... server version ...\n', 'dim'))
1483
- process.stderr.write(color(' >>>>>>> REMOTE (server changes)\n', 'dim'))
1484
- process.stderr.write(color(' 2. Edit the files to keep the changes you want\n', 'dim'))
1485
- process.stderr.write(color(' 3. Remove the conflict markers\n', 'dim'))
1486
- process.stderr.write(color(' 4. Run: resulgit add . && resulgit commit -m "Resolved conflicts" && resulgit push\n\n', 'dim'))
1487
- }
1488
- return
1489
- }
1490
-
1491
- // Check if there are any files to push
1492
- if (Object.keys(merged).length === 0) {
1493
- spinnerFail(spinner, 'Nothing to push')
1494
- if (opts.json === 'true') {
1495
- print({ error: 'No files to push' }, true)
1496
- } else {
1497
- process.stdout.write('No files to push. Add some files first.\n')
1498
- }
1499
- return
1500
- }
1501
-
1502
- // Execute pre-push hook
1503
- spinnerUpdate(spinner, 'Running pre-push hook...')
1504
- try {
1505
- const hookResult = await hooks.executeHook(dir, 'pre-push', { branch: remoteMeta.branch, files: Object.keys(merged) })
1506
- if (hookResult.executed && hookResult.exitCode !== 0) {
1507
- spinnerFail(spinner, 'Pre-push hook failed')
1508
- throw new Error('pre-push hook failed')
1509
- }
1510
- } catch (err) {
1511
- if (err.message === 'pre-push hook failed') throw err
1512
- // Hook doesn't exist or other error, continue
1513
- }
1514
-
1515
- // Step 1: Upload files as blobs
1516
- spinnerUpdate(spinner, `Uploading ${Object.keys(merged).length} file(s)...`)
1517
- let blobMap = {}
1518
- try {
1519
- blobMap = await uploadBlobs(server, remoteMeta.repoId, merged, token)
1520
- } catch (err) {
1521
- // If blob upload fails, fall back to sending content directly
1522
- // (This handles servers that accept content in commits endpoint)
1523
- spinnerUpdate(spinner, 'Blob upload not available, sending files directly...')
1524
- blobMap = merged // Use content directly
1525
- }
1526
-
1527
- // Step 2: Create commit with blob IDs (or content if blob upload failed)
1528
- const commitFiles = {}
1529
- for (const [filePath, content] of Object.entries(merged)) {
1530
- // Use blob ID if available, otherwise use content
1531
- commitFiles[filePath] = blobMap[filePath] || content
1532
1125
  }
1533
-
1534
- const body = {
1535
- message: localMeta.pendingCommit?.message || (opts.message || 'Push'),
1536
- files: commitFiles,
1537
- branchName: remoteMeta.branch
1538
- }
1539
- spinnerUpdate(spinner, `Creating commit on '${remoteMeta.branch}'...`)
1540
- const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1541
- const data = await request('POST', url, body, token)
1542
- localMeta.baseCommitId = data.id || remote.commitId || ''
1543
- localMeta.baseFiles = merged
1544
- localMeta.pendingCommit = null
1545
- await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1546
-
1547
- // Execute post-push hook
1548
- try {
1549
- await hooks.executeHook(dir, 'post-push', { branch: remoteMeta.branch, commitId: localMeta.baseCommitId })
1550
- } catch { }
1551
-
1552
- spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
1553
- print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1554
- } catch (err) {
1555
- spinnerFail(spinner, 'Push failed')
1556
- throw err
1126
+ return
1557
1127
  }
1128
+ const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1129
+ const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1130
+ const data = await request('POST', url, body, token)
1131
+ localMeta.baseCommitId = data.id || remote.commitId || ''
1132
+ localMeta.baseFiles = merged
1133
+ localMeta.pendingCommit = null
1134
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1135
+ print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1558
1136
  }
1559
1137
 
1560
1138
  async function cmdMerge(opts) {
@@ -1896,20 +1474,7 @@ async function cmdBranch(sub, opts) {
1896
1474
  if (m) current = m[1]
1897
1475
  } catch { }
1898
1476
  process.stdout.write(color('Branches:\n', 'bold'))
1899
- let list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '', date: b.lastCommitDate || b.createdAt || '' }))
1900
-
1901
- // Handle --sort option
1902
- if (opts.sort) {
1903
- const sortBy = opts.sort.replace(/^-/, '') // Remove leading dash
1904
- if (sortBy === 'committerdate' || sortBy === '-committerdate') {
1905
- list.sort((a, b) => {
1906
- const dateA = new Date(a.date || 0).getTime()
1907
- const dateB = new Date(b.date || 0).getTime()
1908
- return sortBy.startsWith('-') ? dateB - dateA : dateA - dateB
1909
- })
1910
- }
1911
- }
1912
-
1477
+ const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1913
1478
  for (const b of list) {
1914
1479
  const isCur = b.name === current
1915
1480
  const mark = isCur ? color('*', 'green') : ' '
@@ -1933,17 +1498,12 @@ async function cmdBranch(sub, opts) {
1933
1498
  if (sub === 'delete') {
1934
1499
  const name = opts.name
1935
1500
  if (!name) throw new Error('Missing --name')
1936
- const force = opts.force === 'true' || opts.D === 'true'
1937
1501
  const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1938
1502
  u.searchParams.set('name', name)
1939
- u.searchParams.set('force', force ? 'true' : 'false')
1940
1503
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
1941
1504
  const res = await fetch(u.toString(), { method: 'DELETE', headers })
1942
1505
  if (!res.ok) {
1943
1506
  const body = await res.text().catch(() => '')
1944
- if (force) {
1945
- throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
1946
- }
1947
1507
  throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
1948
1508
  }
1949
1509
  const data = await res.json()
@@ -1972,24 +1532,10 @@ async function cmdSwitch(opts) {
1972
1532
  const dir = path.resolve(opts.dir || '.')
1973
1533
  const meta = readRemoteMeta(dir)
1974
1534
  const branch = opts.branch
1975
- const create = opts.create === 'true' || opts.c === 'true'
1976
1535
  if (!branch) throw new Error('Missing --branch')
1977
1536
  const cfg = loadConfig()
1978
1537
  const server = getServer(opts, cfg) || meta.server
1979
1538
  const token = getToken(opts, cfg) || meta.token
1980
-
1981
- // Handle -c flag (create and switch)
1982
- if (create) {
1983
- // Check if branch exists
1984
- const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1985
- const branchesData = await request('GET', branchesUrl.toString(), null, token)
1986
- const exists = (branchesData.branches || []).some(b => b.name === branch)
1987
- if (!exists) {
1988
- // Create the branch
1989
- await cmdBranch('create', { dir, name: branch, base: meta.branch, repo: meta.repoId, server, token })
1990
- }
1991
- }
1992
-
1993
1539
  await pullToDir(meta.repoId, branch, dir, server, token)
1994
1540
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1995
1541
  }
@@ -2344,28 +1890,10 @@ async function cmdReset(opts) {
2344
1890
  const server = getServer(opts, cfg) || meta.server
2345
1891
  const token = getToken(opts, cfg) || meta.token
2346
1892
 
2347
- let commitId = opts.commit || 'HEAD'
1893
+ const commitId = opts.commit || 'HEAD'
2348
1894
  const mode = opts.mode || 'mixed' // soft, mixed, hard
2349
1895
  const filePath = opts.path
2350
1896
 
2351
- // Handle HEAD^ syntax (parent commit)
2352
- if (commitId === 'HEAD^' || commitId.endsWith('^')) {
2353
- const baseCommit = commitId.replace(/\^+$/, '')
2354
- const targetCommit = baseCommit === 'HEAD' ? 'HEAD' : baseCommit
2355
- let targetSnap
2356
- if (targetCommit === 'HEAD') {
2357
- targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
2358
- } else {
2359
- targetSnap = await fetchSnapshotByCommit(server, meta.repoId, targetCommit, token)
2360
- }
2361
- const commit = await fetchCommitMeta(server, meta.repoId, targetSnap.commitId, token)
2362
- const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2363
- if (!parentId) {
2364
- throw new Error('No parent commit found')
2365
- }
2366
- commitId = parentId
2367
- }
2368
-
2369
1897
  if (filePath) {
2370
1898
  // Reset specific file (unstage)
2371
1899
  const metaDir = path.join(dir, '.vcs-next')
@@ -2436,78 +1964,14 @@ async function cmdReset(opts) {
2436
1964
  }
2437
1965
 
2438
1966
  async function cmdInit(opts) {
1967
+ const dir = path.resolve(opts.dir || '.')
2439
1968
  const cfg = loadConfig()
2440
1969
  const server = getServer(opts, cfg)
2441
1970
  const token = getToken(opts, cfg)
2442
- // Repository name must be a non‑empty string; validate format
2443
- const repo = opts.repo ? validation.validateRepoName(opts.repo) : ''
2444
- // Branch name defaults to 'main' if not supplied
2445
- const branch = opts.branch ? opts.branch : 'main'
2446
-
2447
- // Determine target directory:
2448
- // - If --dir is specified, use that
2449
- // - Otherwise, if repo name is provided, create folder with that name
2450
- // - Fallback to current directory
2451
- let targetDir
2452
- if (opts.dir) {
2453
- targetDir = path.resolve(opts.dir)
2454
- } else if (repo) {
2455
- targetDir = path.resolve(repo)
2456
- } else {
2457
- targetDir = path.resolve('.')
2458
- }
2459
-
2460
- // Create the target directory if it doesn't exist
2461
- await fs.promises.mkdir(targetDir, { recursive: true })
2462
-
2463
- const spinner = createSpinner(`Initializing repository${repo ? ` '${repo}'` : ''}...`, opts.json)
2464
-
2465
- let repoId = repo
2466
- let remoteCreated = false
2467
-
2468
- // If a repo name is provided and we have server, try to create remote repo
2469
- if (repo && server) {
2470
- if (!token) {
2471
- spinnerUpdate(spinner, 'No auth token set. Run "resulgit auth login" first for remote repo creation.')
2472
- }
2473
- try {
2474
- spinnerUpdate(spinner, 'Creating remote repository...')
2475
- const createUrl = new URL('/api/repositories', server).toString()
2476
- const createRes = await request('POST', createUrl, {
2477
- name: repo,
2478
- description: opts.description || '',
2479
- visibility: opts.visibility || 'private',
2480
- initializeWithReadme: true
2481
- }, token)
2482
- // Use the ID returned by the server (could be numeric or string)
2483
- repoId = String(createRes.id || repo)
2484
- remoteCreated = true
2485
- spinnerUpdate(spinner, `Remote repository '${repo}' created (ID: ${repoId})`)
2486
- } catch (err) {
2487
- // If repo already exists (409), try to fetch its ID
2488
- if (err.message && (err.message.includes('409') || err.message.includes('already exists'))) {
2489
- spinnerUpdate(spinner, `Remote repository '${repo}' already exists, linking...`)
2490
- try {
2491
- const listUrl = new URL('/api/repositories', server).toString()
2492
- const repos = await request('GET', listUrl, null, token)
2493
- const found = (repos || []).find(r => r.name === repo)
2494
- if (found) {
2495
- repoId = String(found.id)
2496
- remoteCreated = true
2497
- }
2498
- } catch { /* ignore */ }
2499
- } else if (err.message && err.message.includes('401')) {
2500
- spinnerUpdate(spinner, 'Authentication required. Run "resulgit auth login" to set up credentials.')
2501
- } else {
2502
- // Other error - continue with local init only
2503
- spinnerUpdate(spinner, `Could not create remote repo: ${err.message}`)
2504
- }
2505
- }
2506
- }
2507
-
2508
- spinnerUpdate(spinner, 'Setting up local repository...')
2509
- const metaDir = path.join(targetDir, '.vcs-next')
2510
- const gitDir = path.join(targetDir, '.git')
1971
+ const repo = opts.repo || ''
1972
+ const branch = opts.branch || 'main'
1973
+ const metaDir = path.join(dir, '.vcs-next')
1974
+ const gitDir = path.join(dir, '.git')
2511
1975
  await fs.promises.mkdir(metaDir, { recursive: true })
2512
1976
  await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
2513
1977
  await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
@@ -2521,47 +1985,16 @@ async function cmdInit(opts) {
2521
1985
  '',
2522
1986
  '[vcs-next]',
2523
1987
  `\tserver = ${opts.server || server || ''}`,
2524
- `\trepoId = ${repoId}`,
1988
+ `\trepoId = ${repo}`,
2525
1989
  `\tbranch = ${branch}`,
2526
1990
  `\ttoken = ${opts.token || token || ''}`
2527
1991
  ].join('\n')
2528
1992
  await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
2529
-
2530
- let remoteCommitId = ''
2531
- let baseFiles = {}
2532
-
2533
- // If remote was created, fetch the initial snapshot to sync baseFiles
2534
- if (remoteCreated && repoId && server) {
2535
- try {
2536
- spinnerUpdate(spinner, 'Syncing with remote...')
2537
- const snapshot = await fetchRemoteSnapshot(server, repoId, branch, token)
2538
- remoteCommitId = snapshot.commitId || ''
2539
- baseFiles = snapshot.files || {}
2540
-
2541
- // Write the remote files to the local directory (like a clone)
2542
- for (const [filePath, content] of Object.entries(baseFiles)) {
2543
- const fullPath = path.join(targetDir, filePath)
2544
- await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
2545
- await fs.promises.writeFile(fullPath, String(content), 'utf8')
2546
- }
2547
- } catch (err) {
2548
- // Ignore sync errors - the repo might be truly empty
2549
- }
2550
- }
2551
-
2552
- const remoteMeta = { repoId: repoId, branch, commitId: remoteCommitId, server: opts.server || server || '', token: opts.token || token || '' }
1993
+ const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
2553
1994
  await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
2554
- const localMeta = { baseCommitId: remoteCommitId, baseFiles: baseFiles, pendingCommit: null }
1995
+ const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2555
1996
  await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
2556
-
2557
- if (remoteCreated) {
2558
- spinnerSuccess(spinner, `Initialized repository '${repo}' in ${targetDir} (remote linked)`)
2559
- } else if (repo) {
2560
- spinnerSuccess(spinner, `Initialized local repository in ${targetDir} (remote not created - check auth)`)
2561
- } else {
2562
- spinnerSuccess(spinner, `Initialized repository in ${targetDir}`)
2563
- }
2564
- print({ initialized: targetDir, branch, repoId: repoId, remoteCreated }, opts.json === 'true')
1997
+ print({ initialized: dir, branch }, opts.json === 'true')
2565
1998
  }
2566
1999
 
2567
2000
  async function cmdMv(opts) {
@@ -2831,400 +2264,6 @@ async function cmdRebase(opts) {
2831
2264
  print({ rebased: sourceBranch, onto }, opts.json === 'true')
2832
2265
  }
2833
2266
  }
2834
-
2835
- async function cmdBlame(opts) {
2836
- const dir = path.resolve(opts.dir || '.')
2837
- const filePath = opts.path
2838
- if (!filePath) throw new errors.ValidationError('Missing --path', 'path')
2839
-
2840
- const validPath = validation.validateFilePath(filePath)
2841
- const meta = readRemoteMeta(dir)
2842
- const cfg = loadConfig()
2843
- const server = getServer(opts, cfg) || meta.server
2844
- const token = getToken(opts, cfg) || meta.token
2845
-
2846
- const spinner = createSpinner(`Getting blame for ${validPath}...`, opts.json)
2847
-
2848
- try {
2849
- // Get file content
2850
- const local = await collectLocal(dir)
2851
- if (!local[validPath]) {
2852
- throw new errors.FileSystemError(`File not found: ${validPath}`, validPath, 'read')
2853
- }
2854
-
2855
- // Get commits
2856
- const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2857
- commitsUrl.searchParams.set('branch', meta.branch)
2858
- const commitsRes = await fetch(commitsUrl.toString(), {
2859
- headers: token ? { Authorization: `Bearer ${token}` } : {}
2860
- })
2861
-
2862
- if (!commitsRes.ok) {
2863
- throw new errors.NetworkError('Failed to fetch commits', commitsRes.status, commitsUrl.toString())
2864
- }
2865
-
2866
- const commits = await commitsRes.json()
2867
- const blameData = parseBlame(local[validPath].content, commits, validPath)
2868
-
2869
- spinnerSuccess(spinner, `Blame for ${validPath}`)
2870
-
2871
- if (opts.json === 'true') {
2872
- print(formatBlameJson(blameData), false)
2873
- } else {
2874
- process.stdout.write(formatBlameOutput(blameData) + '\n')
2875
- }
2876
- } catch (err) {
2877
- spinnerFail(spinner, 'Blame failed')
2878
- throw err
2879
- }
2880
- }
2881
-
2882
- async function cmdLog(opts) {
2883
- const dir = path.resolve(opts.dir || '.')
2884
- const meta = readRemoteMeta(dir)
2885
- const cfg = loadConfig()
2886
- const server = getServer(opts, cfg) || meta.server
2887
- const token = getToken(opts, cfg) || meta.token
2888
-
2889
- const spinner = createSpinner('Fetching commit history...', opts.json)
2890
-
2891
- try {
2892
- const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
2893
- if (opts.branch) url.searchParams.set('branch', opts.branch)
2894
- else url.searchParams.set('branch', meta.branch)
2895
-
2896
- const data = await request('GET', url.toString(), null, token)
2897
- let commits = Array.isArray(data) ? data : []
2898
-
2899
- // Filter by file path if provided
2900
- const filePath = opts.path
2901
- if (filePath) {
2902
- const filteredCommits = []
2903
- for (const commit of commits) {
2904
- const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2905
- if (commitSnap.files[filePath] !== undefined) {
2906
- filteredCommits.push(commit)
2907
- }
2908
- }
2909
- commits = filteredCommits
2910
- }
2911
-
2912
- // Filter by content pattern (-G flag)
2913
- const pattern = opts.G || opts.pattern
2914
- if (pattern) {
2915
- const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
2916
- const filteredCommits = []
2917
- for (const commit of commits) {
2918
- const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
2919
- const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
2920
- const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {} }
2921
-
2922
- // Check if pattern matches in any file changed in this commit
2923
- const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
2924
- let matches = false
2925
- for (const p of allPaths) {
2926
- const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : ''
2927
- const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : ''
2928
- if (regex.test(oldContent) || regex.test(newContent)) {
2929
- matches = true
2930
- break
2931
- }
2932
- }
2933
- if (matches) {
2934
- filteredCommits.push(commit)
2935
- }
2936
- }
2937
- commits = filteredCommits
2938
- }
2939
-
2940
- spinnerSuccess(spinner, `Found ${commits.length} commits`)
2941
-
2942
- if (opts.json === 'true') {
2943
- print(commits, true)
2944
- } else if (opts.oneline === 'true') {
2945
- process.stdout.write(formatCompactLog(commits) + '\n')
2946
- } else if (opts.stats === 'true') {
2947
- const stats = generateCommitStats(commits)
2948
- process.stdout.write(color('Commit Statistics\n', 'bold'))
2949
- process.stdout.write(color('━'.repeat(50) + '\n', 'dim'))
2950
- process.stdout.write(`Total commits: ${stats.totalCommits}\n`)
2951
- process.stdout.write(`\nTop authors:\n`)
2952
- for (const author of stats.topAuthors) {
2953
- process.stdout.write(` ${author.name.padEnd(30)} ${color(author.commits + ' commits', 'cyan')}\n`)
2954
- }
2955
- if (stats.datesRange.earliest && stats.datesRange.latest) {
2956
- process.stdout.write(`\nDate range: ${stats.datesRange.earliest.toISOString().split('T')[0]} to ${stats.datesRange.latest.toISOString().split('T')[0]}\n`)
2957
- }
2958
- } else {
2959
- const maxCommits = parseInt(opts.max || '50', 10)
2960
- process.stdout.write(generateLogGraph(commits, { maxCommits }) + '\n')
2961
- }
2962
- } catch (err) {
2963
- spinnerFail(spinner, 'Log fetch failed')
2964
- throw err
2965
- }
2966
- }
2967
-
2968
- async function cmdHook(sub, opts) {
2969
- const dir = path.resolve(opts.dir || '.')
2970
-
2971
- if (sub === 'list') {
2972
- const hooksList = await hooks.listHooks(dir)
2973
- if (opts.json === 'true') {
2974
- print(hooksList, true)
2975
- } else {
2976
- if (hooksList.length === 0) {
2977
- process.stdout.write('No hooks installed.\n')
2978
- } else {
2979
- process.stdout.write(color('Installed Hooks:\n', 'bold'))
2980
- for (const hook of hooksList) {
2981
- const exe = hook.executable ? color('✓', 'green') : color('✗', 'red')
2982
- process.stdout.write(` ${exe} ${color(hook.name, 'cyan')}\n`)
2983
- }
2984
- }
2985
- }
2986
- return
2987
- }
2988
-
2989
- if (sub === 'install') {
2990
- const hookName = opts.name
2991
- if (!hookName) throw new Error('Missing --name')
2992
-
2993
- let script = opts.script
2994
- if (!script && opts.sample === 'true') {
2995
- script = hooks.SAMPLE_HOOKS[hookName]
2996
- if (!script) throw new Error(`No sample available for ${hookName}`)
2997
- }
2998
- if (!script) throw new Error('Missing --script or use --sample')
2999
-
3000
- const result = await hooks.installHook(dir, hookName, script)
3001
- print(result, opts.json === 'true')
3002
- if (opts.json !== 'true') {
3003
- process.stdout.write(color(`✓ Hook '${hookName}' installed\n`, 'green'))
3004
- }
3005
- return
3006
- }
3007
-
3008
- if (sub === 'remove') {
3009
- const hookName = opts.name
3010
- if (!hookName) throw new Error('Missing --name')
3011
-
3012
- const result = await hooks.removeHook(dir, hookName)
3013
- print(result, opts.json === 'true')
3014
- return
3015
- }
3016
-
3017
- if (sub === 'show') {
3018
- const hookName = opts.name
3019
- if (!hookName) throw new Error('Missing --name')
3020
-
3021
- const result = await hooks.readHook(dir, hookName)
3022
- if (result.content) {
3023
- process.stdout.write(result.content + '\n')
3024
- } else {
3025
- process.stdout.write(`Hook '${hookName}' not found.\n`)
3026
- }
3027
- return
3028
- }
3029
-
3030
- throw new Error('Unknown hook subcommand. Use: list, install, remove, show')
3031
- }
3032
-
3033
- async function cmdGrep(opts) {
3034
- const dir = path.resolve(opts.dir || '.')
3035
- const pattern = opts.pattern || opts.p || ''
3036
- if (!pattern) throw new Error('Missing --pattern or -p')
3037
-
3038
- const meta = readRemoteMeta(dir)
3039
- const local = await collectLocal(dir)
3040
- const results = []
3041
-
3042
- const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
3043
-
3044
- for (const [filePath, fileData] of Object.entries(local)) {
3045
- const lines = fileData.content.split(/\r?\n/)
3046
- for (let i = 0; i < lines.length; i++) {
3047
- if (regex.test(lines[i])) {
3048
- results.push({
3049
- path: filePath,
3050
- line: i + 1,
3051
- content: lines[i].trim()
3052
- })
3053
- }
3054
- }
3055
- }
3056
-
3057
- if (opts.json === 'true') {
3058
- print(results, true)
3059
- } else {
3060
- for (const result of results) {
3061
- process.stdout.write(color(`${result.path}:${result.line}`, 'cyan'))
3062
- process.stdout.write(`: ${result.content}\n`)
3063
- }
3064
- }
3065
- }
3066
-
3067
- async function cmdLsFiles(opts) {
3068
- const dir = path.resolve(opts.dir || '.')
3069
- const meta = readRemoteMeta(dir)
3070
- const cfg = loadConfig()
3071
- const server = getServer(opts, cfg) || meta.server
3072
- const token = getToken(opts, cfg) || meta.token
3073
-
3074
- const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
3075
- const files = Object.keys(remote.map)
3076
-
3077
- if (opts.json === 'true') {
3078
- print(files.map(f => ({ path: f })), true)
3079
- } else {
3080
- for (const file of files) {
3081
- process.stdout.write(`${file}\n`)
3082
- }
3083
- }
3084
- }
3085
-
3086
- async function cmdReflog(opts) {
3087
- const dir = path.resolve(opts.dir || '.')
3088
- const metaDir = path.join(dir, '.vcs-next')
3089
- const reflogPath = path.join(metaDir, 'reflog.json')
3090
-
3091
- let reflog = []
3092
- try {
3093
- const content = await fs.promises.readFile(reflogPath, 'utf8')
3094
- reflog = JSON.parse(content)
3095
- } catch { }
3096
-
3097
- if (opts.json === 'true') {
3098
- print(reflog, true)
3099
- } else {
3100
- if (reflog.length === 0) {
3101
- process.stdout.write('No reflog entries.\n')
3102
- } else {
3103
- for (const entry of reflog) {
3104
- const date = new Date(entry.timestamp).toLocaleString()
3105
- process.stdout.write(`${color(entry.commitId.slice(0, 7), 'yellow')} ${entry.action} ${date} ${entry.message || ''}\n`)
3106
- }
3107
- }
3108
- }
3109
- }
3110
-
3111
- async function cmdCatFile(opts) {
3112
- const dir = path.resolve(opts.dir || '.')
3113
- const type = opts.type || ''
3114
- const object = opts.object || ''
3115
-
3116
- if (!type || !object) throw new Error('Missing --type and --object')
3117
-
3118
- const meta = readRemoteMeta(dir)
3119
- const cfg = loadConfig()
3120
- const server = getServer(opts, cfg) || meta.server
3121
- const token = getToken(opts, cfg) || meta.token
3122
-
3123
- if (type === 'blob') {
3124
- const snap = await fetchSnapshotByCommit(server, meta.repoId, object, token)
3125
- const filePath = opts.path || ''
3126
- if (filePath && snap.files[filePath]) {
3127
- process.stdout.write(String(snap.files[filePath]))
3128
- } else {
3129
- throw new Error('File not found in commit')
3130
- }
3131
- } else if (type === 'commit') {
3132
- const commit = await fetchCommitMeta(server, meta.repoId, object, token)
3133
- if (opts.json === 'true') {
3134
- print(commit, true)
3135
- } else {
3136
- process.stdout.write(`commit ${commit.id || commit._id}\n`)
3137
- process.stdout.write(`Author: ${commit.author?.name || ''} <${commit.author?.email || ''}>\n`)
3138
- process.stdout.write(`Date: ${new Date(commit.createdAt || commit.committer?.date || '').toLocaleString()}\n\n`)
3139
- process.stdout.write(`${commit.message || ''}\n`)
3140
- }
3141
- } else {
3142
- throw new Error(`Unsupported type: ${type}`)
3143
- }
3144
- }
3145
-
3146
- async function cmdRevParse(opts) {
3147
- const dir = path.resolve(opts.dir || '.')
3148
- const rev = opts.rev || 'HEAD'
3149
-
3150
- const meta = readRemoteMeta(dir)
3151
- const cfg = loadConfig()
3152
- const server = getServer(opts, cfg) || meta.server
3153
- const token = getToken(opts, cfg) || meta.token
3154
-
3155
- if (rev === 'HEAD') {
3156
- const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
3157
- const found = (info.branches || []).find(b => b.name === meta.branch)
3158
- const commitId = found ? (found.commitId || '') : ''
3159
- print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
3160
- } else if (rev.startsWith('refs/heads/')) {
3161
- const branchName = rev.replace('refs/heads/', '')
3162
- const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
3163
- const found = (info.branches || []).find(b => b.name === branchName)
3164
- const commitId = found ? (found.commitId || '') : ''
3165
- print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
3166
- } else {
3167
- // Assume it's a commit ID
3168
- print(opts.json === 'true' ? { rev, commitId: rev } : rev, opts.json === 'true')
3169
- }
3170
- }
3171
-
3172
- async function cmdDescribe(opts) {
3173
- const dir = path.resolve(opts.dir || '.')
3174
- const commitId = opts.commit || 'HEAD'
3175
-
3176
- const meta = readRemoteMeta(dir)
3177
- const cfg = loadConfig()
3178
- const server = getServer(opts, cfg) || meta.server
3179
- const token = getToken(opts, cfg) || meta.token
3180
-
3181
- // Get tags
3182
- const tagsUrl = new URL(`/api/repositories/${meta.repoId}/tags`, server)
3183
- const tags = await request('GET', tagsUrl.toString(), null, token)
3184
- const tagsList = Array.isArray(tags) ? tags : []
3185
-
3186
- // Find nearest tag (simplified - just find any tag)
3187
- const nearestTag = tagsList[0]
3188
-
3189
- if (nearestTag) {
3190
- const desc = `${nearestTag.name}-0-g${commitId.slice(0, 7)}`
3191
- print(opts.json === 'true' ? { tag: nearestTag.name, commitId, describe: desc } : desc, opts.json === 'true')
3192
- } else {
3193
- print(opts.json === 'true' ? { commitId, describe: commitId.slice(0, 7) } : commitId.slice(0, 7), opts.json === 'true')
3194
- }
3195
- }
3196
-
3197
- async function cmdShortlog(opts) {
3198
- const dir = path.resolve(opts.dir || '.')
3199
- const meta = readRemoteMeta(dir)
3200
- const cfg = loadConfig()
3201
- const server = getServer(opts, cfg) || meta.server
3202
- const token = getToken(opts, cfg) || meta.token
3203
-
3204
- const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
3205
- if (opts.branch) url.searchParams.set('branch', opts.branch)
3206
- else url.searchParams.set('branch', meta.branch)
3207
-
3208
- const commits = await request('GET', url.toString(), null, token)
3209
- const commitsList = Array.isArray(commits) ? commits : []
3210
-
3211
- const stats = generateCommitStats(commitsList)
3212
-
3213
- if (opts.json === 'true') {
3214
- print(stats, true)
3215
- } else {
3216
- for (const author of stats.topAuthors) {
3217
- process.stdout.write(`\n${color(author.name, 'bold')} (${author.commits} commit${author.commits !== 1 ? 's' : ''})\n`)
3218
- // Show commit messages for this author
3219
- const authorCommits = commitsList.filter(c => (c.author?.name || 'Unknown') === author.name)
3220
- for (const commit of authorCommits.slice(0, opts.max ? parseInt(opts.max, 10) : 10)) {
3221
- const msg = (commit.message || 'No message').split('\n')[0].slice(0, 60)
3222
- process.stdout.write(` ${color((commit.id || commit._id || '').slice(0, 7), 'yellow')} ${msg}\n`)
3223
- }
3224
- }
3225
- }
3226
- }
3227
-
3228
2267
  function help() {
3229
2268
  const h = [
3230
2269
  'Usage: resulgit <group> <command> [options]',
@@ -3239,37 +2278,26 @@ function help() {
3239
2278
  ' repo log --repo <id> [--branch <name>] [--json]',
3240
2279
  ' repo head --repo <id> [--branch <name>] [--json]',
3241
2280
  ' repo select [--workspace] (interactive select and clone/open)',
3242
- ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>] [--sort <field>] [--force|-D]',
3243
- ' switch --branch <name> [--create|-c] [--dir <path>]',
3244
- ' checkout --branch <name> [--create|-b] [--commit <id>] [--dir <path>]',
2281
+ ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
2282
+ ' switch --branch <name> [--dir <path>]',
3245
2283
  ' current [--dir <path>] (show active repo/branch)',
3246
2284
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
3247
2285
  ' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
3248
2286
  ' status [--dir <path>] [--json]',
3249
- ' diff [--dir <path>] [--path <file>] [--commit <id>] [--commit1 <id>] [--commit2 <id>] [--stat] [--json]',
3250
- ' commit --message <text> [--all|-a] [--amend] [--dir <path>] [--json]',
2287
+ ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
2288
+ ' commit --message <text> [--dir <path>] [--json]',
3251
2289
  ' push [--dir <path>] [--json]',
3252
2290
  ' head [--dir <path>] [--json]',
3253
- ' rm --path <file> [--cached] [--dir <path>] [--json]',
2291
+ ' rm --path <file> [--dir <path>] [--json]',
3254
2292
  ' pull [--dir <path>]',
3255
2293
  ' fetch [--dir <path>]',
3256
2294
  ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
3257
2295
  ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
3258
2296
  ' restore --path <file> [--source <commit>] [--dir <path>]',
3259
2297
  ' revert --commit <id> [--no-push] [--dir <path>]',
3260
- ' reset [--commit <id>|HEAD^] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2298
+ ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
3261
2299
  ' show --commit <id> [--dir <path>] [--json]',
3262
2300
  ' mv --from <old> --to <new> [--dir <path>]',
3263
- ' blame --path <file> [--dir <path>] [--json] - Show line-by-line authorship',
3264
- ' log [--branch <name>] [--max <N>] [--oneline] [--stats] [--path <file>] [-G <pattern>] [--json] - Show commit history',
3265
- ' hook list|install|remove|show [--name <hook>] [--script <code>] [--sample] - Manage Git hooks',
3266
- ' grep --pattern <pattern> [--ignore-case] [--dir <path>] [--json] - Search in repository',
3267
- ' ls-files [--dir <path>] [--json] - List tracked files',
3268
- ' reflog [--dir <path>] [--json] - Show reference log',
3269
- ' cat-file --type <type> --object <id> [--path <file>] [--dir <path>] [--json] - Display file contents',
3270
- ' rev-parse --rev <ref> [--dir <path>] [--json] - Parse revision names',
3271
- ' describe [--commit <id>] [--dir <path>] [--json] - Describe a commit',
3272
- ' shortlog [--branch <name>] [--max <N>] [--dir <path>] [--json] - Summarize commit log',
3273
2301
  '',
3274
2302
  'Conflict Resolution:',
3275
2303
  ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
@@ -3278,8 +2306,8 @@ function help() {
3278
2306
  ' >>>>>>> incoming (incoming changes)',
3279
2307
  ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
3280
2308
  ' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
3281
- ' clone <url> [--dest <dir>] | --repo <name> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
3282
- ' init [--dir <path>] [--repo <name>] [--server <url>] [--branch <name>] [--token <tok>]',
2309
+ ' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
2310
+ ' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
3283
2311
  ' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
3284
2312
  ' config list|get|set [--key <k>] [--value <v>]',
3285
2313
  ' clean [--dir <path>] [--force]',
@@ -3312,20 +2340,13 @@ async function main() {
3312
2340
  return
3313
2341
  }
3314
2342
  if (cmd[0] === 'clone') {
3315
- const arg = cmd[1];
3316
- if (arg && !arg.startsWith('--')) {
3317
- if (arg.includes('://')) {
3318
- opts.url = arg;
3319
- } else {
3320
- opts.repo = arg;
3321
- }
3322
- }
2343
+ if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
3323
2344
  if (opts.url) {
3324
- await cmdCloneFromUrl(opts, cfg);
2345
+ await cmdCloneFromUrl(opts, cfg)
3325
2346
  } else {
3326
- await cmdClone(opts, cfg);
2347
+ await cmdClone(opts, cfg)
3327
2348
  }
3328
- return;
2349
+ return
3329
2350
  }
3330
2351
  if (cmd[0] === 'status') {
3331
2352
  await cmdStatus(opts)
@@ -3356,18 +2377,10 @@ async function main() {
3356
2377
  return
3357
2378
  }
3358
2379
  if (cmd[0] === 'branch') {
3359
- // Support positional: 'branch create DevBranch' or 'branch delete DevBranch'
3360
- if ((cmd[1] === 'create' || cmd[1] === 'delete') && cmd[2] && !opts.name) {
3361
- opts.name = cmd[2]
3362
- }
3363
2380
  await cmdBranch(cmd[1], opts)
3364
2381
  return
3365
2382
  }
3366
2383
  if (cmd[0] === 'switch') {
3367
- // Support positional: 'switch main' instead of 'switch --branch main'
3368
- if (cmd[1] && !cmd[1].startsWith('-') && !opts.branch) {
3369
- opts.branch = cmd[1]
3370
- }
3371
2384
  await cmdSwitch(opts)
3372
2385
  return
3373
2386
  }
@@ -3380,10 +2393,6 @@ async function main() {
3380
2393
  return
3381
2394
  }
3382
2395
  if (cmd[0] === 'merge') {
3383
- // Support positional: 'merge DevBranch' instead of 'merge --branch DevBranch'
3384
- if (cmd[1] && !cmd[1].startsWith('-') && !opts.branch) {
3385
- opts.branch = cmd[1]
3386
- }
3387
2396
  await cmdMerge(opts)
3388
2397
  return
3389
2398
  }
@@ -3412,12 +2421,8 @@ async function main() {
3412
2421
  return
3413
2422
  }
3414
2423
  if (cmd[0] === 'init') {
3415
- // Allow positional repo name: resulgit init MyRepo
3416
- if (cmd[1] && !cmd[1].startsWith('-') && !opts.repo) {
3417
- opts.repo = cmd[1];
3418
- }
3419
- await cmdInit(opts);
3420
- return;
2424
+ await cmdInit(opts)
2425
+ return
3421
2426
  }
3422
2427
  if (cmd[0] === 'remote') {
3423
2428
  await cmdRemote(cmd[1], opts)
@@ -3467,46 +2472,6 @@ async function main() {
3467
2472
  await cmdAdd(opts)
3468
2473
  return
3469
2474
  }
3470
- if (cmd[0] === 'blame') {
3471
- await cmdBlame(opts)
3472
- return
3473
- }
3474
- if (cmd[0] === 'log') {
3475
- await cmdLog(opts)
3476
- return
3477
- }
3478
- if (cmd[0] === 'hook') {
3479
- await cmdHook(cmd[1], opts)
3480
- return
3481
- }
3482
- if (cmd[0] === 'grep') {
3483
- await cmdGrep(opts)
3484
- return
3485
- }
3486
- if (cmd[0] === 'ls-files') {
3487
- await cmdLsFiles(opts)
3488
- return
3489
- }
3490
- if (cmd[0] === 'reflog') {
3491
- await cmdReflog(opts)
3492
- return
3493
- }
3494
- if (cmd[0] === 'cat-file') {
3495
- await cmdCatFile(opts)
3496
- return
3497
- }
3498
- if (cmd[0] === 'rev-parse') {
3499
- await cmdRevParse(opts)
3500
- return
3501
- }
3502
- if (cmd[0] === 'describe') {
3503
- await cmdDescribe(opts)
3504
- return
3505
- }
3506
- if (cmd[0] === 'shortlog') {
3507
- await cmdShortlog(opts)
3508
- return
3509
- }
3510
2475
  throw new Error('Unknown command')
3511
2476
  }
3512
2477
 
@@ -3621,6 +2586,7 @@ async function copyDir(src, dest) {
3621
2586
  }
3622
2587
  }
3623
2588
 
2589
+
3624
2590
  async function tuiSelectBranch(branches, current) {
3625
2591
  const items = (branches || []).map((b) => ({ name: b.name || '', commitId: b.commitId || '' })).filter((b) => b.name)
3626
2592
  if (items.length === 0) throw new Error('No branches available')