resulgit 1.0.1 → 1.0.3
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.
- package/README.md +40 -1
- package/package.json +14 -7
- package/resulgit.js +1440 -370
package/resulgit.js
CHANGED
|
@@ -3,9 +3,32 @@ 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')
|
|
6
12
|
const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
|
|
7
13
|
function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
|
|
8
14
|
|
|
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
|
+
|
|
9
32
|
function parseArgs(argv) {
|
|
10
33
|
const tokens = argv.slice(2)
|
|
11
34
|
const cmd = []
|
|
@@ -106,11 +129,17 @@ async function cmdAuth(sub, opts) {
|
|
|
106
129
|
const password = opts.password
|
|
107
130
|
if (!email || !password) throw new Error('Missing --email and --password')
|
|
108
131
|
const url = new URL('/api/auth/login', server).toString()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
const spinner = createSpinner('Logging in...', opts.json)
|
|
133
|
+
try {
|
|
134
|
+
const res = await request('POST', url, { email, password }, '')
|
|
135
|
+
const token = res.token || ''
|
|
136
|
+
if (token) saveConfig({ token })
|
|
137
|
+
spinnerSuccess(spinner, `Logged in as ${email}`)
|
|
138
|
+
print(res, opts.json === 'true')
|
|
139
|
+
} catch (err) {
|
|
140
|
+
spinnerFail(spinner, 'Login failed')
|
|
141
|
+
throw err
|
|
142
|
+
}
|
|
114
143
|
return
|
|
115
144
|
}
|
|
116
145
|
if (sub === 'register') {
|
|
@@ -121,10 +150,17 @@ async function cmdAuth(sub, opts) {
|
|
|
121
150
|
const displayName = opts.displayName || username
|
|
122
151
|
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
123
152
|
const url = new URL('/api/auth/register', server).toString()
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
153
|
+
const spinner = createSpinner('Registering account...', opts.json)
|
|
154
|
+
try {
|
|
155
|
+
const res = await request('POST', url, { username, email, password, displayName }, '')
|
|
156
|
+
const token = res.token || ''
|
|
157
|
+
if (token) saveConfig({ token })
|
|
158
|
+
spinnerSuccess(spinner, `Registered as ${username}`)
|
|
159
|
+
print(res, opts.json === 'true')
|
|
160
|
+
} catch (err) {
|
|
161
|
+
spinnerFail(spinner, 'Registration failed')
|
|
162
|
+
throw err
|
|
163
|
+
}
|
|
128
164
|
return
|
|
129
165
|
}
|
|
130
166
|
throw new Error('Unknown auth subcommand')
|
|
@@ -134,10 +170,17 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
134
170
|
const server = getServer(opts, cfg)
|
|
135
171
|
const token = getToken(opts, cfg)
|
|
136
172
|
if (sub === 'list') {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
const spinner = createSpinner('Fetching repositories...', opts.json)
|
|
174
|
+
try {
|
|
175
|
+
const url = new URL('/api/repositories', server).toString()
|
|
176
|
+
const data = await request('GET', url, null, token)
|
|
177
|
+
spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
|
|
178
|
+
print(data, opts.json === 'true')
|
|
179
|
+
await tuiSelectRepo(data || [], cfg, opts)
|
|
180
|
+
} catch (err) {
|
|
181
|
+
spinnerFail(spinner, 'Failed to fetch repositories')
|
|
182
|
+
throw err
|
|
183
|
+
}
|
|
141
184
|
return
|
|
142
185
|
}
|
|
143
186
|
if (sub === 'create') {
|
|
@@ -148,14 +191,21 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
148
191
|
initializeWithReadme: opts.init === 'true'
|
|
149
192
|
}
|
|
150
193
|
if (!body.name) throw new Error('Missing --name')
|
|
151
|
-
const
|
|
152
|
-
const data = await request('POST', url, body, token)
|
|
153
|
-
print(data, opts.json === 'true')
|
|
194
|
+
const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
|
|
154
195
|
try {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
196
|
+
const url = new URL('/api/repositories', server).toString()
|
|
197
|
+
const data = await request('POST', url, body, token)
|
|
198
|
+
spinnerSuccess(spinner, `Repository '${body.name}' created`)
|
|
199
|
+
print(data, opts.json === 'true')
|
|
200
|
+
try {
|
|
201
|
+
const repoId = String(data.id || '')
|
|
202
|
+
const branch = String(data.defaultBranch || 'main')
|
|
203
|
+
if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
|
|
204
|
+
} catch { }
|
|
205
|
+
} catch (err) {
|
|
206
|
+
spinnerFail(spinner, `Failed to create repository '${body.name}'`)
|
|
207
|
+
throw err
|
|
208
|
+
}
|
|
159
209
|
return
|
|
160
210
|
}
|
|
161
211
|
if (sub === 'log') {
|
|
@@ -224,91 +274,105 @@ async function cmdClone(opts, cfg) {
|
|
|
224
274
|
const branch = opts.branch
|
|
225
275
|
let dest = opts.dest
|
|
226
276
|
if (!repo || !branch) throw new Error('Missing --repo and --branch')
|
|
277
|
+
const spinner = createSpinner('Initializing clone...', opts.json)
|
|
227
278
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
279
|
+
try {
|
|
280
|
+
if (!dest) {
|
|
281
|
+
spinnerUpdate(spinner, 'Fetching repository info...')
|
|
282
|
+
try {
|
|
283
|
+
const infoUrl = new URL(`/api/repositories/${repo}`, server)
|
|
284
|
+
const infoRes = await fetch(infoUrl.toString(), { headers })
|
|
285
|
+
if (infoRes.ok) {
|
|
286
|
+
const info = await infoRes.json()
|
|
287
|
+
const name = info.name || String(repo)
|
|
288
|
+
dest = name
|
|
289
|
+
} else {
|
|
290
|
+
dest = String(repo)
|
|
291
|
+
}
|
|
292
|
+
} catch {
|
|
237
293
|
dest = String(repo)
|
|
238
294
|
}
|
|
239
|
-
} catch {
|
|
240
|
-
dest = String(repo)
|
|
241
295
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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))
|
|
296
|
+
dest = path.resolve(dest)
|
|
297
|
+
spinnerUpdate(spinner, `Downloading snapshot from branch '${branch}'...`)
|
|
298
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
299
|
+
url.searchParams.set('branch', branch)
|
|
300
|
+
const res = await fetch(url.toString(), { headers })
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
const text = await res.text()
|
|
303
|
+
throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
|
|
300
304
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
await fs.promises.
|
|
305
|
+
const data = await res.json()
|
|
306
|
+
const files = data.files || {}
|
|
307
|
+
const fileCount = Object.keys(files).length
|
|
308
|
+
spinnerUpdate(spinner, `Writing ${fileCount} files to ${dest}...`)
|
|
309
|
+
const root = dest
|
|
310
|
+
for (const [p, content] of Object.entries(files)) {
|
|
311
|
+
const fullPath = path.join(root, p)
|
|
312
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
313
|
+
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
309
314
|
}
|
|
310
|
-
|
|
311
|
-
|
|
315
|
+
spinnerUpdate(spinner, 'Setting up repository metadata...')
|
|
316
|
+
const metaDir = path.join(root, '.vcs-next')
|
|
317
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
318
|
+
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
319
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
320
|
+
const gitDir = path.join(root, '.git')
|
|
321
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
322
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
323
|
+
const headContent = `ref: refs/heads/${branch}\\n`
|
|
324
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
325
|
+
const commitId = data.commitId || ''
|
|
326
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
327
|
+
const gitConfig = [
|
|
328
|
+
'[core]',
|
|
329
|
+
'\\trepositoryformatversion = 0',
|
|
330
|
+
'\\tfilemode = true',
|
|
331
|
+
'\\tbare = false',
|
|
332
|
+
'\\tlogallrefupdates = true',
|
|
333
|
+
'',
|
|
334
|
+
'[vcs-next]',
|
|
335
|
+
`\\tserver = ${server}`,
|
|
336
|
+
`\\trepoId = ${repo}`,
|
|
337
|
+
`\\tbranch = ${branch}`,
|
|
338
|
+
`\\ttoken = ${token || ''}`
|
|
339
|
+
].join('\\n')
|
|
340
|
+
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
341
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
|
|
342
|
+
spinnerUpdate(spinner, 'Fetching branch information...')
|
|
343
|
+
try {
|
|
344
|
+
const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
|
|
345
|
+
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
346
|
+
if (branchesRes.ok) {
|
|
347
|
+
const branchesData = await branchesRes.json()
|
|
348
|
+
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
349
|
+
const allRefs = {}
|
|
350
|
+
for (const b of branches) {
|
|
351
|
+
const name = b.name || ''
|
|
352
|
+
const id = b.commitId || ''
|
|
353
|
+
if (!name) continue
|
|
354
|
+
allRefs[name] = id
|
|
355
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
356
|
+
}
|
|
357
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
358
|
+
}
|
|
359
|
+
} catch { }
|
|
360
|
+
spinnerUpdate(spinner, 'Fetching commit history...')
|
|
361
|
+
try {
|
|
362
|
+
const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
|
|
363
|
+
commitsUrl.searchParams.set('branch', branch)
|
|
364
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
365
|
+
if (commitsRes.ok) {
|
|
366
|
+
const commitsList = await commitsRes.json()
|
|
367
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
368
|
+
}
|
|
369
|
+
} catch { }
|
|
370
|
+
spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
|
|
371
|
+
print('Clone complete', opts.json === 'true')
|
|
372
|
+
} catch (err) {
|
|
373
|
+
spinnerFail(spinner, 'Clone failed')
|
|
374
|
+
throw err
|
|
375
|
+
}
|
|
312
376
|
}
|
|
313
377
|
|
|
314
378
|
function readRemoteMeta(dir) {
|
|
@@ -324,7 +388,7 @@ function readRemoteMeta(dir) {
|
|
|
324
388
|
const s = fs.readFileSync(fp, 'utf8')
|
|
325
389
|
const meta = JSON.parse(s)
|
|
326
390
|
if (meta.repoId && meta.branch) return meta
|
|
327
|
-
} catch {}
|
|
391
|
+
} catch { }
|
|
328
392
|
}
|
|
329
393
|
const parent = path.dirname(cur)
|
|
330
394
|
if (parent === cur) break
|
|
@@ -421,7 +485,7 @@ async function cmdRestore(opts) {
|
|
|
421
485
|
const cfg = loadConfig()
|
|
422
486
|
const server = getServer(opts, cfg) || meta.server
|
|
423
487
|
const token = getToken(opts, cfg) || meta.token
|
|
424
|
-
|
|
488
|
+
|
|
425
489
|
const sourceCommit = opts.source || 'HEAD'
|
|
426
490
|
let sourceSnap
|
|
427
491
|
if (sourceCommit === 'HEAD') {
|
|
@@ -429,7 +493,7 @@ async function cmdRestore(opts) {
|
|
|
429
493
|
} else {
|
|
430
494
|
sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
|
|
431
495
|
}
|
|
432
|
-
|
|
496
|
+
|
|
433
497
|
const sourceContent = sourceSnap.files[filePath]
|
|
434
498
|
if (sourceContent === undefined) {
|
|
435
499
|
// File doesn't exist in source - delete it
|
|
@@ -464,18 +528,119 @@ async function cmdDiff(opts) {
|
|
|
464
528
|
const cfg = loadConfig()
|
|
465
529
|
const server = getServer(opts, cfg) || meta.server
|
|
466
530
|
const token = getToken(opts, cfg) || meta.token
|
|
467
|
-
|
|
531
|
+
|
|
468
532
|
const filePath = opts.path
|
|
469
533
|
const commitId = opts.commit
|
|
470
|
-
|
|
534
|
+
const commit1 = opts.commit1
|
|
535
|
+
const commit2 = opts.commit2
|
|
536
|
+
const showStat = opts.stat === 'true'
|
|
537
|
+
|
|
538
|
+
// Handle diff between two commits: git diff <commit1> <commit2>
|
|
539
|
+
if (commit1 && commit2) {
|
|
540
|
+
const snap1 = await fetchSnapshotByCommit(server, meta.repoId, commit1, token)
|
|
541
|
+
const snap2 = await fetchSnapshotByCommit(server, meta.repoId, commit2, token)
|
|
542
|
+
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(snap1.files), ...Object.keys(snap2.files)]))
|
|
543
|
+
|
|
544
|
+
let added = 0, deleted = 0, modified = 0
|
|
545
|
+
const stats = []
|
|
546
|
+
|
|
547
|
+
for (const p of files) {
|
|
548
|
+
const content1 = snap1.files[p] !== undefined ? String(snap1.files[p]) : null
|
|
549
|
+
const content2 = snap2.files[p] !== undefined ? String(snap2.files[p]) : null
|
|
550
|
+
if (content1 !== content2) {
|
|
551
|
+
if (content1 === null) {
|
|
552
|
+
added++
|
|
553
|
+
if (showStat) stats.push({ path: p, added: content2.split(/\r?\n/).length, deleted: 0 })
|
|
554
|
+
} else if (content2 === null) {
|
|
555
|
+
deleted++
|
|
556
|
+
if (showStat) stats.push({ path: p, added: 0, deleted: content1.split(/\r?\n/).length })
|
|
557
|
+
} else {
|
|
558
|
+
modified++
|
|
559
|
+
const lines1 = content1.split(/\r?\n/)
|
|
560
|
+
const lines2 = content2.split(/\r?\n/)
|
|
561
|
+
const diff = Math.abs(lines2.length - lines1.length)
|
|
562
|
+
if (showStat) stats.push({ path: p, added: lines2.length > lines1.length ? diff : 0, deleted: lines1.length > lines2.length ? diff : 0 })
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (!showStat) {
|
|
566
|
+
if (opts.json === 'true') {
|
|
567
|
+
print({ path: p, old: content1, new: content2 }, true)
|
|
568
|
+
} else {
|
|
569
|
+
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
|
|
570
|
+
// Show full diff (same as before)
|
|
571
|
+
const oldLines = content1 ? content1.split(/\r?\n/) : []
|
|
572
|
+
const newLines = content2 ? content2.split(/\r?\n/) : []
|
|
573
|
+
const maxLen = Math.max(oldLines.length, newLines.length)
|
|
574
|
+
for (let i = 0; i < maxLen; i++) {
|
|
575
|
+
const oldLine = oldLines[i]
|
|
576
|
+
const newLine = newLines[i]
|
|
577
|
+
if (oldLine !== newLine) {
|
|
578
|
+
if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
|
|
579
|
+
if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
|
|
580
|
+
} else if (oldLine !== undefined) {
|
|
581
|
+
process.stdout.write(` ${oldLine}\n`)
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (showStat) {
|
|
590
|
+
if (opts.json === 'true') {
|
|
591
|
+
print({ added, deleted, modified, files: stats }, true)
|
|
592
|
+
} else {
|
|
593
|
+
for (const stat of stats) {
|
|
594
|
+
process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
|
|
595
|
+
}
|
|
596
|
+
process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
|
|
471
602
|
if (commitId) {
|
|
472
603
|
// Show diff for specific commit
|
|
473
604
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
474
605
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
475
606
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
476
607
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
477
|
-
|
|
608
|
+
|
|
478
609
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
|
|
610
|
+
|
|
611
|
+
if (showStat) {
|
|
612
|
+
let added = 0, deleted = 0, modified = 0
|
|
613
|
+
const stats = []
|
|
614
|
+
for (const p of files) {
|
|
615
|
+
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
616
|
+
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
617
|
+
if (oldContent !== newContent) {
|
|
618
|
+
if (oldContent === null) {
|
|
619
|
+
added++
|
|
620
|
+
if (showStat) stats.push({ path: p, added: newContent.split(/\r?\n/).length, deleted: 0 })
|
|
621
|
+
} else if (newContent === null) {
|
|
622
|
+
deleted++
|
|
623
|
+
if (showStat) stats.push({ path: p, added: 0, deleted: oldContent.split(/\r?\n/).length })
|
|
624
|
+
} else {
|
|
625
|
+
modified++
|
|
626
|
+
const oldLines = oldContent.split(/\r?\n/)
|
|
627
|
+
const newLines = newContent.split(/\r?\n/)
|
|
628
|
+
const diff = Math.abs(newLines.length - oldLines.length)
|
|
629
|
+
if (showStat) stats.push({ path: p, added: newLines.length > oldLines.length ? diff : 0, deleted: oldLines.length > newLines.length ? diff : 0 })
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
if (opts.json === 'true') {
|
|
634
|
+
print({ added, deleted, modified, files: stats }, true)
|
|
635
|
+
} else {
|
|
636
|
+
for (const stat of stats) {
|
|
637
|
+
process.stdout.write(` ${stat.path} | ${stat.added + stat.deleted} ${stat.added > 0 ? color('+' + stat.added, 'green') : ''}${stat.deleted > 0 ? color('-' + stat.deleted, 'red') : ''}\n`)
|
|
638
|
+
}
|
|
639
|
+
process.stdout.write(` ${stats.length} file${stats.length !== 1 ? 's' : ''} changed, ${added} insertion${added !== 1 ? 's' : ''}(+), ${deleted} deletion${deleted !== 1 ? 's' : ''}(-)\n`)
|
|
640
|
+
}
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
|
|
479
644
|
for (const p of files) {
|
|
480
645
|
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
481
646
|
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
@@ -510,18 +675,18 @@ async function cmdDiff(opts) {
|
|
|
510
675
|
}
|
|
511
676
|
return
|
|
512
677
|
}
|
|
513
|
-
|
|
678
|
+
|
|
514
679
|
// Show diff for working directory
|
|
515
680
|
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
516
681
|
const local = await collectLocal(dir)
|
|
517
682
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
|
|
518
|
-
|
|
683
|
+
|
|
519
684
|
for (const p of files) {
|
|
520
685
|
const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
|
|
521
686
|
const localContent = local[p]?.content || null
|
|
522
687
|
const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
|
|
523
688
|
const localId = local[p]?.id
|
|
524
|
-
|
|
689
|
+
|
|
525
690
|
if (remoteId !== localId) {
|
|
526
691
|
if (opts.json === 'true') {
|
|
527
692
|
print({ path: p, remote: remoteContent, local: localContent }, true)
|
|
@@ -561,6 +726,31 @@ async function cmdRm(opts) {
|
|
|
561
726
|
const cfg = loadConfig()
|
|
562
727
|
const server = getServer(opts, cfg) || meta.server
|
|
563
728
|
const token = getToken(opts, cfg) || meta.token
|
|
729
|
+
|
|
730
|
+
// Handle --cached flag (remove from index but keep file)
|
|
731
|
+
if (opts.cached === 'true') {
|
|
732
|
+
// Mark file for deletion in next commit but don't delete from filesystem
|
|
733
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
734
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
735
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null, removedFiles: [] }
|
|
736
|
+
try {
|
|
737
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
738
|
+
localMeta = JSON.parse(s)
|
|
739
|
+
} catch {}
|
|
740
|
+
if (!localMeta.removedFiles) localMeta.removedFiles = []
|
|
741
|
+
if (!localMeta.removedFiles.includes(pathArg)) {
|
|
742
|
+
localMeta.removedFiles.push(pathArg)
|
|
743
|
+
}
|
|
744
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
745
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
746
|
+
if (opts.json === 'true') {
|
|
747
|
+
print({ removed: pathArg, cached: true }, true)
|
|
748
|
+
} else {
|
|
749
|
+
process.stdout.write(color(`Removed '${pathArg}' from index (file kept in working directory)\n`, 'green'))
|
|
750
|
+
}
|
|
751
|
+
return
|
|
752
|
+
}
|
|
753
|
+
|
|
564
754
|
const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
|
|
565
755
|
u.searchParams.set('branch', meta.branch)
|
|
566
756
|
u.searchParams.set('path', pathArg)
|
|
@@ -578,41 +768,94 @@ async function cmdCommit(opts) {
|
|
|
578
768
|
const dir = path.resolve(opts.dir || '.')
|
|
579
769
|
const message = opts.message || ''
|
|
580
770
|
if (!message) throw new Error('Missing --message')
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
if (opts.
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
771
|
+
|
|
772
|
+
const spinner = createSpinner('Preparing commit...', opts.json)
|
|
773
|
+
try {
|
|
774
|
+
// Handle -am flag (add all and commit)
|
|
775
|
+
if (opts.all === 'true' || opts.a === 'true') {
|
|
776
|
+
spinnerUpdate(spinner, 'Staging all changes...')
|
|
777
|
+
await cmdAdd({ dir, all: 'true', json: opts.json })
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Handle --amend flag
|
|
781
|
+
if (opts.amend === 'true') {
|
|
782
|
+
spinnerUpdate(spinner, 'Amending last commit...')
|
|
783
|
+
// Get the last commit and use its message if no new message provided
|
|
784
|
+
const meta = readRemoteMeta(dir)
|
|
785
|
+
const cfg = loadConfig()
|
|
786
|
+
const server = getServer(opts, cfg) || meta.server
|
|
787
|
+
const token = getToken(opts, cfg) || meta.token
|
|
788
|
+
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
789
|
+
url.searchParams.set('branch', meta.branch)
|
|
790
|
+
url.searchParams.set('limit', '1')
|
|
791
|
+
const commits = await request('GET', url.toString(), null, token)
|
|
792
|
+
if (Array.isArray(commits) && commits.length > 0 && !message) {
|
|
793
|
+
opts.message = commits[0].message || 'Amended commit'
|
|
592
794
|
}
|
|
593
|
-
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
594
795
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
796
|
+
|
|
797
|
+
// Check for unresolved conflicts
|
|
798
|
+
spinnerUpdate(spinner, 'Checking for conflicts...')
|
|
799
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
800
|
+
if (unresolvedConflicts.length > 0) {
|
|
801
|
+
spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
|
|
802
|
+
if (opts.json === 'true') {
|
|
803
|
+
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
804
|
+
} else {
|
|
805
|
+
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
806
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
807
|
+
for (const p of unresolvedConflicts) {
|
|
808
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
809
|
+
}
|
|
810
|
+
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
811
|
+
}
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
spinnerUpdate(spinner, 'Collecting changes...')
|
|
816
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
817
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
818
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
819
|
+
try {
|
|
820
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
821
|
+
localMeta = JSON.parse(s)
|
|
822
|
+
} catch { }
|
|
823
|
+
const local = await collectLocal(dir)
|
|
824
|
+
const files = {}
|
|
825
|
+
for (const [p, v] of Object.entries(local)) files[p] = v.content
|
|
826
|
+
|
|
827
|
+
// Execute pre-commit hook
|
|
828
|
+
spinnerUpdate(spinner, 'Running pre-commit hook...')
|
|
829
|
+
try {
|
|
830
|
+
const hookResult = await hooks.executeHook(dir, 'pre-commit', { message, files: Object.keys(files) })
|
|
831
|
+
if (hookResult.executed && hookResult.exitCode !== 0) {
|
|
832
|
+
spinnerFail(spinner, 'Pre-commit hook failed')
|
|
833
|
+
throw new Error('pre-commit hook failed')
|
|
834
|
+
}
|
|
835
|
+
} catch (err) {
|
|
836
|
+
if (err.message === 'pre-commit hook failed') throw err
|
|
837
|
+
// Hook doesn't exist or other error, continue
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
localMeta.pendingCommit = { message, files, createdAt: Date.now() }
|
|
841
|
+
// Clear conflicts if they were resolved
|
|
842
|
+
if (localMeta.conflicts) {
|
|
843
|
+
delete localMeta.conflicts
|
|
844
|
+
}
|
|
845
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
846
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
847
|
+
|
|
848
|
+
// Execute post-commit hook
|
|
849
|
+
try {
|
|
850
|
+
await hooks.executeHook(dir, 'post-commit', { message, files: Object.keys(files) })
|
|
851
|
+
} catch {}
|
|
852
|
+
|
|
853
|
+
spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
|
|
854
|
+
print({ pendingCommit: message }, opts.json === 'true')
|
|
855
|
+
} catch (err) {
|
|
856
|
+
spinnerFail(spinner, 'Commit failed')
|
|
857
|
+
throw err
|
|
612
858
|
}
|
|
613
|
-
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
614
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
615
|
-
print({ pendingCommit: message }, opts.json === 'true')
|
|
616
859
|
}
|
|
617
860
|
|
|
618
861
|
async function pullToDir(repo, branch, dir, server, token) {
|
|
@@ -637,7 +880,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
637
880
|
for (const rel of Object.keys(localMap)) {
|
|
638
881
|
if (!keep.has(rel)) {
|
|
639
882
|
const fp = path.join(root, rel)
|
|
640
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
883
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
641
884
|
}
|
|
642
885
|
}
|
|
643
886
|
const pruneEmptyDirs = async (start) => {
|
|
@@ -650,7 +893,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
650
893
|
if (st.isDirectory()) {
|
|
651
894
|
await pruneEmptyDirs(p)
|
|
652
895
|
const left = await fs.promises.readdir(p).catch(() => [])
|
|
653
|
-
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
|
|
896
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
|
|
654
897
|
}
|
|
655
898
|
}
|
|
656
899
|
}
|
|
@@ -676,29 +919,83 @@ async function cmdPull(opts) {
|
|
|
676
919
|
const cfg = loadConfig()
|
|
677
920
|
const server = getServer(opts, cfg) || meta.server
|
|
678
921
|
const token = getToken(opts, cfg) || meta.token
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const res = await fetch(url.toString(), { headers })
|
|
688
|
-
if (!res.ok) {
|
|
689
|
-
const text = await res.text()
|
|
690
|
-
throw new Error(text || 'snapshot failed')
|
|
922
|
+
const spinner = createSpinner(`Pulling from branch '${meta.branch}'...`, opts.json)
|
|
923
|
+
try {
|
|
924
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
925
|
+
spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
|
|
926
|
+
print('Pull complete', opts.json === 'true')
|
|
927
|
+
} catch (err) {
|
|
928
|
+
spinnerFail(spinner, 'Pull failed')
|
|
929
|
+
throw err
|
|
691
930
|
}
|
|
692
|
-
const data = await res.json()
|
|
693
|
-
return { files: data.files || {}, commitId: data.commitId || '' }
|
|
694
931
|
}
|
|
695
932
|
|
|
696
|
-
async function
|
|
933
|
+
async function cmdFetch(opts) {
|
|
934
|
+
const dir = path.resolve(opts.dir || '.')
|
|
935
|
+
const meta = readRemoteMeta(dir)
|
|
936
|
+
const cfg = loadConfig()
|
|
937
|
+
const server = getServer(opts, cfg) || meta.server
|
|
938
|
+
const token = getToken(opts, cfg) || meta.token
|
|
697
939
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
940
|
+
const gitDir = path.join(dir, '.git')
|
|
941
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
942
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
943
|
+
try {
|
|
944
|
+
const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
945
|
+
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
946
|
+
if (branchesRes.ok) {
|
|
947
|
+
const branchesData = await branchesRes.json()
|
|
948
|
+
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
949
|
+
const allRefs = {}
|
|
950
|
+
for (const b of branches) {
|
|
951
|
+
const name = b.name || ''
|
|
952
|
+
const id = b.commitId || ''
|
|
953
|
+
if (!name) continue
|
|
954
|
+
allRefs[name] = id
|
|
955
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
956
|
+
}
|
|
957
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
958
|
+
const curId = allRefs[meta.branch] || ''
|
|
959
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
960
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
961
|
+
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
962
|
+
let remoteMeta = meta
|
|
963
|
+
try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
|
|
964
|
+
remoteMeta.commitId = curId
|
|
965
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
966
|
+
}
|
|
967
|
+
} catch { }
|
|
968
|
+
try {
|
|
969
|
+
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
970
|
+
commitsUrl.searchParams.set('branch', meta.branch)
|
|
971
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
972
|
+
if (commitsRes.ok) {
|
|
973
|
+
const commitsList = await commitsRes.json()
|
|
974
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
975
|
+
}
|
|
976
|
+
} catch { }
|
|
977
|
+
print('Fetch complete', opts.json === 'true')
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
async function fetchRemoteSnapshot(server, repo, branch, token) {
|
|
981
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
982
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
983
|
+
if (branch) url.searchParams.set('branch', branch)
|
|
984
|
+
const res = await fetch(url.toString(), { headers })
|
|
985
|
+
if (!res.ok) {
|
|
986
|
+
const text = await res.text()
|
|
987
|
+
throw new Error(text || 'snapshot failed')
|
|
988
|
+
}
|
|
989
|
+
const data = await res.json()
|
|
990
|
+
return { files: data.files || {}, commitId: data.commitId || '' }
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function fetchSnapshotByCommit(server, repo, commitId, token) {
|
|
994
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
995
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
996
|
+
url.searchParams.set('commitId', commitId)
|
|
997
|
+
const res = await fetch(url.toString(), { headers })
|
|
998
|
+
if (!res.ok) {
|
|
702
999
|
const text = await res.text()
|
|
703
1000
|
throw new Error(text || 'snapshot failed')
|
|
704
1001
|
}
|
|
@@ -748,57 +1045,57 @@ async function cmdCherryPick(opts) {
|
|
|
748
1045
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
749
1046
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
750
1047
|
if (m) current = m[1]
|
|
751
|
-
} catch {}
|
|
1048
|
+
} catch { }
|
|
752
1049
|
targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
|
|
753
1050
|
}
|
|
754
1051
|
const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
|
|
755
1052
|
if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
|
|
756
|
-
|
|
1053
|
+
|
|
757
1054
|
// Get the current state of the target branch (what we're cherry-picking onto)
|
|
758
1055
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
|
|
759
|
-
|
|
1056
|
+
|
|
760
1057
|
// Get the commit being cherry-picked and its parent
|
|
761
1058
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
762
1059
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
763
1060
|
const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
764
1061
|
const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
765
|
-
|
|
1062
|
+
|
|
766
1063
|
// Pull the target branch to ensure we're working with the latest state
|
|
767
1064
|
await pullToDir(meta.repoId, targetBranch, dir, server, token)
|
|
768
|
-
|
|
1065
|
+
|
|
769
1066
|
// Re-collect local state after pull (now it matches the target branch)
|
|
770
1067
|
const localAfterPull = await collectLocal(dir)
|
|
771
|
-
|
|
1068
|
+
|
|
772
1069
|
// Update local metadata to reflect the pulled state
|
|
773
1070
|
const metaDirInit = path.join(dir, '.vcs-next')
|
|
774
1071
|
await fs.promises.mkdir(metaDirInit, { recursive: true })
|
|
775
1072
|
const localPathInit = path.join(metaDirInit, 'local.json')
|
|
776
1073
|
let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
777
|
-
try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
|
|
1074
|
+
try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch { }
|
|
778
1075
|
localMetaInit.baseCommitId = currentSnap.commitId
|
|
779
1076
|
localMetaInit.baseFiles = currentSnap.files
|
|
780
1077
|
await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
|
|
781
|
-
|
|
1078
|
+
|
|
782
1079
|
// Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
|
|
783
1080
|
const conflicts = []
|
|
784
1081
|
const changes = []
|
|
785
1082
|
const mergedFiles = {}
|
|
786
1083
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
|
|
787
|
-
|
|
1084
|
+
|
|
788
1085
|
for (const p of allPaths) {
|
|
789
1086
|
const b = baseSnap.files[p] // base: parent of commit being cherry-picked
|
|
790
1087
|
const n = targetSnap.files[p] // next: the commit being cherry-picked
|
|
791
1088
|
const c = currentSnap.files[p] // current: current state of target branch (from remote)
|
|
792
|
-
|
|
1089
|
+
|
|
793
1090
|
// For cherry-pick, we need to apply the changes from the commit onto the current branch
|
|
794
1091
|
// The logic: if the commit changed something from its parent, apply that change to current
|
|
795
1092
|
const baseContent = b !== undefined ? String(b) : null
|
|
796
1093
|
const commitContent = n !== undefined ? String(n) : null
|
|
797
1094
|
const currentContent = c !== undefined ? String(c) : null
|
|
798
|
-
|
|
1095
|
+
|
|
799
1096
|
// Check if commit changed this file from its parent
|
|
800
1097
|
const commitChanged = baseContent !== commitContent
|
|
801
|
-
|
|
1098
|
+
|
|
802
1099
|
if (commitChanged) {
|
|
803
1100
|
// The commit modified this file - we want to apply that change
|
|
804
1101
|
// Conflict detection:
|
|
@@ -807,15 +1104,15 @@ async function cmdCherryPick(opts) {
|
|
|
807
1104
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
|
|
808
1105
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
809
1106
|
// - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
|
|
810
|
-
|
|
1107
|
+
|
|
811
1108
|
const fileWasAdded = baseContent === null && commitContent !== null
|
|
812
1109
|
const fileWasDeleted = baseContent !== null && commitContent === null
|
|
813
1110
|
const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
|
|
814
|
-
|
|
1111
|
+
|
|
815
1112
|
// Check for conflicts: current branch changed the file differently than the commit
|
|
816
1113
|
const currentChangedFromBase = currentContent !== baseContent
|
|
817
1114
|
const currentDiffersFromCommit = currentContent !== commitContent
|
|
818
|
-
|
|
1115
|
+
|
|
819
1116
|
// Conflict detection:
|
|
820
1117
|
// - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
|
|
821
1118
|
// - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
|
|
@@ -823,7 +1120,7 @@ async function cmdCherryPick(opts) {
|
|
|
823
1120
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
|
|
824
1121
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
825
1122
|
// - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
|
|
826
|
-
|
|
1123
|
+
|
|
827
1124
|
// Conflict if:
|
|
828
1125
|
// 1. Current branch changed the file from base (current !== base)
|
|
829
1126
|
// 2. AND current differs from what commit wants (current !== commit)
|
|
@@ -833,7 +1130,7 @@ async function cmdCherryPick(opts) {
|
|
|
833
1130
|
// this is always safe - no conflict. The file is simply being added.
|
|
834
1131
|
const safeAddCase = fileWasAdded && currentContent === null
|
|
835
1132
|
const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
|
|
836
|
-
|
|
1133
|
+
|
|
837
1134
|
// Only conflict if file exists in current AND was changed differently
|
|
838
1135
|
if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
|
|
839
1136
|
// Conflict: both changed the file differently
|
|
@@ -863,7 +1160,7 @@ async function cmdCherryPick(opts) {
|
|
|
863
1160
|
}
|
|
864
1161
|
}
|
|
865
1162
|
}
|
|
866
|
-
|
|
1163
|
+
|
|
867
1164
|
if (conflicts.length > 0) {
|
|
868
1165
|
// Write conflict markers to files
|
|
869
1166
|
for (const conflict of conflicts) {
|
|
@@ -873,11 +1170,11 @@ async function cmdCherryPick(opts) {
|
|
|
873
1170
|
conflict.current,
|
|
874
1171
|
conflict.incoming,
|
|
875
1172
|
meta.branch,
|
|
876
|
-
`cherry-pick-${commitId.slice(0,7)}`
|
|
1173
|
+
`cherry-pick-${commitId.slice(0, 7)}`
|
|
877
1174
|
)
|
|
878
1175
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
879
1176
|
}
|
|
880
|
-
|
|
1177
|
+
|
|
881
1178
|
// Store conflict state in metadata
|
|
882
1179
|
const metaDir = path.join(dir, '.vcs-next')
|
|
883
1180
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -886,9 +1183,9 @@ async function cmdCherryPick(opts) {
|
|
|
886
1183
|
try {
|
|
887
1184
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
888
1185
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
889
|
-
} catch {}
|
|
1186
|
+
} catch { }
|
|
890
1187
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
891
|
-
|
|
1188
|
+
|
|
892
1189
|
if (opts.json === 'true') {
|
|
893
1190
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
|
|
894
1191
|
} else {
|
|
@@ -901,22 +1198,22 @@ async function cmdCherryPick(opts) {
|
|
|
901
1198
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
902
1199
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
903
1200
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
904
|
-
process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
|
|
1201
|
+
process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0, 7)}\n`, 'dim'))
|
|
905
1202
|
}
|
|
906
1203
|
return
|
|
907
1204
|
}
|
|
908
|
-
|
|
1205
|
+
|
|
909
1206
|
// Apply the changes to the filesystem
|
|
910
1207
|
for (const ch of changes) {
|
|
911
1208
|
const fp = path.join(dir, ch.path)
|
|
912
1209
|
if (ch.type === 'delete') {
|
|
913
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1210
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
914
1211
|
} else if (ch.type === 'write') {
|
|
915
1212
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
916
1213
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
917
1214
|
}
|
|
918
1215
|
}
|
|
919
|
-
|
|
1216
|
+
|
|
920
1217
|
// Update local metadata with the pending commit
|
|
921
1218
|
const metaDir = path.join(dir, '.vcs-next')
|
|
922
1219
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -925,43 +1222,43 @@ async function cmdCherryPick(opts) {
|
|
|
925
1222
|
try {
|
|
926
1223
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
927
1224
|
localMeta = JSON.parse(s)
|
|
928
|
-
} catch {}
|
|
929
|
-
|
|
930
|
-
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
|
|
931
|
-
|
|
1225
|
+
} catch { }
|
|
1226
|
+
|
|
1227
|
+
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
|
|
1228
|
+
|
|
932
1229
|
// Collect final state after applying changes
|
|
933
1230
|
const finalLocal = await collectLocal(dir)
|
|
934
1231
|
const finalFiles = {}
|
|
935
1232
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
936
1233
|
finalFiles[p] = v.content
|
|
937
1234
|
}
|
|
938
|
-
|
|
1235
|
+
|
|
939
1236
|
// Set pending commit with the actual files
|
|
940
1237
|
localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
|
|
941
1238
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
942
|
-
|
|
1239
|
+
|
|
943
1240
|
// Ensure remote.json has the correct target branch before pushing
|
|
944
1241
|
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
945
1242
|
let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
|
|
946
1243
|
try {
|
|
947
1244
|
const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
|
|
948
1245
|
remoteMeta = { ...JSON.parse(s), branch: targetBranch }
|
|
949
|
-
} catch {}
|
|
1246
|
+
} catch { }
|
|
950
1247
|
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
951
|
-
|
|
1248
|
+
|
|
952
1249
|
// Also update .git metadata
|
|
953
1250
|
const gitDir = path.join(dir, '.git')
|
|
954
1251
|
const gitMetaPath = path.join(gitDir, 'vcs-next.json')
|
|
955
1252
|
await fs.promises.mkdir(gitDir, { recursive: true })
|
|
956
1253
|
await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
957
1254
|
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
|
|
958
|
-
|
|
1255
|
+
|
|
959
1256
|
if (opts.json === 'true') {
|
|
960
1257
|
print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
|
|
961
1258
|
} else {
|
|
962
|
-
process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1259
|
+
process.stdout.write(color(`Cherry-pick ${commitId.slice(0, 7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
|
|
963
1260
|
}
|
|
964
|
-
|
|
1261
|
+
|
|
965
1262
|
if (opts.noPush !== 'true') {
|
|
966
1263
|
await cmdPush({ dir, json: opts.json, message: cherryMsg })
|
|
967
1264
|
}
|
|
@@ -984,7 +1281,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
984
1281
|
const incoming = String(incomingContent || '')
|
|
985
1282
|
const currentLines = current.split(/\r?\n/)
|
|
986
1283
|
const incomingLines = incoming.split(/\r?\n/)
|
|
987
|
-
|
|
1284
|
+
|
|
988
1285
|
// Simple conflict marker format
|
|
989
1286
|
const markers = [
|
|
990
1287
|
`<<<<<<< ${currentLabel || 'HEAD'}`,
|
|
@@ -993,7 +1290,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
993
1290
|
...incomingLines,
|
|
994
1291
|
`>>>>>>> ${incomingLabel || 'incoming'}`
|
|
995
1292
|
]
|
|
996
|
-
|
|
1293
|
+
|
|
997
1294
|
return markers.join('\n')
|
|
998
1295
|
}
|
|
999
1296
|
|
|
@@ -1019,73 +1316,106 @@ async function cmdPush(opts) {
|
|
|
1019
1316
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1020
1317
|
const localPath = path.join(metaDir, 'local.json')
|
|
1021
1318
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1319
|
+
const spinner = createSpinner('Preparing to push...', opts.json)
|
|
1022
1320
|
try {
|
|
1023
1321
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1024
1322
|
localMeta = JSON.parse(s)
|
|
1025
|
-
} catch {}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
process.stderr.write(color(
|
|
1323
|
+
} catch { }
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
// Check for unresolved conflicts in files
|
|
1327
|
+
spinnerUpdate(spinner, 'Checking for conflicts...')
|
|
1328
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1329
|
+
if (unresolvedConflicts.length > 0) {
|
|
1330
|
+
spinnerFail(spinner, 'Cannot push with unresolved conflicts')
|
|
1331
|
+
if (opts.json === 'true') {
|
|
1332
|
+
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1333
|
+
} else {
|
|
1334
|
+
process.stderr.write(color('Error: Cannot push with unresolved conflicts\\n', 'red'))
|
|
1335
|
+
process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
|
|
1336
|
+
for (const p of unresolvedConflicts) {
|
|
1337
|
+
process.stderr.write(color(` ${p}\\n`, 'red'))
|
|
1338
|
+
}
|
|
1339
|
+
process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
|
|
1037
1340
|
}
|
|
1038
|
-
|
|
1341
|
+
return
|
|
1039
1342
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if (
|
|
1063
|
-
|
|
1064
|
-
if (
|
|
1065
|
-
|
|
1066
|
-
|
|
1343
|
+
|
|
1344
|
+
const cfg = loadConfig()
|
|
1345
|
+
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1346
|
+
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1347
|
+
spinnerUpdate(spinner, 'Fetching remote state...')
|
|
1348
|
+
const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
|
|
1349
|
+
const base = localMeta.baseFiles || {}
|
|
1350
|
+
spinnerUpdate(spinner, 'Collecting local changes...')
|
|
1351
|
+
const local = await collectLocal(dir)
|
|
1352
|
+
const conflicts = []
|
|
1353
|
+
const merged = {}
|
|
1354
|
+
const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
|
|
1355
|
+
spinnerUpdate(spinner, 'Merging changes...')
|
|
1356
|
+
for (const p of paths) {
|
|
1357
|
+
const b = p in base ? base[p] : null
|
|
1358
|
+
const r = p in remote.files ? remote.files[p] : null
|
|
1359
|
+
const l = p in local ? local[p].content : null
|
|
1360
|
+
const changedLocal = String(l) !== String(b)
|
|
1361
|
+
const changedRemote = String(r) !== String(b)
|
|
1362
|
+
if (changedLocal && changedRemote && String(l) !== String(r)) {
|
|
1363
|
+
const line = firstDiffLine(l || '', r || '')
|
|
1364
|
+
conflicts.push({ path: p, line })
|
|
1365
|
+
} else if (changedLocal && !changedRemote) {
|
|
1366
|
+
if (l !== null) merged[p] = l
|
|
1367
|
+
} else if (!changedLocal && changedRemote) {
|
|
1368
|
+
if (r !== null) merged[p] = r
|
|
1369
|
+
} else {
|
|
1370
|
+
if (b !== null) merged[p] = b
|
|
1371
|
+
}
|
|
1067
1372
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1373
|
+
if (conflicts.length > 0) {
|
|
1374
|
+
spinnerFail(spinner, 'Push blocked by conflicts')
|
|
1375
|
+
if (opts.json === 'true') {
|
|
1376
|
+
print({ conflicts }, true)
|
|
1377
|
+
} else {
|
|
1378
|
+
process.stderr.write(color('Error: Cannot push with conflicts\\n', 'red'))
|
|
1379
|
+
process.stderr.write(color('Conflicts detected:\\n', 'yellow'))
|
|
1380
|
+
for (const c of conflicts) {
|
|
1381
|
+
process.stderr.write(color(` ${c.path}:${c.line}\\n`, 'red'))
|
|
1382
|
+
}
|
|
1077
1383
|
}
|
|
1384
|
+
return
|
|
1078
1385
|
}
|
|
1079
|
-
|
|
1386
|
+
// Execute pre-push hook
|
|
1387
|
+
spinnerUpdate(spinner, 'Running pre-push hook...')
|
|
1388
|
+
try {
|
|
1389
|
+
const hookResult = await hooks.executeHook(dir, 'pre-push', { branch: remoteMeta.branch, files: Object.keys(merged) })
|
|
1390
|
+
if (hookResult.executed && hookResult.exitCode !== 0) {
|
|
1391
|
+
spinnerFail(spinner, 'Pre-push hook failed')
|
|
1392
|
+
throw new Error('pre-push hook failed')
|
|
1393
|
+
}
|
|
1394
|
+
} catch (err) {
|
|
1395
|
+
if (err.message === 'pre-push hook failed') throw err
|
|
1396
|
+
// Hook doesn't exist or other error, continue
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
|
|
1400
|
+
spinnerUpdate(spinner, `Pushing to '${remoteMeta.branch}'...`)
|
|
1401
|
+
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1402
|
+
const data = await request('POST', url, body, token)
|
|
1403
|
+
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1404
|
+
localMeta.baseFiles = merged
|
|
1405
|
+
localMeta.pendingCommit = null
|
|
1406
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1407
|
+
|
|
1408
|
+
// Execute post-push hook
|
|
1409
|
+
try {
|
|
1410
|
+
await hooks.executeHook(dir, 'post-push', { branch: remoteMeta.branch, commitId: localMeta.baseCommitId })
|
|
1411
|
+
} catch {}
|
|
1412
|
+
|
|
1413
|
+
spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
|
|
1414
|
+
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
spinnerFail(spinner, 'Push failed')
|
|
1417
|
+
throw err
|
|
1080
1418
|
}
|
|
1081
|
-
const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
|
|
1082
|
-
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1083
|
-
const data = await request('POST', url, body, token)
|
|
1084
|
-
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1085
|
-
localMeta.baseFiles = merged
|
|
1086
|
-
localMeta.pendingCommit = null
|
|
1087
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1088
|
-
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1089
1419
|
}
|
|
1090
1420
|
|
|
1091
1421
|
async function cmdMerge(opts) {
|
|
@@ -1096,13 +1426,13 @@ async function cmdMerge(opts) {
|
|
|
1096
1426
|
const token = getToken(opts, cfg) || meta.token
|
|
1097
1427
|
const sourceBranch = opts.branch || ''
|
|
1098
1428
|
if (!sourceBranch) throw new Error('Missing --branch')
|
|
1099
|
-
|
|
1429
|
+
|
|
1100
1430
|
// Get current branch state
|
|
1101
1431
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1102
|
-
|
|
1432
|
+
|
|
1103
1433
|
// Get source branch state
|
|
1104
1434
|
const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
|
|
1105
|
-
|
|
1435
|
+
|
|
1106
1436
|
// Find common ancestor (merge base)
|
|
1107
1437
|
// For simplicity, we'll use the current branch's base commit as the merge base
|
|
1108
1438
|
// In a full implementation, we'd find the actual common ancestor
|
|
@@ -1110,29 +1440,29 @@ async function cmdMerge(opts) {
|
|
|
1110
1440
|
const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
|
|
1111
1441
|
const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
|
|
1112
1442
|
if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
|
|
1113
|
-
|
|
1443
|
+
|
|
1114
1444
|
// Get base commit (for now, use current branch's commit as base)
|
|
1115
1445
|
// In real Git, we'd find the merge base commit
|
|
1116
1446
|
const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1117
|
-
|
|
1447
|
+
|
|
1118
1448
|
// Pull current branch to ensure we're up to date
|
|
1119
1449
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1120
1450
|
const localAfterPull = await collectLocal(dir)
|
|
1121
|
-
|
|
1451
|
+
|
|
1122
1452
|
// Three-way merge: base, current (target), source
|
|
1123
1453
|
const conflicts = []
|
|
1124
1454
|
const changes = []
|
|
1125
1455
|
const mergedFiles = {}
|
|
1126
1456
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
|
|
1127
|
-
|
|
1457
|
+
|
|
1128
1458
|
for (const p of allPaths) {
|
|
1129
1459
|
const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
|
|
1130
1460
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1131
1461
|
const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
|
|
1132
|
-
|
|
1462
|
+
|
|
1133
1463
|
const currentChanged = current !== base
|
|
1134
1464
|
const sourceChanged = source !== base
|
|
1135
|
-
|
|
1465
|
+
|
|
1136
1466
|
if (currentChanged && sourceChanged && current !== source) {
|
|
1137
1467
|
// Conflict: both branches changed the file differently
|
|
1138
1468
|
const line = firstDiffLine(current || '', source || '')
|
|
@@ -1166,7 +1496,7 @@ async function cmdMerge(opts) {
|
|
|
1166
1496
|
}
|
|
1167
1497
|
}
|
|
1168
1498
|
}
|
|
1169
|
-
|
|
1499
|
+
|
|
1170
1500
|
if (conflicts.length > 0) {
|
|
1171
1501
|
// Write conflict markers to files
|
|
1172
1502
|
for (const conflict of conflicts) {
|
|
@@ -1180,7 +1510,7 @@ async function cmdMerge(opts) {
|
|
|
1180
1510
|
)
|
|
1181
1511
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1182
1512
|
}
|
|
1183
|
-
|
|
1513
|
+
|
|
1184
1514
|
// Store conflict state in metadata
|
|
1185
1515
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1186
1516
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1189,9 +1519,9 @@ async function cmdMerge(opts) {
|
|
|
1189
1519
|
try {
|
|
1190
1520
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1191
1521
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1192
|
-
} catch {}
|
|
1522
|
+
} catch { }
|
|
1193
1523
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1194
|
-
|
|
1524
|
+
|
|
1195
1525
|
const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
|
|
1196
1526
|
if (opts.json === 'true') {
|
|
1197
1527
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
|
|
@@ -1209,18 +1539,18 @@ async function cmdMerge(opts) {
|
|
|
1209
1539
|
}
|
|
1210
1540
|
return
|
|
1211
1541
|
}
|
|
1212
|
-
|
|
1542
|
+
|
|
1213
1543
|
// Apply changes to filesystem
|
|
1214
1544
|
for (const ch of changes) {
|
|
1215
1545
|
const fp = path.join(dir, ch.path)
|
|
1216
1546
|
if (ch.type === 'delete') {
|
|
1217
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1547
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1218
1548
|
} else if (ch.type === 'write') {
|
|
1219
1549
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1220
1550
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1221
1551
|
}
|
|
1222
1552
|
}
|
|
1223
|
-
|
|
1553
|
+
|
|
1224
1554
|
// Update local metadata
|
|
1225
1555
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1226
1556
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1229,27 +1559,27 @@ async function cmdMerge(opts) {
|
|
|
1229
1559
|
try {
|
|
1230
1560
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1231
1561
|
localMeta = JSON.parse(s)
|
|
1232
|
-
} catch {}
|
|
1233
|
-
|
|
1562
|
+
} catch { }
|
|
1563
|
+
|
|
1234
1564
|
const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
|
|
1235
|
-
|
|
1565
|
+
|
|
1236
1566
|
// Collect final state
|
|
1237
1567
|
const finalLocal = await collectLocal(dir)
|
|
1238
1568
|
const finalFiles = {}
|
|
1239
1569
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1240
1570
|
finalFiles[p] = v.content
|
|
1241
1571
|
}
|
|
1242
|
-
|
|
1572
|
+
|
|
1243
1573
|
// Set pending commit
|
|
1244
1574
|
localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
|
|
1245
1575
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1246
|
-
|
|
1576
|
+
|
|
1247
1577
|
if (opts.json === 'true') {
|
|
1248
1578
|
print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
|
|
1249
1579
|
} else {
|
|
1250
1580
|
process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1251
1581
|
}
|
|
1252
|
-
|
|
1582
|
+
|
|
1253
1583
|
if (opts.noPush !== 'true') {
|
|
1254
1584
|
await cmdPush({ dir, json: opts.json, message: mergeMsg })
|
|
1255
1585
|
}
|
|
@@ -1260,7 +1590,7 @@ async function cmdStash(sub, opts) {
|
|
|
1260
1590
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1261
1591
|
const stashDir = path.join(metaDir, 'stash')
|
|
1262
1592
|
await fs.promises.mkdir(stashDir, { recursive: true })
|
|
1263
|
-
|
|
1593
|
+
|
|
1264
1594
|
if (sub === 'list' || sub === undefined) {
|
|
1265
1595
|
try {
|
|
1266
1596
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1293,7 +1623,7 @@ async function cmdStash(sub, opts) {
|
|
|
1293
1623
|
}
|
|
1294
1624
|
return
|
|
1295
1625
|
}
|
|
1296
|
-
|
|
1626
|
+
|
|
1297
1627
|
if (sub === 'save' || (sub === undefined && !opts.list)) {
|
|
1298
1628
|
const message = opts.message || 'WIP'
|
|
1299
1629
|
const local = await collectLocal(dir)
|
|
@@ -1304,14 +1634,14 @@ async function cmdStash(sub, opts) {
|
|
|
1304
1634
|
const stashId = Date.now().toString()
|
|
1305
1635
|
const stash = { message, files, createdAt: Date.now() }
|
|
1306
1636
|
await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
|
|
1307
|
-
|
|
1637
|
+
|
|
1308
1638
|
// Restore to base state (discard local changes)
|
|
1309
1639
|
const meta = readRemoteMeta(dir)
|
|
1310
1640
|
const cfg = loadConfig()
|
|
1311
1641
|
const server = getServer(opts, cfg) || meta.server
|
|
1312
1642
|
const token = getToken(opts, cfg) || meta.token
|
|
1313
1643
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1314
|
-
|
|
1644
|
+
|
|
1315
1645
|
if (opts.json === 'true') {
|
|
1316
1646
|
print({ stashId, message }, true)
|
|
1317
1647
|
} else {
|
|
@@ -1319,7 +1649,7 @@ async function cmdStash(sub, opts) {
|
|
|
1319
1649
|
}
|
|
1320
1650
|
return
|
|
1321
1651
|
}
|
|
1322
|
-
|
|
1652
|
+
|
|
1323
1653
|
if (sub === 'pop' || sub === 'apply') {
|
|
1324
1654
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1325
1655
|
try {
|
|
@@ -1335,19 +1665,19 @@ async function cmdStash(sub, opts) {
|
|
|
1335
1665
|
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1336
1666
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1337
1667
|
const stash = stashes[stashIndex]
|
|
1338
|
-
|
|
1668
|
+
|
|
1339
1669
|
// Apply stash files
|
|
1340
1670
|
for (const [p, content] of Object.entries(stash.files || {})) {
|
|
1341
1671
|
const fp = path.join(dir, p)
|
|
1342
1672
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1343
1673
|
await fs.promises.writeFile(fp, content, 'utf8')
|
|
1344
1674
|
}
|
|
1345
|
-
|
|
1675
|
+
|
|
1346
1676
|
if (sub === 'pop') {
|
|
1347
1677
|
// Remove stash
|
|
1348
1678
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1349
1679
|
}
|
|
1350
|
-
|
|
1680
|
+
|
|
1351
1681
|
if (opts.json === 'true') {
|
|
1352
1682
|
print({ applied: stash.id, message: stash.message }, true)
|
|
1353
1683
|
} else {
|
|
@@ -1358,7 +1688,7 @@ async function cmdStash(sub, opts) {
|
|
|
1358
1688
|
}
|
|
1359
1689
|
return
|
|
1360
1690
|
}
|
|
1361
|
-
|
|
1691
|
+
|
|
1362
1692
|
if (sub === 'drop') {
|
|
1363
1693
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1364
1694
|
try {
|
|
@@ -1375,7 +1705,7 @@ async function cmdStash(sub, opts) {
|
|
|
1375
1705
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1376
1706
|
const stash = stashes[stashIndex]
|
|
1377
1707
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1378
|
-
|
|
1708
|
+
|
|
1379
1709
|
if (opts.json === 'true') {
|
|
1380
1710
|
print({ dropped: stash.id }, true)
|
|
1381
1711
|
} else {
|
|
@@ -1386,7 +1716,7 @@ async function cmdStash(sub, opts) {
|
|
|
1386
1716
|
}
|
|
1387
1717
|
return
|
|
1388
1718
|
}
|
|
1389
|
-
|
|
1719
|
+
|
|
1390
1720
|
if (sub === 'clear') {
|
|
1391
1721
|
try {
|
|
1392
1722
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1403,7 +1733,7 @@ async function cmdStash(sub, opts) {
|
|
|
1403
1733
|
}
|
|
1404
1734
|
return
|
|
1405
1735
|
}
|
|
1406
|
-
|
|
1736
|
+
|
|
1407
1737
|
throw new Error('Unknown stash subcommand')
|
|
1408
1738
|
}
|
|
1409
1739
|
|
|
@@ -1425,9 +1755,22 @@ async function cmdBranch(sub, opts) {
|
|
|
1425
1755
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
1426
1756
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
1427
1757
|
if (m) current = m[1]
|
|
1428
|
-
} catch {}
|
|
1758
|
+
} catch { }
|
|
1429
1759
|
process.stdout.write(color('Branches:\n', 'bold'))
|
|
1430
|
-
|
|
1760
|
+
let list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '', date: b.lastCommitDate || b.createdAt || '' }))
|
|
1761
|
+
|
|
1762
|
+
// Handle --sort option
|
|
1763
|
+
if (opts.sort) {
|
|
1764
|
+
const sortBy = opts.sort.replace(/^-/, '') // Remove leading dash
|
|
1765
|
+
if (sortBy === 'committerdate' || sortBy === '-committerdate') {
|
|
1766
|
+
list.sort((a, b) => {
|
|
1767
|
+
const dateA = new Date(a.date || 0).getTime()
|
|
1768
|
+
const dateB = new Date(b.date || 0).getTime()
|
|
1769
|
+
return sortBy.startsWith('-') ? dateB - dateA : dateA - dateB
|
|
1770
|
+
})
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1431
1774
|
for (const b of list) {
|
|
1432
1775
|
const isCur = b.name === current
|
|
1433
1776
|
const mark = isCur ? color('*', 'green') : ' '
|
|
@@ -1451,19 +1794,38 @@ async function cmdBranch(sub, opts) {
|
|
|
1451
1794
|
if (sub === 'delete') {
|
|
1452
1795
|
const name = opts.name
|
|
1453
1796
|
if (!name) throw new Error('Missing --name')
|
|
1797
|
+
const force = opts.force === 'true' || opts.D === 'true'
|
|
1454
1798
|
const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1455
1799
|
u.searchParams.set('name', name)
|
|
1800
|
+
u.searchParams.set('force', force ? 'true' : 'false')
|
|
1456
1801
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1457
1802
|
const res = await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1458
1803
|
if (!res.ok) {
|
|
1459
1804
|
const body = await res.text().catch(() => '')
|
|
1805
|
+
if (force) {
|
|
1806
|
+
throw new Error(`Branch force delete failed: ${res.status} ${res.statusText} ${body}`)
|
|
1807
|
+
}
|
|
1460
1808
|
throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
|
|
1461
1809
|
}
|
|
1462
1810
|
const data = await res.json()
|
|
1463
1811
|
print(data, opts.json === 'true')
|
|
1464
1812
|
return
|
|
1465
1813
|
}
|
|
1466
|
-
|
|
1814
|
+
if (sub === 'rename') {
|
|
1815
|
+
const oldName = opts.old
|
|
1816
|
+
const newName = opts.new
|
|
1817
|
+
if (!oldName || !newName) throw new Error('Missing --old and --new')
|
|
1818
|
+
const body = { name: newName, baseBranch: oldName }
|
|
1819
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1820
|
+
await request('POST', url, body, token)
|
|
1821
|
+
const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1822
|
+
u.searchParams.set('name', oldName)
|
|
1823
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1824
|
+
await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1825
|
+
print({ renamed: { from: oldName, to: newName } }, opts.json === 'true')
|
|
1826
|
+
return
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1467
1829
|
throw new Error('Unknown branch subcommand')
|
|
1468
1830
|
}
|
|
1469
1831
|
|
|
@@ -1471,10 +1833,24 @@ async function cmdSwitch(opts) {
|
|
|
1471
1833
|
const dir = path.resolve(opts.dir || '.')
|
|
1472
1834
|
const meta = readRemoteMeta(dir)
|
|
1473
1835
|
const branch = opts.branch
|
|
1836
|
+
const create = opts.create === 'true' || opts.c === 'true'
|
|
1474
1837
|
if (!branch) throw new Error('Missing --branch')
|
|
1475
1838
|
const cfg = loadConfig()
|
|
1476
1839
|
const server = getServer(opts, cfg) || meta.server
|
|
1477
1840
|
const token = getToken(opts, cfg) || meta.token
|
|
1841
|
+
|
|
1842
|
+
// Handle -c flag (create and switch)
|
|
1843
|
+
if (create) {
|
|
1844
|
+
// Check if branch exists
|
|
1845
|
+
const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1846
|
+
const branchesData = await request('GET', branchesUrl.toString(), null, token)
|
|
1847
|
+
const exists = (branchesData.branches || []).some(b => b.name === branch)
|
|
1848
|
+
if (!exists) {
|
|
1849
|
+
// Create the branch
|
|
1850
|
+
await cmdBranch('create', { dir, name: branch, base: meta.branch, repo: meta.repoId, server, token })
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1478
1854
|
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1479
1855
|
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1480
1856
|
}
|
|
@@ -1493,7 +1869,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
|
|
|
1493
1869
|
for (const rel of Object.keys(localMap)) {
|
|
1494
1870
|
if (!keep.has(rel)) {
|
|
1495
1871
|
const fp = path.join(root, rel)
|
|
1496
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1872
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1497
1873
|
}
|
|
1498
1874
|
}
|
|
1499
1875
|
const metaDir = path.join(root, '.vcs-next')
|
|
@@ -1521,6 +1897,11 @@ async function cmdCheckout(opts) {
|
|
|
1521
1897
|
return
|
|
1522
1898
|
}
|
|
1523
1899
|
if (!branch) throw new Error('Missing --branch or --commit')
|
|
1900
|
+
if (opts.create === 'true') {
|
|
1901
|
+
const body = { name: branch, baseBranch: meta.branch }
|
|
1902
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1903
|
+
await request('POST', url, body, token)
|
|
1904
|
+
}
|
|
1524
1905
|
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1525
1906
|
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1526
1907
|
}
|
|
@@ -1614,7 +1995,7 @@ async function cmdHead(opts) {
|
|
|
1614
1995
|
} else {
|
|
1615
1996
|
commitId = head.trim()
|
|
1616
1997
|
}
|
|
1617
|
-
} catch {}
|
|
1998
|
+
} catch { }
|
|
1618
1999
|
if (!commitId) {
|
|
1619
2000
|
const cfg = loadConfig()
|
|
1620
2001
|
const server = getServer(opts, cfg) || meta.server
|
|
@@ -1635,12 +2016,12 @@ async function cmdShow(opts) {
|
|
|
1635
2016
|
const token = getToken(opts, cfg) || meta.token
|
|
1636
2017
|
const commitId = opts.commit
|
|
1637
2018
|
if (!commitId) throw new Error('Missing --commit')
|
|
1638
|
-
|
|
2019
|
+
|
|
1639
2020
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1640
2021
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1641
2022
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1642
2023
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1643
|
-
|
|
2024
|
+
|
|
1644
2025
|
if (opts.json === 'true') {
|
|
1645
2026
|
print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
|
|
1646
2027
|
} else {
|
|
@@ -1652,7 +2033,7 @@ async function cmdShow(opts) {
|
|
|
1652
2033
|
process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
|
|
1653
2034
|
}
|
|
1654
2035
|
process.stdout.write(`\n${commit.message || ''}\n\n`)
|
|
1655
|
-
|
|
2036
|
+
|
|
1656
2037
|
// Show file changes
|
|
1657
2038
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
1658
2039
|
const changed = []
|
|
@@ -1683,34 +2064,34 @@ async function cmdRevert(opts) {
|
|
|
1683
2064
|
const token = getToken(opts, cfg) || meta.token
|
|
1684
2065
|
const commitId = opts.commit
|
|
1685
2066
|
if (!commitId) throw new Error('Missing --commit')
|
|
1686
|
-
|
|
2067
|
+
|
|
1687
2068
|
// Get the commit to revert
|
|
1688
2069
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1689
2070
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1690
2071
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1691
2072
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1692
|
-
|
|
2073
|
+
|
|
1693
2074
|
// Get current state
|
|
1694
2075
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1695
2076
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1696
2077
|
const localAfterPull = await collectLocal(dir)
|
|
1697
|
-
|
|
2078
|
+
|
|
1698
2079
|
// Revert: apply inverse of commit changes
|
|
1699
2080
|
const conflicts = []
|
|
1700
2081
|
const changes = []
|
|
1701
2082
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
|
|
1702
|
-
|
|
2083
|
+
|
|
1703
2084
|
for (const p of allPaths) {
|
|
1704
2085
|
const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
1705
2086
|
const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
1706
2087
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1707
2088
|
const local = localAfterPull[p]?.content || null
|
|
1708
|
-
|
|
2089
|
+
|
|
1709
2090
|
// Revert: we want to go from commit state back to parent state
|
|
1710
2091
|
// But check if current branch has changed this file
|
|
1711
2092
|
const commitChanged = base !== commitContent
|
|
1712
2093
|
const currentChanged = current !== base
|
|
1713
|
-
|
|
2094
|
+
|
|
1714
2095
|
if (commitChanged) {
|
|
1715
2096
|
// Commit changed this file - we want to revert it
|
|
1716
2097
|
if (currentChanged && current !== base) {
|
|
@@ -1732,7 +2113,7 @@ async function cmdRevert(opts) {
|
|
|
1732
2113
|
}
|
|
1733
2114
|
}
|
|
1734
2115
|
}
|
|
1735
|
-
|
|
2116
|
+
|
|
1736
2117
|
if (conflicts.length > 0) {
|
|
1737
2118
|
// Write conflict markers to files
|
|
1738
2119
|
for (const conflict of conflicts) {
|
|
@@ -1742,11 +2123,11 @@ async function cmdRevert(opts) {
|
|
|
1742
2123
|
conflict.current,
|
|
1743
2124
|
conflict.incoming,
|
|
1744
2125
|
meta.branch,
|
|
1745
|
-
`revert-${commitId.slice(0,7)}`
|
|
2126
|
+
`revert-${commitId.slice(0, 7)}`
|
|
1746
2127
|
)
|
|
1747
2128
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1748
2129
|
}
|
|
1749
|
-
|
|
2130
|
+
|
|
1750
2131
|
// Store conflict state in metadata
|
|
1751
2132
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1752
2133
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1755,9 +2136,9 @@ async function cmdRevert(opts) {
|
|
|
1755
2136
|
try {
|
|
1756
2137
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1757
2138
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1758
|
-
} catch {}
|
|
2139
|
+
} catch { }
|
|
1759
2140
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1760
|
-
|
|
2141
|
+
|
|
1761
2142
|
if (opts.json === 'true') {
|
|
1762
2143
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
|
|
1763
2144
|
} else {
|
|
@@ -1770,22 +2151,22 @@ async function cmdRevert(opts) {
|
|
|
1770
2151
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
1771
2152
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
1772
2153
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
1773
|
-
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
|
|
2154
|
+
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
|
|
1774
2155
|
}
|
|
1775
2156
|
return
|
|
1776
2157
|
}
|
|
1777
|
-
|
|
2158
|
+
|
|
1778
2159
|
// Apply changes
|
|
1779
2160
|
for (const ch of changes) {
|
|
1780
2161
|
const fp = path.join(dir, ch.path)
|
|
1781
2162
|
if (ch.type === 'delete') {
|
|
1782
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
2163
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1783
2164
|
} else if (ch.type === 'write') {
|
|
1784
2165
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1785
2166
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1786
2167
|
}
|
|
1787
2168
|
}
|
|
1788
|
-
|
|
2169
|
+
|
|
1789
2170
|
// Update metadata
|
|
1790
2171
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1791
2172
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -1794,24 +2175,24 @@ async function cmdRevert(opts) {
|
|
|
1794
2175
|
try {
|
|
1795
2176
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1796
2177
|
localMeta = JSON.parse(s)
|
|
1797
|
-
} catch {}
|
|
1798
|
-
|
|
2178
|
+
} catch { }
|
|
2179
|
+
|
|
1799
2180
|
const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
|
|
1800
2181
|
const finalLocal = await collectLocal(dir)
|
|
1801
2182
|
const finalFiles = {}
|
|
1802
2183
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1803
2184
|
finalFiles[p] = v.content
|
|
1804
2185
|
}
|
|
1805
|
-
|
|
2186
|
+
|
|
1806
2187
|
localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
|
|
1807
2188
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1808
|
-
|
|
2189
|
+
|
|
1809
2190
|
if (opts.json === 'true') {
|
|
1810
2191
|
print({ reverted: commitId, changes: changes.length }, true)
|
|
1811
2192
|
} else {
|
|
1812
|
-
process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
|
|
2193
|
+
process.stdout.write(color(`Reverted ${commitId.slice(0, 7)}: ${changes.length} changes\n`, 'green'))
|
|
1813
2194
|
}
|
|
1814
|
-
|
|
2195
|
+
|
|
1815
2196
|
if (opts.noPush !== 'true') {
|
|
1816
2197
|
await cmdPush({ dir, json: opts.json, message: revertMsg })
|
|
1817
2198
|
}
|
|
@@ -1823,11 +2204,29 @@ async function cmdReset(opts) {
|
|
|
1823
2204
|
const cfg = loadConfig()
|
|
1824
2205
|
const server = getServer(opts, cfg) || meta.server
|
|
1825
2206
|
const token = getToken(opts, cfg) || meta.token
|
|
1826
|
-
|
|
1827
|
-
|
|
2207
|
+
|
|
2208
|
+
let commitId = opts.commit || 'HEAD'
|
|
1828
2209
|
const mode = opts.mode || 'mixed' // soft, mixed, hard
|
|
1829
2210
|
const filePath = opts.path
|
|
1830
|
-
|
|
2211
|
+
|
|
2212
|
+
// Handle HEAD^ syntax (parent commit)
|
|
2213
|
+
if (commitId === 'HEAD^' || commitId.endsWith('^')) {
|
|
2214
|
+
const baseCommit = commitId.replace(/\^+$/, '')
|
|
2215
|
+
const targetCommit = baseCommit === 'HEAD' ? 'HEAD' : baseCommit
|
|
2216
|
+
let targetSnap
|
|
2217
|
+
if (targetCommit === 'HEAD') {
|
|
2218
|
+
targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
2219
|
+
} else {
|
|
2220
|
+
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, targetCommit, token)
|
|
2221
|
+
}
|
|
2222
|
+
const commit = await fetchCommitMeta(server, meta.repoId, targetSnap.commitId, token)
|
|
2223
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
2224
|
+
if (!parentId) {
|
|
2225
|
+
throw new Error('No parent commit found')
|
|
2226
|
+
}
|
|
2227
|
+
commitId = parentId
|
|
2228
|
+
}
|
|
2229
|
+
|
|
1831
2230
|
if (filePath) {
|
|
1832
2231
|
// Reset specific file (unstage)
|
|
1833
2232
|
const metaDir = path.join(dir, '.vcs-next')
|
|
@@ -1836,8 +2235,8 @@ async function cmdReset(opts) {
|
|
|
1836
2235
|
try {
|
|
1837
2236
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1838
2237
|
localMeta = JSON.parse(s)
|
|
1839
|
-
} catch {}
|
|
1840
|
-
|
|
2238
|
+
} catch { }
|
|
2239
|
+
|
|
1841
2240
|
// Restore file from base
|
|
1842
2241
|
const baseContent = localMeta.baseFiles[filePath]
|
|
1843
2242
|
if (baseContent !== undefined) {
|
|
@@ -1845,7 +2244,7 @@ async function cmdReset(opts) {
|
|
|
1845
2244
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1846
2245
|
await fs.promises.writeFile(fp, String(baseContent), 'utf8')
|
|
1847
2246
|
}
|
|
1848
|
-
|
|
2247
|
+
|
|
1849
2248
|
if (opts.json === 'true') {
|
|
1850
2249
|
print({ reset: filePath }, true)
|
|
1851
2250
|
} else {
|
|
@@ -1853,7 +2252,7 @@ async function cmdReset(opts) {
|
|
|
1853
2252
|
}
|
|
1854
2253
|
return
|
|
1855
2254
|
}
|
|
1856
|
-
|
|
2255
|
+
|
|
1857
2256
|
// Reset to commit
|
|
1858
2257
|
let targetSnap
|
|
1859
2258
|
if (commitId === 'HEAD') {
|
|
@@ -1861,7 +2260,7 @@ async function cmdReset(opts) {
|
|
|
1861
2260
|
} else {
|
|
1862
2261
|
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1863
2262
|
}
|
|
1864
|
-
|
|
2263
|
+
|
|
1865
2264
|
if (mode === 'hard') {
|
|
1866
2265
|
// Hard reset: discard all changes, reset to commit
|
|
1867
2266
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
@@ -1882,14 +2281,14 @@ async function cmdReset(opts) {
|
|
|
1882
2281
|
// Mixed reset: keep changes unstaged (default)
|
|
1883
2282
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1884
2283
|
}
|
|
1885
|
-
|
|
2284
|
+
|
|
1886
2285
|
// Update metadata
|
|
1887
2286
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1888
2287
|
const localPath = path.join(metaDir, 'local.json')
|
|
1889
2288
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1890
2289
|
const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
|
|
1891
2290
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1892
|
-
|
|
2291
|
+
|
|
1893
2292
|
if (opts.json === 'true') {
|
|
1894
2293
|
print({ reset: commitId, mode }, true)
|
|
1895
2294
|
} else {
|
|
@@ -1897,23 +2296,57 @@ async function cmdReset(opts) {
|
|
|
1897
2296
|
}
|
|
1898
2297
|
}
|
|
1899
2298
|
|
|
2299
|
+
async function cmdInit(opts) {
|
|
2300
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2301
|
+
const cfg = loadConfig()
|
|
2302
|
+
const server = getServer(opts, cfg)
|
|
2303
|
+
const token = getToken(opts, cfg)
|
|
2304
|
+
const repo = opts.repo || ''
|
|
2305
|
+
const branch = opts.branch || 'main'
|
|
2306
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2307
|
+
const gitDir = path.join(dir, '.git')
|
|
2308
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2309
|
+
await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
|
|
2310
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
|
|
2311
|
+
await fs.promises.writeFile(path.join(gitDir, 'refs', 'heads', branch), '', 'utf8')
|
|
2312
|
+
const gitConfig = [
|
|
2313
|
+
'[core]',
|
|
2314
|
+
'\trepositoryformatversion = 0',
|
|
2315
|
+
'\tfilemode = true',
|
|
2316
|
+
'\tbare = false',
|
|
2317
|
+
'\tlogallrefupdates = true',
|
|
2318
|
+
'',
|
|
2319
|
+
'[vcs-next]',
|
|
2320
|
+
`\tserver = ${opts.server || server || ''}`,
|
|
2321
|
+
`\trepoId = ${repo}`,
|
|
2322
|
+
`\tbranch = ${branch}`,
|
|
2323
|
+
`\ttoken = ${opts.token || token || ''}`
|
|
2324
|
+
].join('\n')
|
|
2325
|
+
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
2326
|
+
const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
|
|
2327
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
|
|
2328
|
+
const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
2329
|
+
await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
|
|
2330
|
+
print({ initialized: dir, branch }, opts.json === 'true')
|
|
2331
|
+
}
|
|
2332
|
+
|
|
1900
2333
|
async function cmdMv(opts) {
|
|
1901
2334
|
const dir = path.resolve(opts.dir || '.')
|
|
1902
2335
|
const from = opts.from
|
|
1903
2336
|
const to = opts.to
|
|
1904
2337
|
if (!from || !to) throw new Error('Missing --from and --to')
|
|
1905
|
-
|
|
2338
|
+
|
|
1906
2339
|
const fromPath = path.join(dir, from)
|
|
1907
2340
|
const toPath = path.join(dir, to)
|
|
1908
|
-
|
|
2341
|
+
|
|
1909
2342
|
// Check if source exists
|
|
1910
2343
|
const stat = await fs.promises.stat(fromPath).catch(() => null)
|
|
1911
2344
|
if (!stat) throw new Error(`Source file not found: ${from}`)
|
|
1912
|
-
|
|
2345
|
+
|
|
1913
2346
|
// Move file
|
|
1914
2347
|
await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
|
|
1915
2348
|
await fs.promises.rename(fromPath, toPath)
|
|
1916
|
-
|
|
2349
|
+
|
|
1917
2350
|
// If it's a tracked file, we need to update it in the next commit
|
|
1918
2351
|
// For now, just move it - the next commit will track the change
|
|
1919
2352
|
if (opts.json === 'true') {
|
|
@@ -1925,7 +2358,7 @@ async function cmdMv(opts) {
|
|
|
1925
2358
|
|
|
1926
2359
|
async function cmdAdd(opts) {
|
|
1927
2360
|
const dir = path.resolve(opts.dir || '.')
|
|
1928
|
-
|
|
2361
|
+
|
|
1929
2362
|
// Handle --all flag
|
|
1930
2363
|
if (opts.all === 'true' || opts.all === true) {
|
|
1931
2364
|
const meta = readRemoteMeta(dir)
|
|
@@ -1934,7 +2367,7 @@ async function cmdAdd(opts) {
|
|
|
1934
2367
|
const token = getToken(opts, cfg) || meta.token
|
|
1935
2368
|
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
1936
2369
|
const local = await collectLocal(dir)
|
|
1937
|
-
|
|
2370
|
+
|
|
1938
2371
|
// Stage all changes (add new, modified, delete removed)
|
|
1939
2372
|
const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
|
|
1940
2373
|
let stagedCount = 0
|
|
@@ -1945,7 +2378,7 @@ async function cmdAdd(opts) {
|
|
|
1945
2378
|
stagedCount++
|
|
1946
2379
|
}
|
|
1947
2380
|
}
|
|
1948
|
-
|
|
2381
|
+
|
|
1949
2382
|
if (opts.json === 'true') {
|
|
1950
2383
|
print({ staged: stagedCount }, true)
|
|
1951
2384
|
} else {
|
|
@@ -1953,7 +2386,7 @@ async function cmdAdd(opts) {
|
|
|
1953
2386
|
}
|
|
1954
2387
|
return
|
|
1955
2388
|
}
|
|
1956
|
-
|
|
2389
|
+
|
|
1957
2390
|
const p = opts.path
|
|
1958
2391
|
if (!p) throw new Error('Missing --path')
|
|
1959
2392
|
const abs = path.join(dir, p)
|
|
@@ -2001,6 +2434,563 @@ async function cmdAdd(opts) {
|
|
|
2001
2434
|
print({ added: p, dir }, opts.json === 'true')
|
|
2002
2435
|
}
|
|
2003
2436
|
|
|
2437
|
+
async function cmdRemote(sub, opts) {
|
|
2438
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2439
|
+
const meta = readRemoteMeta(dir)
|
|
2440
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2441
|
+
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
2442
|
+
let remoteMeta = meta
|
|
2443
|
+
try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
|
|
2444
|
+
if (sub === 'show' || sub === undefined) {
|
|
2445
|
+
print(remoteMeta, opts.json === 'true')
|
|
2446
|
+
return
|
|
2447
|
+
}
|
|
2448
|
+
if (sub === 'set-url') {
|
|
2449
|
+
const server = opts.server || ''
|
|
2450
|
+
if (!server) throw new Error('Missing --server')
|
|
2451
|
+
remoteMeta.server = server
|
|
2452
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2453
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
2454
|
+
const gitDir = path.join(dir, '.git')
|
|
2455
|
+
const cfgPath = path.join(gitDir, 'config')
|
|
2456
|
+
let cfgText = ''
|
|
2457
|
+
try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
|
|
2458
|
+
const lines = cfgText.split('\n')
|
|
2459
|
+
let inSec = false
|
|
2460
|
+
const out = []
|
|
2461
|
+
for (const ln of lines) {
|
|
2462
|
+
if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
|
|
2463
|
+
if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
|
|
2464
|
+
if (inSec && ln.trim().startsWith('server =')) { out.push(`\tserver = ${server}`); continue }
|
|
2465
|
+
out.push(ln)
|
|
2466
|
+
}
|
|
2467
|
+
await fs.promises.writeFile(cfgPath, out.join('\n'))
|
|
2468
|
+
print({ server }, opts.json === 'true')
|
|
2469
|
+
return
|
|
2470
|
+
}
|
|
2471
|
+
if (sub === 'set-token') {
|
|
2472
|
+
const token = opts.token || ''
|
|
2473
|
+
if (!token) throw new Error('Missing --token')
|
|
2474
|
+
remoteMeta.token = token
|
|
2475
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2476
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
2477
|
+
const gitDir = path.join(dir, '.git')
|
|
2478
|
+
const cfgPath = path.join(gitDir, 'config')
|
|
2479
|
+
let cfgText = ''
|
|
2480
|
+
try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
|
|
2481
|
+
const lines = cfgText.split('\n')
|
|
2482
|
+
let inSec = false
|
|
2483
|
+
const out = []
|
|
2484
|
+
for (const ln of lines) {
|
|
2485
|
+
if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
|
|
2486
|
+
if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
|
|
2487
|
+
if (inSec && ln.trim().startsWith('token =')) { out.push(`\ttoken = ${token}`); continue }
|
|
2488
|
+
out.push(ln)
|
|
2489
|
+
}
|
|
2490
|
+
await fs.promises.writeFile(cfgPath, out.join('\n'))
|
|
2491
|
+
print({ token: 'updated' }, opts.json === 'true')
|
|
2492
|
+
return
|
|
2493
|
+
}
|
|
2494
|
+
throw new Error('Unknown remote subcommand')
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
async function cmdConfig(sub, opts) {
|
|
2498
|
+
if (sub === 'list' || sub === undefined) {
|
|
2499
|
+
print(loadConfig(), opts.json === 'true')
|
|
2500
|
+
return
|
|
2501
|
+
}
|
|
2502
|
+
if (sub === 'get') {
|
|
2503
|
+
const key = opts.key
|
|
2504
|
+
if (!key) throw new Error('Missing --key')
|
|
2505
|
+
const cfg = loadConfig()
|
|
2506
|
+
const val = cfg[key]
|
|
2507
|
+
print({ [key]: val }, opts.json === 'true')
|
|
2508
|
+
return
|
|
2509
|
+
}
|
|
2510
|
+
if (sub === 'set') {
|
|
2511
|
+
const key = opts.key
|
|
2512
|
+
const value = opts.value
|
|
2513
|
+
if (!key) throw new Error('Missing --key')
|
|
2514
|
+
const next = saveConfig({ [key]: value })
|
|
2515
|
+
print({ [key]: next[key] }, opts.json === 'true')
|
|
2516
|
+
return
|
|
2517
|
+
}
|
|
2518
|
+
throw new Error('Unknown config subcommand')
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
async function cmdClean(opts) {
|
|
2522
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2523
|
+
const meta = readRemoteMeta(dir)
|
|
2524
|
+
const cfg = loadConfig()
|
|
2525
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2526
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2527
|
+
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
2528
|
+
const local = await collectLocal(dir)
|
|
2529
|
+
const keep = new Set(Object.keys(remoteSnap.files))
|
|
2530
|
+
const toDelete = []
|
|
2531
|
+
for (const p of Object.keys(local)) {
|
|
2532
|
+
if (!keep.has(p)) toDelete.push(p)
|
|
2533
|
+
}
|
|
2534
|
+
const force = opts.force === 'true'
|
|
2535
|
+
if (!force) {
|
|
2536
|
+
print({ untracked: toDelete }, opts.json === 'true')
|
|
2537
|
+
return
|
|
2538
|
+
}
|
|
2539
|
+
for (const rel of toDelete) {
|
|
2540
|
+
const fp = path.join(dir, rel)
|
|
2541
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
2542
|
+
}
|
|
2543
|
+
const pruneEmptyDirs = async (start) => {
|
|
2544
|
+
const entries = await fs.promises.readdir(start).catch(() => [])
|
|
2545
|
+
for (const name of entries) {
|
|
2546
|
+
if (name === '.git' || name === '.vcs-next') continue
|
|
2547
|
+
const p = path.join(start, name)
|
|
2548
|
+
const st = await fs.promises.stat(p).catch(() => null)
|
|
2549
|
+
if (!st) continue
|
|
2550
|
+
if (st.isDirectory()) {
|
|
2551
|
+
await pruneEmptyDirs(p)
|
|
2552
|
+
const left = await fs.promises.readdir(p).catch(() => [])
|
|
2553
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
await pruneEmptyDirs(dir)
|
|
2558
|
+
print({ cleaned: toDelete.length }, opts.json === 'true')
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
async function cmdRebase(opts) {
|
|
2562
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2563
|
+
const meta = readRemoteMeta(dir)
|
|
2564
|
+
const cfg = loadConfig()
|
|
2565
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2566
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2567
|
+
const sourceBranch = opts.branch || ''
|
|
2568
|
+
const onto = opts.onto || meta.branch
|
|
2569
|
+
if (!sourceBranch) throw new Error('Missing --branch')
|
|
2570
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
2571
|
+
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2572
|
+
commitsUrl.searchParams.set('branch', sourceBranch)
|
|
2573
|
+
const res = await fetch(commitsUrl.toString(), { headers })
|
|
2574
|
+
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(t || 'commits fetch failed') }
|
|
2575
|
+
const list = await res.json()
|
|
2576
|
+
const ids = Array.isArray(list) ? list.map((c) => c.id || c._id || '').filter(Boolean) : []
|
|
2577
|
+
for (const id of ids) {
|
|
2578
|
+
await cmdCherryPick({ dir, commit: id, branch: onto, noPush: 'true' })
|
|
2579
|
+
const unresolved = await checkForUnresolvedConflicts(dir)
|
|
2580
|
+
if (unresolved.length > 0) {
|
|
2581
|
+
if (opts.json === 'true') {
|
|
2582
|
+
print({ error: 'Rebase stopped due to conflicts', conflicts: unresolved }, true)
|
|
2583
|
+
} else {
|
|
2584
|
+
process.stderr.write(color('Rebase stopped due to conflicts\n', 'red'))
|
|
2585
|
+
}
|
|
2586
|
+
return
|
|
2587
|
+
}
|
|
2588
|
+
}
|
|
2589
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2590
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
2591
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
2592
|
+
try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
|
|
2593
|
+
const message = opts.message || `rebase ${sourceBranch} onto ${onto}`
|
|
2594
|
+
if (opts.noPush !== 'true') {
|
|
2595
|
+
await cmdPush({ dir, json: opts.json, message })
|
|
2596
|
+
} else {
|
|
2597
|
+
print({ rebased: sourceBranch, onto }, opts.json === 'true')
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
async function cmdBlame(opts) {
|
|
2602
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2603
|
+
const filePath = opts.path
|
|
2604
|
+
if (!filePath) throw new errors.ValidationError('Missing --path', 'path')
|
|
2605
|
+
|
|
2606
|
+
const validPath = validation.validateFilePath(filePath)
|
|
2607
|
+
const meta = readRemoteMeta(dir)
|
|
2608
|
+
const cfg = loadConfig()
|
|
2609
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2610
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2611
|
+
|
|
2612
|
+
const spinner = createSpinner(`Getting blame for ${validPath}...`, opts.json)
|
|
2613
|
+
|
|
2614
|
+
try {
|
|
2615
|
+
// Get file content
|
|
2616
|
+
const local = await collectLocal(dir)
|
|
2617
|
+
if (!local[validPath]) {
|
|
2618
|
+
throw new errors.FileSystemError(`File not found: ${validPath}`, validPath, 'read')
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// Get commits
|
|
2622
|
+
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2623
|
+
commitsUrl.searchParams.set('branch', meta.branch)
|
|
2624
|
+
const commitsRes = await fetch(commitsUrl.toString(), {
|
|
2625
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
2626
|
+
})
|
|
2627
|
+
|
|
2628
|
+
if (!commitsRes.ok) {
|
|
2629
|
+
throw new errors.NetworkError('Failed to fetch commits', commitsRes.status, commitsUrl.toString())
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
const commits = await commitsRes.json()
|
|
2633
|
+
const blameData = parseBlame(local[validPath].content, commits, validPath)
|
|
2634
|
+
|
|
2635
|
+
spinnerSuccess(spinner, `Blame for ${validPath}`)
|
|
2636
|
+
|
|
2637
|
+
if (opts.json === 'true') {
|
|
2638
|
+
print(formatBlameJson(blameData), false)
|
|
2639
|
+
} else {
|
|
2640
|
+
process.stdout.write(formatBlameOutput(blameData) + '\n')
|
|
2641
|
+
}
|
|
2642
|
+
} catch (err) {
|
|
2643
|
+
spinnerFail(spinner, 'Blame failed')
|
|
2644
|
+
throw err
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
async function cmdLog(opts) {
|
|
2649
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2650
|
+
const meta = readRemoteMeta(dir)
|
|
2651
|
+
const cfg = loadConfig()
|
|
2652
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2653
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2654
|
+
|
|
2655
|
+
const spinner = createSpinner('Fetching commit history...', opts.json)
|
|
2656
|
+
|
|
2657
|
+
try {
|
|
2658
|
+
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2659
|
+
if (opts.branch) url.searchParams.set('branch', opts.branch)
|
|
2660
|
+
else url.searchParams.set('branch', meta.branch)
|
|
2661
|
+
|
|
2662
|
+
const data = await request('GET', url.toString(), null, token)
|
|
2663
|
+
let commits = Array.isArray(data) ? data : []
|
|
2664
|
+
|
|
2665
|
+
// Filter by file path if provided
|
|
2666
|
+
const filePath = opts.path
|
|
2667
|
+
if (filePath) {
|
|
2668
|
+
const filteredCommits = []
|
|
2669
|
+
for (const commit of commits) {
|
|
2670
|
+
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
|
|
2671
|
+
if (commitSnap.files[filePath] !== undefined) {
|
|
2672
|
+
filteredCommits.push(commit)
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
commits = filteredCommits
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// Filter by content pattern (-G flag)
|
|
2679
|
+
const pattern = opts.G || opts.pattern
|
|
2680
|
+
if (pattern) {
|
|
2681
|
+
const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
|
|
2682
|
+
const filteredCommits = []
|
|
2683
|
+
for (const commit of commits) {
|
|
2684
|
+
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commit.id || commit._id, token)
|
|
2685
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
2686
|
+
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {} }
|
|
2687
|
+
|
|
2688
|
+
// Check if pattern matches in any file changed in this commit
|
|
2689
|
+
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
2690
|
+
let matches = false
|
|
2691
|
+
for (const p of allPaths) {
|
|
2692
|
+
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : ''
|
|
2693
|
+
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : ''
|
|
2694
|
+
if (regex.test(oldContent) || regex.test(newContent)) {
|
|
2695
|
+
matches = true
|
|
2696
|
+
break
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
if (matches) {
|
|
2700
|
+
filteredCommits.push(commit)
|
|
2701
|
+
}
|
|
2702
|
+
}
|
|
2703
|
+
commits = filteredCommits
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
spinnerSuccess(spinner, `Found ${commits.length} commits`)
|
|
2707
|
+
|
|
2708
|
+
if (opts.json === 'true') {
|
|
2709
|
+
print(commits, true)
|
|
2710
|
+
} else if (opts.oneline === 'true') {
|
|
2711
|
+
process.stdout.write(formatCompactLog(commits) + '\n')
|
|
2712
|
+
} else if (opts.stats === 'true') {
|
|
2713
|
+
const stats = generateCommitStats(commits)
|
|
2714
|
+
process.stdout.write(color('Commit Statistics\n', 'bold'))
|
|
2715
|
+
process.stdout.write(color('━'.repeat(50) + '\n', 'dim'))
|
|
2716
|
+
process.stdout.write(`Total commits: ${stats.totalCommits}\n`)
|
|
2717
|
+
process.stdout.write(`\nTop authors:\n`)
|
|
2718
|
+
for (const author of stats.topAuthors) {
|
|
2719
|
+
process.stdout.write(` ${author.name.padEnd(30)} ${color(author.commits + ' commits', 'cyan')}\n`)
|
|
2720
|
+
}
|
|
2721
|
+
if (stats.datesRange.earliest && stats.datesRange.latest) {
|
|
2722
|
+
process.stdout.write(`\nDate range: ${stats.datesRange.earliest.toISOString().split('T')[0]} to ${stats.datesRange.latest.toISOString().split('T')[0]}\n`)
|
|
2723
|
+
}
|
|
2724
|
+
} else {
|
|
2725
|
+
const maxCommits = parseInt(opts.max || '50', 10)
|
|
2726
|
+
process.stdout.write(generateLogGraph(commits, { maxCommits }) + '\n')
|
|
2727
|
+
}
|
|
2728
|
+
} catch (err) {
|
|
2729
|
+
spinnerFail(spinner, 'Log fetch failed')
|
|
2730
|
+
throw err
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
async function cmdHook(sub, opts) {
|
|
2735
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2736
|
+
|
|
2737
|
+
if (sub === 'list') {
|
|
2738
|
+
const hooksList = await hooks.listHooks(dir)
|
|
2739
|
+
if (opts.json === 'true') {
|
|
2740
|
+
print(hooksList, true)
|
|
2741
|
+
} else {
|
|
2742
|
+
if (hooksList.length === 0) {
|
|
2743
|
+
process.stdout.write('No hooks installed.\n')
|
|
2744
|
+
} else {
|
|
2745
|
+
process.stdout.write(color('Installed Hooks:\n', 'bold'))
|
|
2746
|
+
for (const hook of hooksList) {
|
|
2747
|
+
const exe = hook.executable ? color('✓', 'green') : color('✗', 'red')
|
|
2748
|
+
process.stdout.write(` ${exe} ${color(hook.name, 'cyan')}\n`)
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
if (sub === 'install') {
|
|
2756
|
+
const hookName = opts.name
|
|
2757
|
+
if (!hookName) throw new Error('Missing --name')
|
|
2758
|
+
|
|
2759
|
+
let script = opts.script
|
|
2760
|
+
if (!script && opts.sample === 'true') {
|
|
2761
|
+
script = hooks.SAMPLE_HOOKS[hookName]
|
|
2762
|
+
if (!script) throw new Error(`No sample available for ${hookName}`)
|
|
2763
|
+
}
|
|
2764
|
+
if (!script) throw new Error('Missing --script or use --sample')
|
|
2765
|
+
|
|
2766
|
+
const result = await hooks.installHook(dir, hookName, script)
|
|
2767
|
+
print(result, opts.json === 'true')
|
|
2768
|
+
if (opts.json !== 'true') {
|
|
2769
|
+
process.stdout.write(color(`✓ Hook '${hookName}' installed\n`, 'green'))
|
|
2770
|
+
}
|
|
2771
|
+
return
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
if (sub === 'remove') {
|
|
2775
|
+
const hookName = opts.name
|
|
2776
|
+
if (!hookName) throw new Error('Missing --name')
|
|
2777
|
+
|
|
2778
|
+
const result = await hooks.removeHook(dir, hookName)
|
|
2779
|
+
print(result, opts.json === 'true')
|
|
2780
|
+
return
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
if (sub === 'show') {
|
|
2784
|
+
const hookName = opts.name
|
|
2785
|
+
if (!hookName) throw new Error('Missing --name')
|
|
2786
|
+
|
|
2787
|
+
const result = await hooks.readHook(dir, hookName)
|
|
2788
|
+
if (result.content) {
|
|
2789
|
+
process.stdout.write(result.content + '\n')
|
|
2790
|
+
} else {
|
|
2791
|
+
process.stdout.write(`Hook '${hookName}' not found.\n`)
|
|
2792
|
+
}
|
|
2793
|
+
return
|
|
2794
|
+
}
|
|
2795
|
+
|
|
2796
|
+
throw new Error('Unknown hook subcommand. Use: list, install, remove, show')
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
async function cmdGrep(opts) {
|
|
2800
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2801
|
+
const pattern = opts.pattern || opts.p || ''
|
|
2802
|
+
if (!pattern) throw new Error('Missing --pattern or -p')
|
|
2803
|
+
|
|
2804
|
+
const meta = readRemoteMeta(dir)
|
|
2805
|
+
const local = await collectLocal(dir)
|
|
2806
|
+
const results = []
|
|
2807
|
+
|
|
2808
|
+
const regex = new RegExp(pattern, opts.ignoreCase === 'true' ? 'i' : '')
|
|
2809
|
+
|
|
2810
|
+
for (const [filePath, fileData] of Object.entries(local)) {
|
|
2811
|
+
const lines = fileData.content.split(/\r?\n/)
|
|
2812
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2813
|
+
if (regex.test(lines[i])) {
|
|
2814
|
+
results.push({
|
|
2815
|
+
path: filePath,
|
|
2816
|
+
line: i + 1,
|
|
2817
|
+
content: lines[i].trim()
|
|
2818
|
+
})
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
if (opts.json === 'true') {
|
|
2824
|
+
print(results, true)
|
|
2825
|
+
} else {
|
|
2826
|
+
for (const result of results) {
|
|
2827
|
+
process.stdout.write(color(`${result.path}:${result.line}`, 'cyan'))
|
|
2828
|
+
process.stdout.write(`: ${result.content}\n`)
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
async function cmdLsFiles(opts) {
|
|
2834
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2835
|
+
const meta = readRemoteMeta(dir)
|
|
2836
|
+
const cfg = loadConfig()
|
|
2837
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2838
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2839
|
+
|
|
2840
|
+
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
2841
|
+
const files = Object.keys(remote.map)
|
|
2842
|
+
|
|
2843
|
+
if (opts.json === 'true') {
|
|
2844
|
+
print(files.map(f => ({ path: f })), true)
|
|
2845
|
+
} else {
|
|
2846
|
+
for (const file of files) {
|
|
2847
|
+
process.stdout.write(`${file}\n`)
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
async function cmdReflog(opts) {
|
|
2853
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2854
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2855
|
+
const reflogPath = path.join(metaDir, 'reflog.json')
|
|
2856
|
+
|
|
2857
|
+
let reflog = []
|
|
2858
|
+
try {
|
|
2859
|
+
const content = await fs.promises.readFile(reflogPath, 'utf8')
|
|
2860
|
+
reflog = JSON.parse(content)
|
|
2861
|
+
} catch {}
|
|
2862
|
+
|
|
2863
|
+
if (opts.json === 'true') {
|
|
2864
|
+
print(reflog, true)
|
|
2865
|
+
} else {
|
|
2866
|
+
if (reflog.length === 0) {
|
|
2867
|
+
process.stdout.write('No reflog entries.\n')
|
|
2868
|
+
} else {
|
|
2869
|
+
for (const entry of reflog) {
|
|
2870
|
+
const date = new Date(entry.timestamp).toLocaleString()
|
|
2871
|
+
process.stdout.write(`${color(entry.commitId.slice(0, 7), 'yellow')} ${entry.action} ${date} ${entry.message || ''}\n`)
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
async function cmdCatFile(opts) {
|
|
2878
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2879
|
+
const type = opts.type || ''
|
|
2880
|
+
const object = opts.object || ''
|
|
2881
|
+
|
|
2882
|
+
if (!type || !object) throw new Error('Missing --type and --object')
|
|
2883
|
+
|
|
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
|
+
if (type === 'blob') {
|
|
2890
|
+
const snap = await fetchSnapshotByCommit(server, meta.repoId, object, token)
|
|
2891
|
+
const filePath = opts.path || ''
|
|
2892
|
+
if (filePath && snap.files[filePath]) {
|
|
2893
|
+
process.stdout.write(String(snap.files[filePath]))
|
|
2894
|
+
} else {
|
|
2895
|
+
throw new Error('File not found in commit')
|
|
2896
|
+
}
|
|
2897
|
+
} else if (type === 'commit') {
|
|
2898
|
+
const commit = await fetchCommitMeta(server, meta.repoId, object, token)
|
|
2899
|
+
if (opts.json === 'true') {
|
|
2900
|
+
print(commit, true)
|
|
2901
|
+
} else {
|
|
2902
|
+
process.stdout.write(`commit ${commit.id || commit._id}\n`)
|
|
2903
|
+
process.stdout.write(`Author: ${commit.author?.name || ''} <${commit.author?.email || ''}>\n`)
|
|
2904
|
+
process.stdout.write(`Date: ${new Date(commit.createdAt || commit.committer?.date || '').toLocaleString()}\n\n`)
|
|
2905
|
+
process.stdout.write(`${commit.message || ''}\n`)
|
|
2906
|
+
}
|
|
2907
|
+
} else {
|
|
2908
|
+
throw new Error(`Unsupported type: ${type}`)
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
async function cmdRevParse(opts) {
|
|
2913
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2914
|
+
const rev = opts.rev || 'HEAD'
|
|
2915
|
+
|
|
2916
|
+
const meta = readRemoteMeta(dir)
|
|
2917
|
+
const cfg = loadConfig()
|
|
2918
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2919
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2920
|
+
|
|
2921
|
+
if (rev === 'HEAD') {
|
|
2922
|
+
const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
2923
|
+
const found = (info.branches || []).find(b => b.name === meta.branch)
|
|
2924
|
+
const commitId = found ? (found.commitId || '') : ''
|
|
2925
|
+
print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
|
|
2926
|
+
} else if (rev.startsWith('refs/heads/')) {
|
|
2927
|
+
const branchName = rev.replace('refs/heads/', '')
|
|
2928
|
+
const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
2929
|
+
const found = (info.branches || []).find(b => b.name === branchName)
|
|
2930
|
+
const commitId = found ? (found.commitId || '') : ''
|
|
2931
|
+
print(opts.json === 'true' ? { rev, commitId } : commitId, opts.json === 'true')
|
|
2932
|
+
} else {
|
|
2933
|
+
// Assume it's a commit ID
|
|
2934
|
+
print(opts.json === 'true' ? { rev, commitId: rev } : rev, opts.json === 'true')
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
async function cmdDescribe(opts) {
|
|
2939
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2940
|
+
const commitId = opts.commit || 'HEAD'
|
|
2941
|
+
|
|
2942
|
+
const meta = readRemoteMeta(dir)
|
|
2943
|
+
const cfg = loadConfig()
|
|
2944
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2945
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2946
|
+
|
|
2947
|
+
// Get tags
|
|
2948
|
+
const tagsUrl = new URL(`/api/repositories/${meta.repoId}/tags`, server)
|
|
2949
|
+
const tags = await request('GET', tagsUrl.toString(), null, token)
|
|
2950
|
+
const tagsList = Array.isArray(tags) ? tags : []
|
|
2951
|
+
|
|
2952
|
+
// Find nearest tag (simplified - just find any tag)
|
|
2953
|
+
const nearestTag = tagsList[0]
|
|
2954
|
+
|
|
2955
|
+
if (nearestTag) {
|
|
2956
|
+
const desc = `${nearestTag.name}-0-g${commitId.slice(0, 7)}`
|
|
2957
|
+
print(opts.json === 'true' ? { tag: nearestTag.name, commitId, describe: desc } : desc, opts.json === 'true')
|
|
2958
|
+
} else {
|
|
2959
|
+
print(opts.json === 'true' ? { commitId, describe: commitId.slice(0, 7) } : commitId.slice(0, 7), opts.json === 'true')
|
|
2960
|
+
}
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
async function cmdShortlog(opts) {
|
|
2964
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2965
|
+
const meta = readRemoteMeta(dir)
|
|
2966
|
+
const cfg = loadConfig()
|
|
2967
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2968
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2969
|
+
|
|
2970
|
+
const url = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2971
|
+
if (opts.branch) url.searchParams.set('branch', opts.branch)
|
|
2972
|
+
else url.searchParams.set('branch', meta.branch)
|
|
2973
|
+
|
|
2974
|
+
const commits = await request('GET', url.toString(), null, token)
|
|
2975
|
+
const commitsList = Array.isArray(commits) ? commits : []
|
|
2976
|
+
|
|
2977
|
+
const stats = generateCommitStats(commitsList)
|
|
2978
|
+
|
|
2979
|
+
if (opts.json === 'true') {
|
|
2980
|
+
print(stats, true)
|
|
2981
|
+
} else {
|
|
2982
|
+
for (const author of stats.topAuthors) {
|
|
2983
|
+
process.stdout.write(`\n${color(author.name, 'bold')} (${author.commits} commit${author.commits !== 1 ? 's' : ''})\n`)
|
|
2984
|
+
// Show commit messages for this author
|
|
2985
|
+
const authorCommits = commitsList.filter(c => (c.author?.name || 'Unknown') === author.name)
|
|
2986
|
+
for (const commit of authorCommits.slice(0, opts.max ? parseInt(opts.max, 10) : 10)) {
|
|
2987
|
+
const msg = (commit.message || 'No message').split('\n')[0].slice(0, 60)
|
|
2988
|
+
process.stdout.write(` ${color((commit.id || commit._id || '').slice(0, 7), 'yellow')} ${msg}\n`)
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2004
2994
|
function help() {
|
|
2005
2995
|
const h = [
|
|
2006
2996
|
'Usage: resulgit <group> <command> [options]',
|
|
@@ -2012,40 +3002,57 @@ function help() {
|
|
|
2012
3002
|
' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
|
|
2013
3003
|
' repo list [--json]',
|
|
2014
3004
|
' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
' branch list|create|delete [--dir <path>] [--name <branch>] [--base <branch>]',
|
|
2019
|
-
' switch --branch <name> [--dir <path>]',
|
|
3005
|
+
' repo log --repo <id> [--branch <name>] [--json]',
|
|
3006
|
+
' repo head --repo <id> [--branch <name>] [--json]',
|
|
3007
|
+
' repo select [--workspace] (interactive select and clone/open)',
|
|
3008
|
+
' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>] [--sort <field>] [--force|-D]',
|
|
3009
|
+
' switch --branch <name> [--create|-c] [--dir <path>]',
|
|
3010
|
+
' checkout --branch <name> [--create|-b] [--commit <id>] [--dir <path>]',
|
|
2020
3011
|
' current [--dir <path>] (show active repo/branch)',
|
|
2021
3012
|
' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
|
|
2022
3013
|
' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
|
|
2023
3014
|
' status [--dir <path>] [--json]',
|
|
2024
|
-
' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
|
|
2025
|
-
' commit --message <text> [--dir <path>] [--json]',
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
3015
|
+
' diff [--dir <path>] [--path <file>] [--commit <id>] [--commit1 <id>] [--commit2 <id>] [--stat] [--json]',
|
|
3016
|
+
' commit --message <text> [--all|-a] [--amend] [--dir <path>] [--json]',
|
|
3017
|
+
' push [--dir <path>] [--json]',
|
|
3018
|
+
' head [--dir <path>] [--json]',
|
|
3019
|
+
' rm --path <file> [--cached] [--dir <path>] [--json]',
|
|
2029
3020
|
' pull [--dir <path>]',
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
'
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
'
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
3021
|
+
' fetch [--dir <path>]',
|
|
3022
|
+
' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
|
|
3023
|
+
' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
|
|
3024
|
+
' restore --path <file> [--source <commit>] [--dir <path>]',
|
|
3025
|
+
' revert --commit <id> [--no-push] [--dir <path>]',
|
|
3026
|
+
' reset [--commit <id>|HEAD^] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
|
|
3027
|
+
' show --commit <id> [--dir <path>] [--json]',
|
|
3028
|
+
' mv --from <old> --to <new> [--dir <path>]',
|
|
3029
|
+
' blame --path <file> [--dir <path>] [--json] - Show line-by-line authorship',
|
|
3030
|
+
' log [--branch <name>] [--max <N>] [--oneline] [--stats] [--path <file>] [-G <pattern>] [--json] - Show commit history',
|
|
3031
|
+
' hook list|install|remove|show [--name <hook>] [--script <code>] [--sample] - Manage Git hooks',
|
|
3032
|
+
' grep --pattern <pattern> [--ignore-case] [--dir <path>] [--json] - Search in repository',
|
|
3033
|
+
' ls-files [--dir <path>] [--json] - List tracked files',
|
|
3034
|
+
' reflog [--dir <path>] [--json] - Show reference log',
|
|
3035
|
+
' cat-file --type <type> --object <id> [--path <file>] [--dir <path>] [--json] - Display file contents',
|
|
3036
|
+
' rev-parse --rev <ref> [--dir <path>] [--json] - Parse revision names',
|
|
3037
|
+
' describe [--commit <id>] [--dir <path>] [--json] - Describe a commit',
|
|
3038
|
+
' shortlog [--branch <name>] [--max <N>] [--dir <path>] [--json] - Summarize commit log',
|
|
3039
|
+
'',
|
|
3040
|
+
'Conflict Resolution:',
|
|
3041
|
+
' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
|
|
3042
|
+
' <<<<<<< HEAD (current changes)',
|
|
3043
|
+
' =======',
|
|
3044
|
+
' >>>>>>> incoming (incoming changes)',
|
|
3045
|
+
' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
|
|
3046
|
+
' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
|
|
3047
|
+
' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
|
|
3048
|
+
' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
|
|
3049
|
+
' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
|
|
3050
|
+
' config list|get|set [--key <k>] [--value <v>]',
|
|
3051
|
+
' clean [--dir <path>] [--force]',
|
|
3052
|
+
' workspace set-root --path <dir>',
|
|
3053
|
+
' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
|
|
3054
|
+
' checkout <branch>|--branch <name> | --commit <id> [--create] [--dir <path>]',
|
|
3055
|
+
' rebase --branch <name> [--onto <name>] [--no-push] [--dir <path>]',
|
|
2049
3056
|
'',
|
|
2050
3057
|
'Global options:',
|
|
2051
3058
|
' --server <url> Override default server',
|
|
@@ -2071,7 +3078,6 @@ async function main() {
|
|
|
2071
3078
|
return
|
|
2072
3079
|
}
|
|
2073
3080
|
if (cmd[0] === 'clone') {
|
|
2074
|
-
print({ url: 'Rajaram' }, opts.json === 'true')
|
|
2075
3081
|
if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
|
|
2076
3082
|
if (opts.url) {
|
|
2077
3083
|
await cmdCloneFromUrl(opts, cfg)
|
|
@@ -2104,6 +3110,10 @@ async function main() {
|
|
|
2104
3110
|
await cmdPull(opts)
|
|
2105
3111
|
return
|
|
2106
3112
|
}
|
|
3113
|
+
if (cmd[0] === 'fetch') {
|
|
3114
|
+
await cmdFetch(opts)
|
|
3115
|
+
return
|
|
3116
|
+
}
|
|
2107
3117
|
if (cmd[0] === 'branch') {
|
|
2108
3118
|
await cmdBranch(cmd[1], opts)
|
|
2109
3119
|
return
|
|
@@ -2148,6 +3158,26 @@ async function main() {
|
|
|
2148
3158
|
await cmdMv(opts)
|
|
2149
3159
|
return
|
|
2150
3160
|
}
|
|
3161
|
+
if (cmd[0] === 'init') {
|
|
3162
|
+
await cmdInit(opts)
|
|
3163
|
+
return
|
|
3164
|
+
}
|
|
3165
|
+
if (cmd[0] === 'remote') {
|
|
3166
|
+
await cmdRemote(cmd[1], opts)
|
|
3167
|
+
return
|
|
3168
|
+
}
|
|
3169
|
+
if (cmd[0] === 'config') {
|
|
3170
|
+
await cmdConfig(cmd[1], opts)
|
|
3171
|
+
return
|
|
3172
|
+
}
|
|
3173
|
+
if (cmd[0] === 'clean') {
|
|
3174
|
+
await cmdClean(opts)
|
|
3175
|
+
return
|
|
3176
|
+
}
|
|
3177
|
+
if (cmd[0] === 'rebase') {
|
|
3178
|
+
await cmdRebase(opts)
|
|
3179
|
+
return
|
|
3180
|
+
}
|
|
2151
3181
|
if (cmd[0] === 'cherry-pick') {
|
|
2152
3182
|
await cmdCherryPick(opts)
|
|
2153
3183
|
return
|
|
@@ -2180,6 +3210,46 @@ async function main() {
|
|
|
2180
3210
|
await cmdAdd(opts)
|
|
2181
3211
|
return
|
|
2182
3212
|
}
|
|
3213
|
+
if (cmd[0] === 'blame') {
|
|
3214
|
+
await cmdBlame(opts)
|
|
3215
|
+
return
|
|
3216
|
+
}
|
|
3217
|
+
if (cmd[0] === 'log') {
|
|
3218
|
+
await cmdLog(opts)
|
|
3219
|
+
return
|
|
3220
|
+
}
|
|
3221
|
+
if (cmd[0] === 'hook') {
|
|
3222
|
+
await cmdHook(cmd[1], opts)
|
|
3223
|
+
return
|
|
3224
|
+
}
|
|
3225
|
+
if (cmd[0] === 'grep') {
|
|
3226
|
+
await cmdGrep(opts)
|
|
3227
|
+
return
|
|
3228
|
+
}
|
|
3229
|
+
if (cmd[0] === 'ls-files') {
|
|
3230
|
+
await cmdLsFiles(opts)
|
|
3231
|
+
return
|
|
3232
|
+
}
|
|
3233
|
+
if (cmd[0] === 'reflog') {
|
|
3234
|
+
await cmdReflog(opts)
|
|
3235
|
+
return
|
|
3236
|
+
}
|
|
3237
|
+
if (cmd[0] === 'cat-file') {
|
|
3238
|
+
await cmdCatFile(opts)
|
|
3239
|
+
return
|
|
3240
|
+
}
|
|
3241
|
+
if (cmd[0] === 'rev-parse') {
|
|
3242
|
+
await cmdRevParse(opts)
|
|
3243
|
+
return
|
|
3244
|
+
}
|
|
3245
|
+
if (cmd[0] === 'describe') {
|
|
3246
|
+
await cmdDescribe(opts)
|
|
3247
|
+
return
|
|
3248
|
+
}
|
|
3249
|
+
if (cmd[0] === 'shortlog') {
|
|
3250
|
+
await cmdShortlog(opts)
|
|
3251
|
+
return
|
|
3252
|
+
}
|
|
2183
3253
|
throw new Error('Unknown command')
|
|
2184
3254
|
}
|
|
2185
3255
|
|
|
@@ -2316,7 +3386,7 @@ async function tuiSelectBranch(branches, current) {
|
|
|
2316
3386
|
process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
|
|
2317
3387
|
}
|
|
2318
3388
|
const cleanup = (listener) => {
|
|
2319
|
-
try { process.stdin.removeListener('data', listener) } catch {}
|
|
3389
|
+
try { process.stdin.removeListener('data', listener) } catch { }
|
|
2320
3390
|
process.stdin.setRawMode(false)
|
|
2321
3391
|
process.stdin.pause()
|
|
2322
3392
|
process.stdout.write('\x1b[?25h')
|