resulgit 1.0.18 → 1.0.20

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 +10 -61
  2. package/package.json +1 -1
  3. package/resulgit.js +451 -1307
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'
41
- 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'
17
+ const val = tokens[i + 1] && !tokens[i + 1].startsWith('--') ? tokens[++i] : 'true'
46
18
  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) {
@@ -450,15 +339,134 @@ function hashContent(buf) {
450
339
  return crypto.createHash('sha1').update(buf).digest('hex')
451
340
  }
452
341
 
342
+ // LCS-based diff: computes edit script (list of equal/insert/delete operations)
343
+ function computeDiff(oldLines, newLines) {
344
+ const m = oldLines.length
345
+ const n = newLines.length
346
+ // Build LCS table
347
+ const dp = Array.from({ length: m + 1 }, () => new Uint16Array(n + 1))
348
+ for (let i = 1; i <= m; i++) {
349
+ for (let j = 1; j <= n; j++) {
350
+ if (oldLines[i - 1] === newLines[j - 1]) {
351
+ dp[i][j] = dp[i - 1][j - 1] + 1
352
+ } else {
353
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
354
+ }
355
+ }
356
+ }
357
+ // Backtrack to produce edit operations
358
+ const ops = [] // { type: 'equal'|'delete'|'insert', oldIdx, newIdx, line }
359
+ let i = m, j = n
360
+ while (i > 0 || j > 0) {
361
+ if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
362
+ ops.push({ type: 'equal', oldIdx: i - 1, newIdx: j - 1, line: oldLines[i - 1] })
363
+ i--; j--
364
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
365
+ ops.push({ type: 'insert', newIdx: j - 1, line: newLines[j - 1] })
366
+ j--
367
+ } else {
368
+ ops.push({ type: 'delete', oldIdx: i - 1, line: oldLines[i - 1] })
369
+ i--
370
+ }
371
+ }
372
+ ops.reverse()
373
+ return ops
374
+ }
375
+
376
+ // Print unified diff with context lines and @@ hunk headers
377
+ function printUnifiedDiff(oldLines, newLines, contextLines) {
378
+ const ctx = contextLines !== undefined ? contextLines : 3
379
+ const ops = computeDiff(oldLines, newLines)
380
+ // Group ops into hunks (runs of changes with context)
381
+ const hunks = []
382
+ let hunk = null
383
+ let lastChangeEnd = -1
384
+ for (let k = 0; k < ops.length; k++) {
385
+ const op = ops[k]
386
+ if (op.type !== 'equal') {
387
+ // Start or extend a hunk
388
+ const contextStart = Math.max(0, k - ctx)
389
+ if (hunk && contextStart <= lastChangeEnd + ctx) {
390
+ // Extend current hunk
391
+ } else {
392
+ // Save previous hunk and start new
393
+ if (hunk) hunks.push(hunk)
394
+ hunk = { startIdx: Math.max(0, k - ctx), endIdx: k }
395
+ }
396
+ hunk.endIdx = k
397
+ lastChangeEnd = k
398
+ }
399
+ }
400
+ if (hunk) {
401
+ hunk.endIdx = Math.min(ops.length - 1, hunk.endIdx + ctx)
402
+ hunks.push(hunk)
403
+ }
404
+ // Print each hunk
405
+ for (const h of hunks) {
406
+ const start = h.startIdx
407
+ const end = Math.min(ops.length - 1, h.endIdx)
408
+ // Compute line numbers for header
409
+ let oldStart = 1, newStart = 1
410
+ for (let k = 0; k < start; k++) {
411
+ if (ops[k].type === 'equal' || ops[k].type === 'delete') oldStart++
412
+ if (ops[k].type === 'equal' || ops[k].type === 'insert') newStart++
413
+ }
414
+ let oldCount = 0, newCount = 0
415
+ for (let k = start; k <= end; k++) {
416
+ if (ops[k].type === 'equal' || ops[k].type === 'delete') oldCount++
417
+ if (ops[k].type === 'equal' || ops[k].type === 'insert') newCount++
418
+ }
419
+ process.stdout.write(color(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@\n`, 'cyan'))
420
+ for (let k = start; k <= end; k++) {
421
+ const op = ops[k]
422
+ if (op.type === 'equal') {
423
+ process.stdout.write(` ${op.line}\n`)
424
+ } else if (op.type === 'delete') {
425
+ process.stdout.write(color(`-${op.line}\n`, 'red'))
426
+ } else if (op.type === 'insert') {
427
+ process.stdout.write(color(`+${op.line}\n`, 'green'))
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ function loadIgnorePatterns(dir) {
434
+ const patterns = ['.git', '.vcs-next', 'node_modules', '.DS_Store', 'dist', 'build']
435
+ const tryFiles = ['.vcs-ignore', '.gitignore']
436
+ for (const f of tryFiles) {
437
+ try {
438
+ const content = fs.readFileSync(path.join(dir, f), 'utf8')
439
+ const lines = content.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#'))
440
+ patterns.push(...lines)
441
+ } catch {}
442
+ }
443
+ return [...new Set(patterns)]
444
+ }
445
+
446
+ function shouldIgnore(p, patterns) {
447
+ const segments = p.split('/')
448
+ for (const pat of patterns) {
449
+ if (segments.includes(pat)) return true
450
+ if (p === pat || p.startsWith(pat + '/')) return true
451
+ // Basic glob-like support for simple cases
452
+ if (pat.startsWith('**/')) {
453
+ const sub = pat.slice(3)
454
+ if (p.endsWith('/' + sub) || p === sub) return true
455
+ }
456
+ }
457
+ return false
458
+ }
459
+
453
460
  async function collectLocal(dir) {
454
461
  const out = {}
455
462
  const base = path.resolve(dir)
463
+ const patterns = loadIgnorePatterns(base)
456
464
  async function walk(cur, rel) {
457
465
  const entries = await fs.promises.readdir(cur, { withFileTypes: true })
458
466
  for (const e of entries) {
459
- if (e.name === '.git' || e.name === '.vcs-next') continue
460
- const abs = path.join(cur, e.name)
461
467
  const rp = rel ? rel + '/' + e.name : e.name
468
+ if (shouldIgnore(rp, patterns)) continue
469
+ const abs = path.join(cur, e.name)
462
470
  if (e.isDirectory()) {
463
471
  await walk(abs, rp)
464
472
  } else if (e.isFile()) {
@@ -503,25 +511,98 @@ async function fetchRemoteFilesMap(server, repo, branch, token) {
503
511
  async function cmdStatus(opts) {
504
512
  const dir = path.resolve(opts.dir || '.')
505
513
  const meta = readRemoteMeta(dir)
506
- const cfg = loadConfig()
507
- const server = getServer(opts, cfg) || meta.server
508
- const token = getToken(opts, cfg) || meta.token
514
+ const metaDir = path.join(dir, '.vcs-next')
515
+ const localPath = path.join(metaDir, 'local.json')
516
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
517
+ try {
518
+ const s = fs.readFileSync(localPath, 'utf8')
519
+ localMeta = JSON.parse(s)
520
+ } catch {}
521
+
509
522
  const local = await collectLocal(dir)
510
- const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
511
- const added = []
512
- const modified = []
513
- const deleted = []
514
- const remotePaths = new Set(Object.keys(remote.map))
515
- const localPaths = new Set(Object.keys(local))
516
- for (const p of localPaths) {
517
- if (!remotePaths.has(p)) added.push(p)
518
- else if (remote.map[p] !== local[p].id) modified.push(p)
523
+ const baseFiles = localMeta.baseFiles || {}
524
+ const pendingFiles = localMeta.pendingCommit ? localMeta.pendingCommit.files : null
525
+
526
+ const untracked = []
527
+ const modifiedUnstaged = []
528
+ const deletedUnstaged = []
529
+ const modifiedStaged = []
530
+ const deletedStaged = []
531
+ const newStaged = []
532
+
533
+ // If there's a pending commit, that's our "staged" area
534
+ if (pendingFiles) {
535
+ // staged changes: pendingFiles vs baseFiles
536
+ const allStagedPaths = new Set([...Object.keys(baseFiles), ...Object.keys(pendingFiles)])
537
+ for (const p of allStagedPaths) {
538
+ const b = baseFiles[p]
539
+ const s = pendingFiles[p]
540
+ if (b === undefined && s !== undefined) newStaged.push(p)
541
+ else if (b !== undefined && s === undefined) deletedStaged.push(p)
542
+ else if (b !== s) modifiedStaged.push(p)
543
+ }
544
+
545
+ // unstaged changes: local vs pendingFiles
546
+ const localPaths = new Set(Object.keys(local))
547
+ const stagedPaths = new Set(Object.keys(pendingFiles))
548
+ for (const p of localPaths) {
549
+ if (!stagedPaths.has(p)) untracked.push(p)
550
+ else if (pendingFiles[p] !== local[p].content) modifiedUnstaged.push(p)
551
+ }
552
+ for (const p of stagedPaths) {
553
+ if (!localPaths.has(p)) deletedUnstaged.push(p)
554
+ }
555
+ } else {
556
+ // No pending commit: just local vs baseFiles
557
+ const localPaths = new Set(Object.keys(local))
558
+ const basePaths = new Set(Object.keys(baseFiles))
559
+ for (const p of localPaths) {
560
+ if (!basePaths.has(p)) untracked.push(p)
561
+ else if (baseFiles[p] !== local[p].content) modifiedUnstaged.push(p)
562
+ }
563
+ for (const p of basePaths) {
564
+ if (!localPaths.has(p)) deletedUnstaged.push(p)
565
+ }
519
566
  }
520
- for (const p of remotePaths) {
521
- if (!localPaths.has(p)) deleted.push(p)
567
+
568
+ if (opts.json === 'true') {
569
+ print({
570
+ branch: meta.branch,
571
+ ahead: localMeta.pendingCommit ? 1 : 0,
572
+ staged: { modified: modifiedStaged, deleted: deletedStaged, new: newStaged },
573
+ unstaged: { modified: modifiedUnstaged, deleted: deletedUnstaged },
574
+ untracked
575
+ }, true)
576
+ return
577
+ }
578
+
579
+ process.stdout.write(`On branch ${color(meta.branch, 'cyan')}\n`)
580
+ if (localMeta.pendingCommit) {
581
+ process.stdout.write(`Your branch is ahead of 'origin/${meta.branch}' by 1 commit.\n`)
582
+ }
583
+
584
+ if (modifiedStaged.length > 0 || deletedStaged.length > 0 || newStaged.length > 0) {
585
+ process.stdout.write('\nChanges to be committed:\n')
586
+ for (const p of newStaged) process.stdout.write(color(` new file: ${p}\n`, 'green'))
587
+ for (const p of modifiedStaged) process.stdout.write(color(` modified: ${p}\n`, 'green'))
588
+ for (const p of deletedStaged) process.stdout.write(color(` deleted: ${p}\n`, 'green'))
589
+ }
590
+
591
+ if (modifiedUnstaged.length > 0 || deletedUnstaged.length > 0) {
592
+ process.stdout.write('\nChanges not staged for commit:\n')
593
+ for (const p of modifiedUnstaged) process.stdout.write(color(` modified: ${p}\n`, 'red'))
594
+ for (const p of deletedUnstaged) process.stdout.write(color(` deleted: ${p}\n`, 'red'))
595
+ }
596
+
597
+ if (untracked.length > 0) {
598
+ process.stdout.write('\nUntracked files:\n')
599
+ for (const p of untracked) process.stdout.write(color(` ${p}\n`, 'red'))
600
+ }
601
+
602
+ if (modifiedStaged.length === 0 && deletedStaged.length === 0 && newStaged.length === 0 &&
603
+ modifiedUnstaged.length === 0 && deletedUnstaged.length === 0 && untracked.length === 0) {
604
+ process.stdout.write('nothing to commit, working tree clean\n')
522
605
  }
523
- const out = { branch: meta.branch, head: remote.headCommitId, added, modified, deleted }
524
- print(out, opts.json === 'true')
525
606
  }
526
607
 
527
608
  async function cmdRestore(opts) {
@@ -578,73 +659,6 @@ async function cmdDiff(opts) {
578
659
 
579
660
  const filePath = opts.path
580
661
  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
662
 
649
663
  if (commitId) {
650
664
  // Show diff for specific commit
@@ -653,41 +667,7 @@ async function cmdDiff(opts) {
653
667
  const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
654
668
  const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
655
669
 
656
- 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
-
670
+ const files = filePath ? [filePath] : [...new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])]
691
671
  for (const p of files) {
692
672
  const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
693
673
  const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
@@ -695,27 +675,17 @@ async function cmdDiff(opts) {
695
675
  if (opts.json === 'true') {
696
676
  print({ path: p, old: oldContent, new: newContent }, true)
697
677
  } else {
698
- process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
678
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'bold'))
699
679
  if (oldContent === null) {
700
680
  process.stdout.write(color(`+++ b/${p}\n`, 'green'))
701
- process.stdout.write(color(`+${newContent}\n`, 'green'))
681
+ const lines = newContent.split(/\r?\n/)
682
+ for (const line of lines) process.stdout.write(color(`+${line}\n`, 'green'))
702
683
  } else if (newContent === null) {
703
684
  process.stdout.write(color(`--- a/${p}\n`, 'red'))
704
- process.stdout.write(color(`-${oldContent}\n`, 'red'))
685
+ const lines = oldContent.split(/\r?\n/)
686
+ for (const line of lines) process.stdout.write(color(`-${line}\n`, 'red'))
705
687
  } else {
706
- const oldLines = oldContent.split(/\r?\n/)
707
- const newLines = newContent.split(/\r?\n/)
708
- const maxLen = Math.max(oldLines.length, newLines.length)
709
- for (let i = 0; i < maxLen; i++) {
710
- const oldLine = oldLines[i]
711
- const newLine = newLines[i]
712
- if (oldLine !== newLine) {
713
- if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
714
- if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
715
- } else if (oldLine !== undefined) {
716
- process.stdout.write(` ${oldLine}\n`)
717
- }
718
- }
688
+ printUnifiedDiff(oldContent.split(/\r?\n/), newContent.split(/\r?\n/))
719
689
  }
720
690
  }
721
691
  }
@@ -724,41 +694,37 @@ async function cmdDiff(opts) {
724
694
  }
725
695
 
726
696
  // Show diff for working directory
727
- const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
697
+ const metaDir = path.join(dir, '.vcs-next')
698
+ const localPath = path.join(metaDir, 'local.json')
699
+ let localMeta = { baseFiles: {} }
700
+ try {
701
+ const s = fs.readFileSync(localPath, 'utf8')
702
+ localMeta = JSON.parse(s)
703
+ } catch {}
704
+
705
+ const baseFiles = localMeta.pendingCommit ? localMeta.pendingCommit.files : localMeta.baseFiles
728
706
  const local = await collectLocal(dir)
729
- const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
707
+ const files = filePath ? [filePath] : [...new Set([...Object.keys(baseFiles), ...Object.keys(local)])]
730
708
 
731
709
  for (const p of files) {
732
- const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
710
+ const baseContent = baseFiles[p] !== undefined ? String(baseFiles[p]) : null
733
711
  const localContent = local[p]?.content || null
734
- const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
735
- const localId = local[p]?.id
736
712
 
737
- if (remoteId !== localId) {
713
+ if (baseContent !== localContent) {
738
714
  if (opts.json === 'true') {
739
- print({ path: p, remote: remoteContent, local: localContent }, true)
715
+ print({ path: p, base: baseContent, local: localContent }, true)
740
716
  } else {
741
- process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
717
+ process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'bold'))
742
718
  if (localContent === null) {
743
719
  process.stdout.write(color(`--- a/${p}\n`, 'red'))
744
- process.stdout.write(color(`-${remoteContent || ''}\n`, 'red'))
745
- } else if (remoteContent === null) {
720
+ const lines = (baseContent || '').split(/\r?\n/)
721
+ for (const line of lines) process.stdout.write(color(`-${line}\n`, 'red'))
722
+ } else if (baseContent === null) {
746
723
  process.stdout.write(color(`+++ b/${p}\n`, 'green'))
747
- process.stdout.write(color(`+${localContent}\n`, 'green'))
724
+ const lines = localContent.split(/\r?\n/)
725
+ for (const line of lines) process.stdout.write(color(`+${line}\n`, 'green'))
748
726
  } else {
749
- const remoteLines = String(remoteContent).split(/\r?\n/)
750
- const localLines = String(localContent).split(/\r?\n/)
751
- const maxLen = Math.max(remoteLines.length, localLines.length)
752
- for (let i = 0; i < maxLen; i++) {
753
- const remoteLine = remoteLines[i]
754
- const localLine = localLines[i]
755
- if (remoteLine !== localLine) {
756
- if (remoteLine !== undefined) process.stdout.write(color(`-${remoteLine}\n`, 'red'))
757
- if (localLine !== undefined) process.stdout.write(color(`+${localLine}\n`, 'green'))
758
- } else if (remoteLine !== undefined) {
759
- process.stdout.write(` ${remoteLine}\n`)
760
- }
761
- }
727
+ printUnifiedDiff(baseContent.split(/\r?\n/), localContent.split(/\r?\n/))
762
728
  }
763
729
  }
764
730
  }
@@ -773,31 +739,6 @@ async function cmdRm(opts) {
773
739
  const cfg = loadConfig()
774
740
  const server = getServer(opts, cfg) || meta.server
775
741
  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
742
  const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
802
743
  u.searchParams.set('branch', meta.branch)
803
744
  u.searchParams.set('path', pathArg)
@@ -816,93 +757,40 @@ async function cmdCommit(opts) {
816
757
  const message = opts.message || ''
817
758
  if (!message) throw new Error('Missing --message')
818
759
 
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')
760
+ // Check for unresolved conflicts
761
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
762
+ if (unresolvedConflicts.length > 0) {
763
+ if (opts.json === 'true') {
764
+ print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
765
+ } else {
766
+ process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
767
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
768
+ for (const p of unresolvedConflicts) {
769
+ process.stderr.write(color(` ${p}\n`, 'red'))
881
770
  }
882
- } catch (err) {
883
- if (err.message === 'pre-commit hook failed') throw err
884
- // Hook doesn't exist or other error, continue
771
+ process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
885
772
  }
773
+ return
774
+ }
886
775
 
887
- localMeta.pendingCommit = { message, files, createdAt: Date.now() }
888
- // Clear conflicts if they were resolved
889
- if (localMeta.conflicts) {
890
- delete localMeta.conflicts
891
- }
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 { }
899
-
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
776
+ const metaDir = path.join(dir, '.vcs-next')
777
+ const localPath = path.join(metaDir, 'local.json')
778
+ let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
779
+ try {
780
+ const s = await fs.promises.readFile(localPath, 'utf8')
781
+ localMeta = JSON.parse(s)
782
+ } catch { }
783
+ const local = await collectLocal(dir)
784
+ const files = {}
785
+ for (const [p, v] of Object.entries(local)) files[p] = v.content
786
+ localMeta.pendingCommit = { message, files, createdAt: Date.now() }
787
+ // Clear conflicts if they were resolved
788
+ if (localMeta.conflicts) {
789
+ delete localMeta.conflicts
905
790
  }
791
+ await fs.promises.mkdir(metaDir, { recursive: true })
792
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
793
+ print({ pendingCommit: message }, opts.json === 'true')
906
794
  }
907
795
 
908
796
  async function pullToDir(repo, branch, dir, server, token) {
@@ -966,15 +854,8 @@ async function cmdPull(opts) {
966
854
  const cfg = loadConfig()
967
855
  const server = getServer(opts, cfg) || meta.server
968
856
  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
- }
857
+ await pullToDir(meta.repoId, meta.branch, dir, server, token)
858
+ print('Pull complete', opts.json === 'true')
978
859
  }
979
860
 
980
861
  async function cmdFetch(opts) {
@@ -1030,14 +911,6 @@ async function fetchRemoteSnapshot(server, repo, branch, token) {
1030
911
  if (branch) url.searchParams.set('branch', branch)
1031
912
  const res = await fetch(url.toString(), { headers })
1032
913
  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
914
  const text = await res.text()
1042
915
  throw new Error(text || 'snapshot failed')
1043
916
  }
@@ -1371,190 +1244,73 @@ async function cmdPush(opts) {
1371
1244
  const metaDir = path.join(dir, '.vcs-next')
1372
1245
  const localPath = path.join(metaDir, 'local.json')
1373
1246
  let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
1374
- const spinner = createSpinner('Preparing to push...', opts.json)
1375
1247
  try {
1376
1248
  const s = await fs.promises.readFile(localPath, 'utf8')
1377
1249
  localMeta = JSON.parse(s)
1378
1250
  } catch { }
1379
1251
 
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'))
1252
+ // Check for unresolved conflicts in files
1253
+ const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
1254
+ if (unresolvedConflicts.length > 0) {
1255
+ if (opts.json === 'true') {
1256
+ print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
1257
+ } else {
1258
+ process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
1259
+ process.stderr.write(color('Conflicts in files:\n', 'yellow'))
1260
+ for (const p of unresolvedConflicts) {
1261
+ process.stderr.write(color(` ${p}\n`, 'red'))
1395
1262
  }
1396
- return
1263
+ process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
1397
1264
  }
1265
+ return
1266
+ }
1398
1267
 
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
- }
1268
+ const cfg = loadConfig()
1269
+ const server = getServer(opts, cfg) || remoteMeta.server
1270
+ const token = getToken(opts, cfg) || remoteMeta.token
1271
+ const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
1272
+ const base = localMeta.baseFiles || {}
1273
+ const local = await collectLocal(dir)
1274
+ const conflicts = []
1275
+ const merged = {}
1276
+ const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
1277
+ for (const p of paths) {
1278
+ const b = p in base ? base[p] : null
1279
+ const r = p in remote.files ? remote.files[p] : null
1280
+ const l = p in local ? local[p].content : null
1281
+ const changedLocal = String(l) !== String(b)
1282
+ const changedRemote = String(r) !== String(b)
1283
+ if (changedLocal && changedRemote && String(l) !== String(r)) {
1284
+ const line = firstDiffLine(l || '', r || '')
1285
+ conflicts.push({ path: p, line })
1286
+ } else if (changedLocal && !changedRemote) {
1287
+ if (l !== null) merged[p] = l
1288
+ } else if (!changedLocal && changedRemote) {
1289
+ if (r !== null) merged[p] = r
1290
+ } else {
1291
+ if (b !== null) merged[p] = b
1446
1292
  }
1447
- if (conflicts.length > 0) {
1448
- spinnerFail(spinner, 'Push blocked by conflicts')
1449
-
1450
- // Write conflict markers to local files
1293
+ }
1294
+ if (conflicts.length > 0) {
1295
+ if (opts.json === 'true') {
1296
+ print({ conflicts }, true)
1297
+ } else {
1298
+ process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
1299
+ process.stderr.write(color('Conflicts detected:\n', 'yellow'))
1451
1300
  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')
1301
+ process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
1468
1302
  }
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
- }
1533
-
1534
- const body = {
1535
- message: localMeta.pendingCommit?.message || (opts.message || 'Push'),
1536
- files: commitFiles,
1537
- branchName: remoteMeta.branch
1538
1303
  }
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
1304
+ return
1557
1305
  }
1306
+ const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
1307
+ const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
1308
+ const data = await request('POST', url, body, token)
1309
+ localMeta.baseCommitId = data.id || remote.commitId || ''
1310
+ localMeta.baseFiles = merged
1311
+ localMeta.pendingCommit = null
1312
+ await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
1313
+ print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
1558
1314
  }
1559
1315
 
1560
1316
  async function cmdMerge(opts) {
@@ -1896,20 +1652,7 @@ async function cmdBranch(sub, opts) {
1896
1652
  if (m) current = m[1]
1897
1653
  } catch { }
1898
1654
  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
-
1655
+ const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
1913
1656
  for (const b of list) {
1914
1657
  const isCur = b.name === current
1915
1658
  const mark = isCur ? color('*', 'green') : ' '
@@ -1933,17 +1676,12 @@ async function cmdBranch(sub, opts) {
1933
1676
  if (sub === 'delete') {
1934
1677
  const name = opts.name
1935
1678
  if (!name) throw new Error('Missing --name')
1936
- const force = opts.force === 'true' || opts.D === 'true'
1937
1679
  const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
1938
1680
  u.searchParams.set('name', name)
1939
- u.searchParams.set('force', force ? 'true' : 'false')
1940
1681
  const headers = token ? { Authorization: `Bearer ${token}` } : {}
1941
1682
  const res = await fetch(u.toString(), { method: 'DELETE', headers })
1942
1683
  if (!res.ok) {
1943
1684
  const body = await res.text().catch(() => '')
1944
- if (force) {
1945
- throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
1946
- }
1947
1685
  throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
1948
1686
  }
1949
1687
  const data = await res.json()
@@ -1972,24 +1710,10 @@ async function cmdSwitch(opts) {
1972
1710
  const dir = path.resolve(opts.dir || '.')
1973
1711
  const meta = readRemoteMeta(dir)
1974
1712
  const branch = opts.branch
1975
- const create = opts.create === 'true' || opts.c === 'true'
1976
1713
  if (!branch) throw new Error('Missing --branch')
1977
1714
  const cfg = loadConfig()
1978
1715
  const server = getServer(opts, cfg) || meta.server
1979
1716
  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
1717
  await pullToDir(meta.repoId, branch, dir, server, token)
1994
1718
  print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
1995
1719
  }
@@ -2344,28 +2068,10 @@ async function cmdReset(opts) {
2344
2068
  const server = getServer(opts, cfg) || meta.server
2345
2069
  const token = getToken(opts, cfg) || meta.token
2346
2070
 
2347
- let commitId = opts.commit || 'HEAD'
2071
+ const commitId = opts.commit || 'HEAD'
2348
2072
  const mode = opts.mode || 'mixed' // soft, mixed, hard
2349
2073
  const filePath = opts.path
2350
2074
 
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
2075
  if (filePath) {
2370
2076
  // Reset specific file (unstage)
2371
2077
  const metaDir = path.join(dir, '.vcs-next')
@@ -2436,78 +2142,14 @@ async function cmdReset(opts) {
2436
2142
  }
2437
2143
 
2438
2144
  async function cmdInit(opts) {
2145
+ const dir = path.resolve(opts.dir || '.')
2439
2146
  const cfg = loadConfig()
2440
2147
  const server = getServer(opts, cfg)
2441
2148
  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')
2149
+ const repo = opts.repo || ''
2150
+ const branch = opts.branch || 'main'
2151
+ const metaDir = path.join(dir, '.vcs-next')
2152
+ const gitDir = path.join(dir, '.git')
2511
2153
  await fs.promises.mkdir(metaDir, { recursive: true })
2512
2154
  await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
2513
2155
  await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
@@ -2521,47 +2163,16 @@ async function cmdInit(opts) {
2521
2163
  '',
2522
2164
  '[vcs-next]',
2523
2165
  `\tserver = ${opts.server || server || ''}`,
2524
- `\trepoId = ${repoId}`,
2166
+ `\trepoId = ${repo}`,
2525
2167
  `\tbranch = ${branch}`,
2526
2168
  `\ttoken = ${opts.token || token || ''}`
2527
2169
  ].join('\n')
2528
2170
  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 || '' }
2171
+ const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
2553
2172
  await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
2554
- const localMeta = { baseCommitId: remoteCommitId, baseFiles: baseFiles, pendingCommit: null }
2173
+ const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
2555
2174
  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')
2175
+ print({ initialized: dir, branch }, opts.json === 'true')
2565
2176
  }
2566
2177
 
2567
2178
  async function cmdMv(opts) {
@@ -2831,400 +2442,6 @@ async function cmdRebase(opts) {
2831
2442
  print({ rebased: sourceBranch, onto }, opts.json === 'true')
2832
2443
  }
2833
2444
  }
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
2445
  function help() {
3229
2446
  const h = [
3230
2447
  'Usage: resulgit <group> <command> [options]',
@@ -3239,37 +2456,26 @@ function help() {
3239
2456
  ' repo log --repo <id> [--branch <name>] [--json]',
3240
2457
  ' repo head --repo <id> [--branch <name>] [--json]',
3241
2458
  ' 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>]',
2459
+ ' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
2460
+ ' switch --branch <name> [--dir <path>]',
3245
2461
  ' current [--dir <path>] (show active repo/branch)',
3246
2462
  ' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
3247
2463
  ' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
3248
2464
  ' 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]',
2465
+ ' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
2466
+ ' commit --message <text> [--dir <path>] [--json]',
3251
2467
  ' push [--dir <path>] [--json]',
3252
2468
  ' head [--dir <path>] [--json]',
3253
- ' rm --path <file> [--cached] [--dir <path>] [--json]',
2469
+ ' rm --path <file> [--dir <path>] [--json]',
3254
2470
  ' pull [--dir <path>]',
3255
2471
  ' fetch [--dir <path>]',
3256
2472
  ' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
3257
2473
  ' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
3258
2474
  ' restore --path <file> [--source <commit>] [--dir <path>]',
3259
2475
  ' revert --commit <id> [--no-push] [--dir <path>]',
3260
- ' reset [--commit <id>|HEAD^] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
2476
+ ' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
3261
2477
  ' show --commit <id> [--dir <path>] [--json]',
3262
2478
  ' 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
2479
  '',
3274
2480
  'Conflict Resolution:',
3275
2481
  ' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
@@ -3278,8 +2484,8 @@ function help() {
3278
2484
  ' >>>>>>> incoming (incoming changes)',
3279
2485
  ' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
3280
2486
  ' 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>]',
2487
+ ' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
2488
+ ' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
3283
2489
  ' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
3284
2490
  ' config list|get|set [--key <k>] [--value <v>]',
3285
2491
  ' clean [--dir <path>] [--force]',
@@ -3312,20 +2518,13 @@ async function main() {
3312
2518
  return
3313
2519
  }
3314
2520
  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
- }
2521
+ if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
3323
2522
  if (opts.url) {
3324
- await cmdCloneFromUrl(opts, cfg);
2523
+ await cmdCloneFromUrl(opts, cfg)
3325
2524
  } else {
3326
- await cmdClone(opts, cfg);
2525
+ await cmdClone(opts, cfg)
3327
2526
  }
3328
- return;
2527
+ return
3329
2528
  }
3330
2529
  if (cmd[0] === 'status') {
3331
2530
  await cmdStatus(opts)
@@ -3356,18 +2555,10 @@ async function main() {
3356
2555
  return
3357
2556
  }
3358
2557
  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
2558
  await cmdBranch(cmd[1], opts)
3364
2559
  return
3365
2560
  }
3366
2561
  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
2562
  await cmdSwitch(opts)
3372
2563
  return
3373
2564
  }
@@ -3380,10 +2571,6 @@ async function main() {
3380
2571
  return
3381
2572
  }
3382
2573
  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
2574
  await cmdMerge(opts)
3388
2575
  return
3389
2576
  }
@@ -3412,12 +2599,8 @@ async function main() {
3412
2599
  return
3413
2600
  }
3414
2601
  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;
2602
+ await cmdInit(opts)
2603
+ return
3421
2604
  }
3422
2605
  if (cmd[0] === 'remote') {
3423
2606
  await cmdRemote(cmd[1], opts)
@@ -3467,46 +2650,6 @@ async function main() {
3467
2650
  await cmdAdd(opts)
3468
2651
  return
3469
2652
  }
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
2653
  throw new Error('Unknown command')
3511
2654
  }
3512
2655
 
@@ -3621,6 +2764,7 @@ async function copyDir(src, dest) {
3621
2764
  }
3622
2765
  }
3623
2766
 
2767
+
3624
2768
  async function tuiSelectBranch(branches, current) {
3625
2769
  const items = (branches || []).map((b) => ({ name: b.name || '', commitId: b.commitId || '' })).filter((b) => b.name)
3626
2770
  if (items.length === 0) throw new Error('No branches available')