resulgit 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -7
- package/resulgit.js +730 -348
package/resulgit.js
CHANGED
|
@@ -3,9 +3,27 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const os = require('os')
|
|
5
5
|
const crypto = require('crypto')
|
|
6
|
+
const ora = require('ora')
|
|
6
7
|
const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
|
|
7
8
|
function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
|
|
8
9
|
|
|
10
|
+
function createSpinner(text, jsonMode) {
|
|
11
|
+
if (jsonMode === 'true' || !process.stdout.isTTY) return null
|
|
12
|
+
return ora(text).start()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function spinnerSuccess(spinner, text) {
|
|
16
|
+
if (spinner) spinner.succeed(text)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function spinnerFail(spinner, text) {
|
|
20
|
+
if (spinner) spinner.fail(text)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function spinnerUpdate(spinner, text) {
|
|
24
|
+
if (spinner) spinner.text = text
|
|
25
|
+
}
|
|
26
|
+
|
|
9
27
|
function parseArgs(argv) {
|
|
10
28
|
const tokens = argv.slice(2)
|
|
11
29
|
const cmd = []
|
|
@@ -106,11 +124,17 @@ async function cmdAuth(sub, opts) {
|
|
|
106
124
|
const password = opts.password
|
|
107
125
|
if (!email || !password) throw new Error('Missing --email and --password')
|
|
108
126
|
const url = new URL('/api/auth/login', server).toString()
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
127
|
+
const spinner = createSpinner('Logging in...', opts.json)
|
|
128
|
+
try {
|
|
129
|
+
const res = await request('POST', url, { email, password }, '')
|
|
130
|
+
const token = res.token || ''
|
|
131
|
+
if (token) saveConfig({ token })
|
|
132
|
+
spinnerSuccess(spinner, `Logged in as ${email}`)
|
|
133
|
+
print(res, opts.json === 'true')
|
|
134
|
+
} catch (err) {
|
|
135
|
+
spinnerFail(spinner, 'Login failed')
|
|
136
|
+
throw err
|
|
137
|
+
}
|
|
114
138
|
return
|
|
115
139
|
}
|
|
116
140
|
if (sub === 'register') {
|
|
@@ -121,10 +145,17 @@ async function cmdAuth(sub, opts) {
|
|
|
121
145
|
const displayName = opts.displayName || username
|
|
122
146
|
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
123
147
|
const url = new URL('/api/auth/register', server).toString()
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
148
|
+
const spinner = createSpinner('Registering account...', opts.json)
|
|
149
|
+
try {
|
|
150
|
+
const res = await request('POST', url, { username, email, password, displayName }, '')
|
|
151
|
+
const token = res.token || ''
|
|
152
|
+
if (token) saveConfig({ token })
|
|
153
|
+
spinnerSuccess(spinner, `Registered as ${username}`)
|
|
154
|
+
print(res, opts.json === 'true')
|
|
155
|
+
} catch (err) {
|
|
156
|
+
spinnerFail(spinner, 'Registration failed')
|
|
157
|
+
throw err
|
|
158
|
+
}
|
|
128
159
|
return
|
|
129
160
|
}
|
|
130
161
|
throw new Error('Unknown auth subcommand')
|
|
@@ -134,10 +165,17 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
134
165
|
const server = getServer(opts, cfg)
|
|
135
166
|
const token = getToken(opts, cfg)
|
|
136
167
|
if (sub === 'list') {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
const spinner = createSpinner('Fetching repositories...', opts.json)
|
|
169
|
+
try {
|
|
170
|
+
const url = new URL('/api/repositories', server).toString()
|
|
171
|
+
const data = await request('GET', url, null, token)
|
|
172
|
+
spinnerSuccess(spinner, `Found ${(data || []).length} repositories`)
|
|
173
|
+
print(data, opts.json === 'true')
|
|
174
|
+
await tuiSelectRepo(data || [], cfg, opts)
|
|
175
|
+
} catch (err) {
|
|
176
|
+
spinnerFail(spinner, 'Failed to fetch repositories')
|
|
177
|
+
throw err
|
|
178
|
+
}
|
|
141
179
|
return
|
|
142
180
|
}
|
|
143
181
|
if (sub === 'create') {
|
|
@@ -148,14 +186,21 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
148
186
|
initializeWithReadme: opts.init === 'true'
|
|
149
187
|
}
|
|
150
188
|
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')
|
|
189
|
+
const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
|
|
154
190
|
try {
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
191
|
+
const url = new URL('/api/repositories', server).toString()
|
|
192
|
+
const data = await request('POST', url, body, token)
|
|
193
|
+
spinnerSuccess(spinner, `Repository '${body.name}' created`)
|
|
194
|
+
print(data, opts.json === 'true')
|
|
195
|
+
try {
|
|
196
|
+
const repoId = String(data.id || '')
|
|
197
|
+
const branch = String(data.defaultBranch || 'main')
|
|
198
|
+
if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
|
|
199
|
+
} catch { }
|
|
200
|
+
} catch (err) {
|
|
201
|
+
spinnerFail(spinner, `Failed to create repository '${body.name}'`)
|
|
202
|
+
throw err
|
|
203
|
+
}
|
|
159
204
|
return
|
|
160
205
|
}
|
|
161
206
|
if (sub === 'log') {
|
|
@@ -224,91 +269,105 @@ async function cmdClone(opts, cfg) {
|
|
|
224
269
|
const branch = opts.branch
|
|
225
270
|
let dest = opts.dest
|
|
226
271
|
if (!repo || !branch) throw new Error('Missing --repo and --branch')
|
|
272
|
+
const spinner = createSpinner('Initializing clone...', opts.json)
|
|
227
273
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
274
|
+
try {
|
|
275
|
+
if (!dest) {
|
|
276
|
+
spinnerUpdate(spinner, 'Fetching repository info...')
|
|
277
|
+
try {
|
|
278
|
+
const infoUrl = new URL(`/api/repositories/${repo}`, server)
|
|
279
|
+
const infoRes = await fetch(infoUrl.toString(), { headers })
|
|
280
|
+
if (infoRes.ok) {
|
|
281
|
+
const info = await infoRes.json()
|
|
282
|
+
const name = info.name || String(repo)
|
|
283
|
+
dest = name
|
|
284
|
+
} else {
|
|
285
|
+
dest = String(repo)
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
237
288
|
dest = String(repo)
|
|
238
289
|
}
|
|
239
|
-
} catch {
|
|
240
|
-
dest = String(repo)
|
|
241
290
|
}
|
|
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))
|
|
291
|
+
dest = path.resolve(dest)
|
|
292
|
+
spinnerUpdate(spinner, `Downloading snapshot from branch '${branch}'...`)
|
|
293
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
294
|
+
url.searchParams.set('branch', branch)
|
|
295
|
+
const res = await fetch(url.toString(), { headers })
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
const text = await res.text()
|
|
298
|
+
throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
|
|
300
299
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
await fs.promises.
|
|
300
|
+
const data = await res.json()
|
|
301
|
+
const files = data.files || {}
|
|
302
|
+
const fileCount = Object.keys(files).length
|
|
303
|
+
spinnerUpdate(spinner, `Writing ${fileCount} files to ${dest}...`)
|
|
304
|
+
const root = dest
|
|
305
|
+
for (const [p, content] of Object.entries(files)) {
|
|
306
|
+
const fullPath = path.join(root, p)
|
|
307
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
308
|
+
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
309
309
|
}
|
|
310
|
-
|
|
311
|
-
|
|
310
|
+
spinnerUpdate(spinner, 'Setting up repository metadata...')
|
|
311
|
+
const metaDir = path.join(root, '.vcs-next')
|
|
312
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
313
|
+
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
314
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
315
|
+
const gitDir = path.join(root, '.git')
|
|
316
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
317
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
318
|
+
const headContent = `ref: refs/heads/${branch}\\n`
|
|
319
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
320
|
+
const commitId = data.commitId || ''
|
|
321
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
322
|
+
const gitConfig = [
|
|
323
|
+
'[core]',
|
|
324
|
+
'\\trepositoryformatversion = 0',
|
|
325
|
+
'\\tfilemode = true',
|
|
326
|
+
'\\tbare = false',
|
|
327
|
+
'\\tlogallrefupdates = true',
|
|
328
|
+
'',
|
|
329
|
+
'[vcs-next]',
|
|
330
|
+
`\\tserver = ${server}`,
|
|
331
|
+
`\\trepoId = ${repo}`,
|
|
332
|
+
`\\tbranch = ${branch}`,
|
|
333
|
+
`\\ttoken = ${token || ''}`
|
|
334
|
+
].join('\\n')
|
|
335
|
+
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
336
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
|
|
337
|
+
spinnerUpdate(spinner, 'Fetching branch information...')
|
|
338
|
+
try {
|
|
339
|
+
const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
|
|
340
|
+
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
341
|
+
if (branchesRes.ok) {
|
|
342
|
+
const branchesData = await branchesRes.json()
|
|
343
|
+
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
344
|
+
const allRefs = {}
|
|
345
|
+
for (const b of branches) {
|
|
346
|
+
const name = b.name || ''
|
|
347
|
+
const id = b.commitId || ''
|
|
348
|
+
if (!name) continue
|
|
349
|
+
allRefs[name] = id
|
|
350
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
351
|
+
}
|
|
352
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
353
|
+
}
|
|
354
|
+
} catch { }
|
|
355
|
+
spinnerUpdate(spinner, 'Fetching commit history...')
|
|
356
|
+
try {
|
|
357
|
+
const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
|
|
358
|
+
commitsUrl.searchParams.set('branch', branch)
|
|
359
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
360
|
+
if (commitsRes.ok) {
|
|
361
|
+
const commitsList = await commitsRes.json()
|
|
362
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
363
|
+
}
|
|
364
|
+
} catch { }
|
|
365
|
+
spinnerSuccess(spinner, `Cloned to ${dest} (${fileCount} files)`)
|
|
366
|
+
print('Clone complete', opts.json === 'true')
|
|
367
|
+
} catch (err) {
|
|
368
|
+
spinnerFail(spinner, 'Clone failed')
|
|
369
|
+
throw err
|
|
370
|
+
}
|
|
312
371
|
}
|
|
313
372
|
|
|
314
373
|
function readRemoteMeta(dir) {
|
|
@@ -324,7 +383,7 @@ function readRemoteMeta(dir) {
|
|
|
324
383
|
const s = fs.readFileSync(fp, 'utf8')
|
|
325
384
|
const meta = JSON.parse(s)
|
|
326
385
|
if (meta.repoId && meta.branch) return meta
|
|
327
|
-
} catch {}
|
|
386
|
+
} catch { }
|
|
328
387
|
}
|
|
329
388
|
const parent = path.dirname(cur)
|
|
330
389
|
if (parent === cur) break
|
|
@@ -421,7 +480,7 @@ async function cmdRestore(opts) {
|
|
|
421
480
|
const cfg = loadConfig()
|
|
422
481
|
const server = getServer(opts, cfg) || meta.server
|
|
423
482
|
const token = getToken(opts, cfg) || meta.token
|
|
424
|
-
|
|
483
|
+
|
|
425
484
|
const sourceCommit = opts.source || 'HEAD'
|
|
426
485
|
let sourceSnap
|
|
427
486
|
if (sourceCommit === 'HEAD') {
|
|
@@ -429,7 +488,7 @@ async function cmdRestore(opts) {
|
|
|
429
488
|
} else {
|
|
430
489
|
sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
|
|
431
490
|
}
|
|
432
|
-
|
|
491
|
+
|
|
433
492
|
const sourceContent = sourceSnap.files[filePath]
|
|
434
493
|
if (sourceContent === undefined) {
|
|
435
494
|
// File doesn't exist in source - delete it
|
|
@@ -464,17 +523,17 @@ async function cmdDiff(opts) {
|
|
|
464
523
|
const cfg = loadConfig()
|
|
465
524
|
const server = getServer(opts, cfg) || meta.server
|
|
466
525
|
const token = getToken(opts, cfg) || meta.token
|
|
467
|
-
|
|
526
|
+
|
|
468
527
|
const filePath = opts.path
|
|
469
528
|
const commitId = opts.commit
|
|
470
|
-
|
|
529
|
+
|
|
471
530
|
if (commitId) {
|
|
472
531
|
// Show diff for specific commit
|
|
473
532
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
474
533
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
475
534
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
476
535
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
477
|
-
|
|
536
|
+
|
|
478
537
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
|
|
479
538
|
for (const p of files) {
|
|
480
539
|
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
@@ -510,18 +569,18 @@ async function cmdDiff(opts) {
|
|
|
510
569
|
}
|
|
511
570
|
return
|
|
512
571
|
}
|
|
513
|
-
|
|
572
|
+
|
|
514
573
|
// Show diff for working directory
|
|
515
574
|
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
516
575
|
const local = await collectLocal(dir)
|
|
517
576
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
|
|
518
|
-
|
|
577
|
+
|
|
519
578
|
for (const p of files) {
|
|
520
579
|
const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
|
|
521
580
|
const localContent = local[p]?.content || null
|
|
522
581
|
const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
|
|
523
582
|
const localId = local[p]?.id
|
|
524
|
-
|
|
583
|
+
|
|
525
584
|
if (remoteId !== localId) {
|
|
526
585
|
if (opts.json === 'true') {
|
|
527
586
|
print({ path: p, remote: remoteContent, local: localContent }, true)
|
|
@@ -578,41 +637,51 @@ async function cmdCommit(opts) {
|
|
|
578
637
|
const dir = path.resolve(opts.dir || '.')
|
|
579
638
|
const message = opts.message || ''
|
|
580
639
|
if (!message) throw new Error('Missing --message')
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
640
|
+
|
|
641
|
+
const spinner = createSpinner('Preparing commit...', opts.json)
|
|
642
|
+
try {
|
|
643
|
+
// Check for unresolved conflicts
|
|
644
|
+
spinnerUpdate(spinner, 'Checking for conflicts...')
|
|
645
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
646
|
+
if (unresolvedConflicts.length > 0) {
|
|
647
|
+
spinnerFail(spinner, 'Cannot commit with unresolved conflicts')
|
|
648
|
+
if (opts.json === 'true') {
|
|
649
|
+
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
650
|
+
} else {
|
|
651
|
+
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
652
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
653
|
+
for (const p of unresolvedConflicts) {
|
|
654
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
655
|
+
}
|
|
656
|
+
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
592
657
|
}
|
|
593
|
-
|
|
658
|
+
return
|
|
594
659
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
660
|
+
|
|
661
|
+
spinnerUpdate(spinner, 'Collecting changes...')
|
|
662
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
663
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
664
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
665
|
+
try {
|
|
666
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
667
|
+
localMeta = JSON.parse(s)
|
|
668
|
+
} catch { }
|
|
669
|
+
const local = await collectLocal(dir)
|
|
670
|
+
const files = {}
|
|
671
|
+
for (const [p, v] of Object.entries(local)) files[p] = v.content
|
|
672
|
+
localMeta.pendingCommit = { message, files, createdAt: Date.now() }
|
|
673
|
+
// Clear conflicts if they were resolved
|
|
674
|
+
if (localMeta.conflicts) {
|
|
675
|
+
delete localMeta.conflicts
|
|
676
|
+
}
|
|
677
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
678
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
679
|
+
spinnerSuccess(spinner, `Staged changes for commit: "${message}"`)
|
|
680
|
+
print({ pendingCommit: message }, opts.json === 'true')
|
|
681
|
+
} catch (err) {
|
|
682
|
+
spinnerFail(spinner, 'Commit failed')
|
|
683
|
+
throw err
|
|
612
684
|
}
|
|
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
685
|
}
|
|
617
686
|
|
|
618
687
|
async function pullToDir(repo, branch, dir, server, token) {
|
|
@@ -637,7 +706,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
637
706
|
for (const rel of Object.keys(localMap)) {
|
|
638
707
|
if (!keep.has(rel)) {
|
|
639
708
|
const fp = path.join(root, rel)
|
|
640
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
709
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
641
710
|
}
|
|
642
711
|
}
|
|
643
712
|
const pruneEmptyDirs = async (start) => {
|
|
@@ -650,7 +719,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
650
719
|
if (st.isDirectory()) {
|
|
651
720
|
await pruneEmptyDirs(p)
|
|
652
721
|
const left = await fs.promises.readdir(p).catch(() => [])
|
|
653
|
-
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
|
|
722
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
|
|
654
723
|
}
|
|
655
724
|
}
|
|
656
725
|
}
|
|
@@ -676,8 +745,62 @@ async function cmdPull(opts) {
|
|
|
676
745
|
const cfg = loadConfig()
|
|
677
746
|
const server = getServer(opts, cfg) || meta.server
|
|
678
747
|
const token = getToken(opts, cfg) || meta.token
|
|
679
|
-
|
|
680
|
-
|
|
748
|
+
const spinner = createSpinner(`Pulling from branch '${meta.branch}'...`, opts.json)
|
|
749
|
+
try {
|
|
750
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
751
|
+
spinnerSuccess(spinner, `Pulled latest changes from '${meta.branch}'`)
|
|
752
|
+
print('Pull complete', opts.json === 'true')
|
|
753
|
+
} catch (err) {
|
|
754
|
+
spinnerFail(spinner, 'Pull failed')
|
|
755
|
+
throw err
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
async function cmdFetch(opts) {
|
|
760
|
+
const dir = path.resolve(opts.dir || '.')
|
|
761
|
+
const meta = readRemoteMeta(dir)
|
|
762
|
+
const cfg = loadConfig()
|
|
763
|
+
const server = getServer(opts, cfg) || meta.server
|
|
764
|
+
const token = getToken(opts, cfg) || meta.token
|
|
765
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
766
|
+
const gitDir = path.join(dir, '.git')
|
|
767
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
768
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
769
|
+
try {
|
|
770
|
+
const branchesUrl = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
771
|
+
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
772
|
+
if (branchesRes.ok) {
|
|
773
|
+
const branchesData = await branchesRes.json()
|
|
774
|
+
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
775
|
+
const allRefs = {}
|
|
776
|
+
for (const b of branches) {
|
|
777
|
+
const name = b.name || ''
|
|
778
|
+
const id = b.commitId || ''
|
|
779
|
+
if (!name) continue
|
|
780
|
+
allRefs[name] = id
|
|
781
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
782
|
+
}
|
|
783
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
784
|
+
const curId = allRefs[meta.branch] || ''
|
|
785
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
786
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
787
|
+
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
788
|
+
let remoteMeta = meta
|
|
789
|
+
try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
|
|
790
|
+
remoteMeta.commitId = curId
|
|
791
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
792
|
+
}
|
|
793
|
+
} catch { }
|
|
794
|
+
try {
|
|
795
|
+
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
796
|
+
commitsUrl.searchParams.set('branch', meta.branch)
|
|
797
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
798
|
+
if (commitsRes.ok) {
|
|
799
|
+
const commitsList = await commitsRes.json()
|
|
800
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
801
|
+
}
|
|
802
|
+
} catch { }
|
|
803
|
+
print('Fetch complete', opts.json === 'true')
|
|
681
804
|
}
|
|
682
805
|
|
|
683
806
|
async function fetchRemoteSnapshot(server, repo, branch, token) {
|
|
@@ -748,57 +871,57 @@ async function cmdCherryPick(opts) {
|
|
|
748
871
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
749
872
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
750
873
|
if (m) current = m[1]
|
|
751
|
-
} catch {}
|
|
874
|
+
} catch { }
|
|
752
875
|
targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
|
|
753
876
|
}
|
|
754
877
|
const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
|
|
755
878
|
if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
|
|
756
|
-
|
|
879
|
+
|
|
757
880
|
// Get the current state of the target branch (what we're cherry-picking onto)
|
|
758
881
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
|
|
759
|
-
|
|
882
|
+
|
|
760
883
|
// Get the commit being cherry-picked and its parent
|
|
761
884
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
762
885
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
763
886
|
const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
764
887
|
const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
765
|
-
|
|
888
|
+
|
|
766
889
|
// Pull the target branch to ensure we're working with the latest state
|
|
767
890
|
await pullToDir(meta.repoId, targetBranch, dir, server, token)
|
|
768
|
-
|
|
891
|
+
|
|
769
892
|
// Re-collect local state after pull (now it matches the target branch)
|
|
770
893
|
const localAfterPull = await collectLocal(dir)
|
|
771
|
-
|
|
894
|
+
|
|
772
895
|
// Update local metadata to reflect the pulled state
|
|
773
896
|
const metaDirInit = path.join(dir, '.vcs-next')
|
|
774
897
|
await fs.promises.mkdir(metaDirInit, { recursive: true })
|
|
775
898
|
const localPathInit = path.join(metaDirInit, 'local.json')
|
|
776
899
|
let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
777
|
-
try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
|
|
900
|
+
try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch { }
|
|
778
901
|
localMetaInit.baseCommitId = currentSnap.commitId
|
|
779
902
|
localMetaInit.baseFiles = currentSnap.files
|
|
780
903
|
await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
|
|
781
|
-
|
|
904
|
+
|
|
782
905
|
// Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
|
|
783
906
|
const conflicts = []
|
|
784
907
|
const changes = []
|
|
785
908
|
const mergedFiles = {}
|
|
786
909
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
|
|
787
|
-
|
|
910
|
+
|
|
788
911
|
for (const p of allPaths) {
|
|
789
912
|
const b = baseSnap.files[p] // base: parent of commit being cherry-picked
|
|
790
913
|
const n = targetSnap.files[p] // next: the commit being cherry-picked
|
|
791
914
|
const c = currentSnap.files[p] // current: current state of target branch (from remote)
|
|
792
|
-
|
|
915
|
+
|
|
793
916
|
// For cherry-pick, we need to apply the changes from the commit onto the current branch
|
|
794
917
|
// The logic: if the commit changed something from its parent, apply that change to current
|
|
795
918
|
const baseContent = b !== undefined ? String(b) : null
|
|
796
919
|
const commitContent = n !== undefined ? String(n) : null
|
|
797
920
|
const currentContent = c !== undefined ? String(c) : null
|
|
798
|
-
|
|
921
|
+
|
|
799
922
|
// Check if commit changed this file from its parent
|
|
800
923
|
const commitChanged = baseContent !== commitContent
|
|
801
|
-
|
|
924
|
+
|
|
802
925
|
if (commitChanged) {
|
|
803
926
|
// The commit modified this file - we want to apply that change
|
|
804
927
|
// Conflict detection:
|
|
@@ -807,15 +930,15 @@ async function cmdCherryPick(opts) {
|
|
|
807
930
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
|
|
808
931
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
809
932
|
// - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
|
|
810
|
-
|
|
933
|
+
|
|
811
934
|
const fileWasAdded = baseContent === null && commitContent !== null
|
|
812
935
|
const fileWasDeleted = baseContent !== null && commitContent === null
|
|
813
936
|
const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
|
|
814
|
-
|
|
937
|
+
|
|
815
938
|
// Check for conflicts: current branch changed the file differently than the commit
|
|
816
939
|
const currentChangedFromBase = currentContent !== baseContent
|
|
817
940
|
const currentDiffersFromCommit = currentContent !== commitContent
|
|
818
|
-
|
|
941
|
+
|
|
819
942
|
// Conflict detection:
|
|
820
943
|
// - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
|
|
821
944
|
// - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
|
|
@@ -823,7 +946,7 @@ async function cmdCherryPick(opts) {
|
|
|
823
946
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
|
|
824
947
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
825
948
|
// - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
|
|
826
|
-
|
|
949
|
+
|
|
827
950
|
// Conflict if:
|
|
828
951
|
// 1. Current branch changed the file from base (current !== base)
|
|
829
952
|
// 2. AND current differs from what commit wants (current !== commit)
|
|
@@ -833,7 +956,7 @@ async function cmdCherryPick(opts) {
|
|
|
833
956
|
// this is always safe - no conflict. The file is simply being added.
|
|
834
957
|
const safeAddCase = fileWasAdded && currentContent === null
|
|
835
958
|
const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
|
|
836
|
-
|
|
959
|
+
|
|
837
960
|
// Only conflict if file exists in current AND was changed differently
|
|
838
961
|
if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
|
|
839
962
|
// Conflict: both changed the file differently
|
|
@@ -863,7 +986,7 @@ async function cmdCherryPick(opts) {
|
|
|
863
986
|
}
|
|
864
987
|
}
|
|
865
988
|
}
|
|
866
|
-
|
|
989
|
+
|
|
867
990
|
if (conflicts.length > 0) {
|
|
868
991
|
// Write conflict markers to files
|
|
869
992
|
for (const conflict of conflicts) {
|
|
@@ -873,11 +996,11 @@ async function cmdCherryPick(opts) {
|
|
|
873
996
|
conflict.current,
|
|
874
997
|
conflict.incoming,
|
|
875
998
|
meta.branch,
|
|
876
|
-
`cherry-pick-${commitId.slice(0,7)}`
|
|
999
|
+
`cherry-pick-${commitId.slice(0, 7)}`
|
|
877
1000
|
)
|
|
878
1001
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
879
1002
|
}
|
|
880
|
-
|
|
1003
|
+
|
|
881
1004
|
// Store conflict state in metadata
|
|
882
1005
|
const metaDir = path.join(dir, '.vcs-next')
|
|
883
1006
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -886,9 +1009,9 @@ async function cmdCherryPick(opts) {
|
|
|
886
1009
|
try {
|
|
887
1010
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
888
1011
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
889
|
-
} catch {}
|
|
1012
|
+
} catch { }
|
|
890
1013
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
891
|
-
|
|
1014
|
+
|
|
892
1015
|
if (opts.json === 'true') {
|
|
893
1016
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
|
|
894
1017
|
} else {
|
|
@@ -901,22 +1024,22 @@ async function cmdCherryPick(opts) {
|
|
|
901
1024
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
902
1025
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
903
1026
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
904
|
-
process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
|
|
1027
|
+
process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0, 7)}\n`, 'dim'))
|
|
905
1028
|
}
|
|
906
1029
|
return
|
|
907
1030
|
}
|
|
908
|
-
|
|
1031
|
+
|
|
909
1032
|
// Apply the changes to the filesystem
|
|
910
1033
|
for (const ch of changes) {
|
|
911
1034
|
const fp = path.join(dir, ch.path)
|
|
912
1035
|
if (ch.type === 'delete') {
|
|
913
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1036
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
914
1037
|
} else if (ch.type === 'write') {
|
|
915
1038
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
916
1039
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
917
1040
|
}
|
|
918
1041
|
}
|
|
919
|
-
|
|
1042
|
+
|
|
920
1043
|
// Update local metadata with the pending commit
|
|
921
1044
|
const metaDir = path.join(dir, '.vcs-next')
|
|
922
1045
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -925,43 +1048,43 @@ async function cmdCherryPick(opts) {
|
|
|
925
1048
|
try {
|
|
926
1049
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
927
1050
|
localMeta = JSON.parse(s)
|
|
928
|
-
} catch {}
|
|
929
|
-
|
|
930
|
-
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
|
|
931
|
-
|
|
1051
|
+
} catch { }
|
|
1052
|
+
|
|
1053
|
+
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
|
|
1054
|
+
|
|
932
1055
|
// Collect final state after applying changes
|
|
933
1056
|
const finalLocal = await collectLocal(dir)
|
|
934
1057
|
const finalFiles = {}
|
|
935
1058
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
936
1059
|
finalFiles[p] = v.content
|
|
937
1060
|
}
|
|
938
|
-
|
|
1061
|
+
|
|
939
1062
|
// Set pending commit with the actual files
|
|
940
1063
|
localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
|
|
941
1064
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
942
|
-
|
|
1065
|
+
|
|
943
1066
|
// Ensure remote.json has the correct target branch before pushing
|
|
944
1067
|
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
945
1068
|
let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
|
|
946
1069
|
try {
|
|
947
1070
|
const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
|
|
948
1071
|
remoteMeta = { ...JSON.parse(s), branch: targetBranch }
|
|
949
|
-
} catch {}
|
|
1072
|
+
} catch { }
|
|
950
1073
|
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
951
|
-
|
|
1074
|
+
|
|
952
1075
|
// Also update .git metadata
|
|
953
1076
|
const gitDir = path.join(dir, '.git')
|
|
954
1077
|
const gitMetaPath = path.join(gitDir, 'vcs-next.json')
|
|
955
1078
|
await fs.promises.mkdir(gitDir, { recursive: true })
|
|
956
1079
|
await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
957
1080
|
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
|
|
958
|
-
|
|
1081
|
+
|
|
959
1082
|
if (opts.json === 'true') {
|
|
960
1083
|
print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
|
|
961
1084
|
} else {
|
|
962
|
-
process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1085
|
+
process.stdout.write(color(`Cherry-pick ${commitId.slice(0, 7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
|
|
963
1086
|
}
|
|
964
|
-
|
|
1087
|
+
|
|
965
1088
|
if (opts.noPush !== 'true') {
|
|
966
1089
|
await cmdPush({ dir, json: opts.json, message: cherryMsg })
|
|
967
1090
|
}
|
|
@@ -984,7 +1107,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
984
1107
|
const incoming = String(incomingContent || '')
|
|
985
1108
|
const currentLines = current.split(/\r?\n/)
|
|
986
1109
|
const incomingLines = incoming.split(/\r?\n/)
|
|
987
|
-
|
|
1110
|
+
|
|
988
1111
|
// Simple conflict marker format
|
|
989
1112
|
const markers = [
|
|
990
1113
|
`<<<<<<< ${currentLabel || 'HEAD'}`,
|
|
@@ -993,7 +1116,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
993
1116
|
...incomingLines,
|
|
994
1117
|
`>>>>>>> ${incomingLabel || 'incoming'}`
|
|
995
1118
|
]
|
|
996
|
-
|
|
1119
|
+
|
|
997
1120
|
return markers.join('\n')
|
|
998
1121
|
}
|
|
999
1122
|
|
|
@@ -1019,73 +1142,87 @@ async function cmdPush(opts) {
|
|
|
1019
1142
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1020
1143
|
const localPath = path.join(metaDir, 'local.json')
|
|
1021
1144
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1145
|
+
const spinner = createSpinner('Preparing to push...', opts.json)
|
|
1022
1146
|
try {
|
|
1023
1147
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1024
1148
|
localMeta = JSON.parse(s)
|
|
1025
|
-
} catch {}
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
process.stderr.write(color(
|
|
1149
|
+
} catch { }
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
// Check for unresolved conflicts in files
|
|
1153
|
+
spinnerUpdate(spinner, 'Checking for conflicts...')
|
|
1154
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1155
|
+
if (unresolvedConflicts.length > 0) {
|
|
1156
|
+
spinnerFail(spinner, 'Cannot push with unresolved conflicts')
|
|
1157
|
+
if (opts.json === 'true') {
|
|
1158
|
+
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1159
|
+
} else {
|
|
1160
|
+
process.stderr.write(color('Error: Cannot push with unresolved conflicts\\n', 'red'))
|
|
1161
|
+
process.stderr.write(color('Conflicts in files:\\n', 'yellow'))
|
|
1162
|
+
for (const p of unresolvedConflicts) {
|
|
1163
|
+
process.stderr.write(color(` ${p}\\n`, 'red'))
|
|
1164
|
+
}
|
|
1165
|
+
process.stderr.write(color('\\nResolve conflicts manually, then try pushing again.\\n', 'yellow'))
|
|
1037
1166
|
}
|
|
1038
|
-
|
|
1167
|
+
return
|
|
1039
1168
|
}
|
|
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
|
-
|
|
1169
|
+
|
|
1170
|
+
const cfg = loadConfig()
|
|
1171
|
+
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1172
|
+
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1173
|
+
spinnerUpdate(spinner, 'Fetching remote state...')
|
|
1174
|
+
const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
|
|
1175
|
+
const base = localMeta.baseFiles || {}
|
|
1176
|
+
spinnerUpdate(spinner, 'Collecting local changes...')
|
|
1177
|
+
const local = await collectLocal(dir)
|
|
1178
|
+
const conflicts = []
|
|
1179
|
+
const merged = {}
|
|
1180
|
+
const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
|
|
1181
|
+
spinnerUpdate(spinner, 'Merging changes...')
|
|
1182
|
+
for (const p of paths) {
|
|
1183
|
+
const b = p in base ? base[p] : null
|
|
1184
|
+
const r = p in remote.files ? remote.files[p] : null
|
|
1185
|
+
const l = p in local ? local[p].content : null
|
|
1186
|
+
const changedLocal = String(l) !== String(b)
|
|
1187
|
+
const changedRemote = String(r) !== String(b)
|
|
1188
|
+
if (changedLocal && changedRemote && String(l) !== String(r)) {
|
|
1189
|
+
const line = firstDiffLine(l || '', r || '')
|
|
1190
|
+
conflicts.push({ path: p, line })
|
|
1191
|
+
} else if (changedLocal && !changedRemote) {
|
|
1192
|
+
if (l !== null) merged[p] = l
|
|
1193
|
+
} else if (!changedLocal && changedRemote) {
|
|
1194
|
+
if (r !== null) merged[p] = r
|
|
1195
|
+
} else {
|
|
1196
|
+
if (b !== null) merged[p] = b
|
|
1197
|
+
}
|
|
1067
1198
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1199
|
+
if (conflicts.length > 0) {
|
|
1200
|
+
spinnerFail(spinner, 'Push blocked by conflicts')
|
|
1201
|
+
if (opts.json === 'true') {
|
|
1202
|
+
print({ conflicts }, true)
|
|
1203
|
+
} else {
|
|
1204
|
+
process.stderr.write(color('Error: Cannot push with conflicts\\n', 'red'))
|
|
1205
|
+
process.stderr.write(color('Conflicts detected:\\n', 'yellow'))
|
|
1206
|
+
for (const c of conflicts) {
|
|
1207
|
+
process.stderr.write(color(` ${c.path}:${c.line}\\n`, 'red'))
|
|
1208
|
+
}
|
|
1077
1209
|
}
|
|
1210
|
+
return
|
|
1078
1211
|
}
|
|
1079
|
-
|
|
1212
|
+
const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
|
|
1213
|
+
spinnerUpdate(spinner, `Pushing to '${remoteMeta.branch}'...`)
|
|
1214
|
+
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1215
|
+
const data = await request('POST', url, body, token)
|
|
1216
|
+
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1217
|
+
localMeta.baseFiles = merged
|
|
1218
|
+
localMeta.pendingCommit = null
|
|
1219
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1220
|
+
spinnerSuccess(spinner, `Pushed to '${remoteMeta.branch}' (commit: ${(data.id || '').slice(0, 7)})`)
|
|
1221
|
+
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
spinnerFail(spinner, 'Push failed')
|
|
1224
|
+
throw err
|
|
1080
1225
|
}
|
|
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
1226
|
}
|
|
1090
1227
|
|
|
1091
1228
|
async function cmdMerge(opts) {
|
|
@@ -1096,13 +1233,13 @@ async function cmdMerge(opts) {
|
|
|
1096
1233
|
const token = getToken(opts, cfg) || meta.token
|
|
1097
1234
|
const sourceBranch = opts.branch || ''
|
|
1098
1235
|
if (!sourceBranch) throw new Error('Missing --branch')
|
|
1099
|
-
|
|
1236
|
+
|
|
1100
1237
|
// Get current branch state
|
|
1101
1238
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1102
|
-
|
|
1239
|
+
|
|
1103
1240
|
// Get source branch state
|
|
1104
1241
|
const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
|
|
1105
|
-
|
|
1242
|
+
|
|
1106
1243
|
// Find common ancestor (merge base)
|
|
1107
1244
|
// For simplicity, we'll use the current branch's base commit as the merge base
|
|
1108
1245
|
// In a full implementation, we'd find the actual common ancestor
|
|
@@ -1110,29 +1247,29 @@ async function cmdMerge(opts) {
|
|
|
1110
1247
|
const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
|
|
1111
1248
|
const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
|
|
1112
1249
|
if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
|
|
1113
|
-
|
|
1250
|
+
|
|
1114
1251
|
// Get base commit (for now, use current branch's commit as base)
|
|
1115
1252
|
// In real Git, we'd find the merge base commit
|
|
1116
1253
|
const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1117
|
-
|
|
1254
|
+
|
|
1118
1255
|
// Pull current branch to ensure we're up to date
|
|
1119
1256
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1120
1257
|
const localAfterPull = await collectLocal(dir)
|
|
1121
|
-
|
|
1258
|
+
|
|
1122
1259
|
// Three-way merge: base, current (target), source
|
|
1123
1260
|
const conflicts = []
|
|
1124
1261
|
const changes = []
|
|
1125
1262
|
const mergedFiles = {}
|
|
1126
1263
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
|
|
1127
|
-
|
|
1264
|
+
|
|
1128
1265
|
for (const p of allPaths) {
|
|
1129
1266
|
const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
|
|
1130
1267
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1131
1268
|
const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
|
|
1132
|
-
|
|
1269
|
+
|
|
1133
1270
|
const currentChanged = current !== base
|
|
1134
1271
|
const sourceChanged = source !== base
|
|
1135
|
-
|
|
1272
|
+
|
|
1136
1273
|
if (currentChanged && sourceChanged && current !== source) {
|
|
1137
1274
|
// Conflict: both branches changed the file differently
|
|
1138
1275
|
const line = firstDiffLine(current || '', source || '')
|
|
@@ -1166,7 +1303,7 @@ async function cmdMerge(opts) {
|
|
|
1166
1303
|
}
|
|
1167
1304
|
}
|
|
1168
1305
|
}
|
|
1169
|
-
|
|
1306
|
+
|
|
1170
1307
|
if (conflicts.length > 0) {
|
|
1171
1308
|
// Write conflict markers to files
|
|
1172
1309
|
for (const conflict of conflicts) {
|
|
@@ -1180,7 +1317,7 @@ async function cmdMerge(opts) {
|
|
|
1180
1317
|
)
|
|
1181
1318
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1182
1319
|
}
|
|
1183
|
-
|
|
1320
|
+
|
|
1184
1321
|
// Store conflict state in metadata
|
|
1185
1322
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1186
1323
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1189,9 +1326,9 @@ async function cmdMerge(opts) {
|
|
|
1189
1326
|
try {
|
|
1190
1327
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1191
1328
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1192
|
-
} catch {}
|
|
1329
|
+
} catch { }
|
|
1193
1330
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1194
|
-
|
|
1331
|
+
|
|
1195
1332
|
const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
|
|
1196
1333
|
if (opts.json === 'true') {
|
|
1197
1334
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
|
|
@@ -1209,18 +1346,18 @@ async function cmdMerge(opts) {
|
|
|
1209
1346
|
}
|
|
1210
1347
|
return
|
|
1211
1348
|
}
|
|
1212
|
-
|
|
1349
|
+
|
|
1213
1350
|
// Apply changes to filesystem
|
|
1214
1351
|
for (const ch of changes) {
|
|
1215
1352
|
const fp = path.join(dir, ch.path)
|
|
1216
1353
|
if (ch.type === 'delete') {
|
|
1217
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1354
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1218
1355
|
} else if (ch.type === 'write') {
|
|
1219
1356
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1220
1357
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1221
1358
|
}
|
|
1222
1359
|
}
|
|
1223
|
-
|
|
1360
|
+
|
|
1224
1361
|
// Update local metadata
|
|
1225
1362
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1226
1363
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1229,27 +1366,27 @@ async function cmdMerge(opts) {
|
|
|
1229
1366
|
try {
|
|
1230
1367
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1231
1368
|
localMeta = JSON.parse(s)
|
|
1232
|
-
} catch {}
|
|
1233
|
-
|
|
1369
|
+
} catch { }
|
|
1370
|
+
|
|
1234
1371
|
const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
|
|
1235
|
-
|
|
1372
|
+
|
|
1236
1373
|
// Collect final state
|
|
1237
1374
|
const finalLocal = await collectLocal(dir)
|
|
1238
1375
|
const finalFiles = {}
|
|
1239
1376
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1240
1377
|
finalFiles[p] = v.content
|
|
1241
1378
|
}
|
|
1242
|
-
|
|
1379
|
+
|
|
1243
1380
|
// Set pending commit
|
|
1244
1381
|
localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
|
|
1245
1382
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1246
|
-
|
|
1383
|
+
|
|
1247
1384
|
if (opts.json === 'true') {
|
|
1248
1385
|
print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
|
|
1249
1386
|
} else {
|
|
1250
1387
|
process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1251
1388
|
}
|
|
1252
|
-
|
|
1389
|
+
|
|
1253
1390
|
if (opts.noPush !== 'true') {
|
|
1254
1391
|
await cmdPush({ dir, json: opts.json, message: mergeMsg })
|
|
1255
1392
|
}
|
|
@@ -1260,7 +1397,7 @@ async function cmdStash(sub, opts) {
|
|
|
1260
1397
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1261
1398
|
const stashDir = path.join(metaDir, 'stash')
|
|
1262
1399
|
await fs.promises.mkdir(stashDir, { recursive: true })
|
|
1263
|
-
|
|
1400
|
+
|
|
1264
1401
|
if (sub === 'list' || sub === undefined) {
|
|
1265
1402
|
try {
|
|
1266
1403
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1293,7 +1430,7 @@ async function cmdStash(sub, opts) {
|
|
|
1293
1430
|
}
|
|
1294
1431
|
return
|
|
1295
1432
|
}
|
|
1296
|
-
|
|
1433
|
+
|
|
1297
1434
|
if (sub === 'save' || (sub === undefined && !opts.list)) {
|
|
1298
1435
|
const message = opts.message || 'WIP'
|
|
1299
1436
|
const local = await collectLocal(dir)
|
|
@@ -1304,14 +1441,14 @@ async function cmdStash(sub, opts) {
|
|
|
1304
1441
|
const stashId = Date.now().toString()
|
|
1305
1442
|
const stash = { message, files, createdAt: Date.now() }
|
|
1306
1443
|
await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
|
|
1307
|
-
|
|
1444
|
+
|
|
1308
1445
|
// Restore to base state (discard local changes)
|
|
1309
1446
|
const meta = readRemoteMeta(dir)
|
|
1310
1447
|
const cfg = loadConfig()
|
|
1311
1448
|
const server = getServer(opts, cfg) || meta.server
|
|
1312
1449
|
const token = getToken(opts, cfg) || meta.token
|
|
1313
1450
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1314
|
-
|
|
1451
|
+
|
|
1315
1452
|
if (opts.json === 'true') {
|
|
1316
1453
|
print({ stashId, message }, true)
|
|
1317
1454
|
} else {
|
|
@@ -1319,7 +1456,7 @@ async function cmdStash(sub, opts) {
|
|
|
1319
1456
|
}
|
|
1320
1457
|
return
|
|
1321
1458
|
}
|
|
1322
|
-
|
|
1459
|
+
|
|
1323
1460
|
if (sub === 'pop' || sub === 'apply') {
|
|
1324
1461
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1325
1462
|
try {
|
|
@@ -1335,19 +1472,19 @@ async function cmdStash(sub, opts) {
|
|
|
1335
1472
|
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1336
1473
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1337
1474
|
const stash = stashes[stashIndex]
|
|
1338
|
-
|
|
1475
|
+
|
|
1339
1476
|
// Apply stash files
|
|
1340
1477
|
for (const [p, content] of Object.entries(stash.files || {})) {
|
|
1341
1478
|
const fp = path.join(dir, p)
|
|
1342
1479
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1343
1480
|
await fs.promises.writeFile(fp, content, 'utf8')
|
|
1344
1481
|
}
|
|
1345
|
-
|
|
1482
|
+
|
|
1346
1483
|
if (sub === 'pop') {
|
|
1347
1484
|
// Remove stash
|
|
1348
1485
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1349
1486
|
}
|
|
1350
|
-
|
|
1487
|
+
|
|
1351
1488
|
if (opts.json === 'true') {
|
|
1352
1489
|
print({ applied: stash.id, message: stash.message }, true)
|
|
1353
1490
|
} else {
|
|
@@ -1358,7 +1495,7 @@ async function cmdStash(sub, opts) {
|
|
|
1358
1495
|
}
|
|
1359
1496
|
return
|
|
1360
1497
|
}
|
|
1361
|
-
|
|
1498
|
+
|
|
1362
1499
|
if (sub === 'drop') {
|
|
1363
1500
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1364
1501
|
try {
|
|
@@ -1375,7 +1512,7 @@ async function cmdStash(sub, opts) {
|
|
|
1375
1512
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1376
1513
|
const stash = stashes[stashIndex]
|
|
1377
1514
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1378
|
-
|
|
1515
|
+
|
|
1379
1516
|
if (opts.json === 'true') {
|
|
1380
1517
|
print({ dropped: stash.id }, true)
|
|
1381
1518
|
} else {
|
|
@@ -1386,7 +1523,7 @@ async function cmdStash(sub, opts) {
|
|
|
1386
1523
|
}
|
|
1387
1524
|
return
|
|
1388
1525
|
}
|
|
1389
|
-
|
|
1526
|
+
|
|
1390
1527
|
if (sub === 'clear') {
|
|
1391
1528
|
try {
|
|
1392
1529
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1403,7 +1540,7 @@ async function cmdStash(sub, opts) {
|
|
|
1403
1540
|
}
|
|
1404
1541
|
return
|
|
1405
1542
|
}
|
|
1406
|
-
|
|
1543
|
+
|
|
1407
1544
|
throw new Error('Unknown stash subcommand')
|
|
1408
1545
|
}
|
|
1409
1546
|
|
|
@@ -1425,7 +1562,7 @@ async function cmdBranch(sub, opts) {
|
|
|
1425
1562
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
1426
1563
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
1427
1564
|
if (m) current = m[1]
|
|
1428
|
-
} catch {}
|
|
1565
|
+
} catch { }
|
|
1429
1566
|
process.stdout.write(color('Branches:\n', 'bold'))
|
|
1430
1567
|
const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
|
|
1431
1568
|
for (const b of list) {
|
|
@@ -1463,7 +1600,21 @@ async function cmdBranch(sub, opts) {
|
|
|
1463
1600
|
print(data, opts.json === 'true')
|
|
1464
1601
|
return
|
|
1465
1602
|
}
|
|
1466
|
-
|
|
1603
|
+
if (sub === 'rename') {
|
|
1604
|
+
const oldName = opts.old
|
|
1605
|
+
const newName = opts.new
|
|
1606
|
+
if (!oldName || !newName) throw new Error('Missing --old and --new')
|
|
1607
|
+
const body = { name: newName, baseBranch: oldName }
|
|
1608
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1609
|
+
await request('POST', url, body, token)
|
|
1610
|
+
const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1611
|
+
u.searchParams.set('name', oldName)
|
|
1612
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1613
|
+
await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1614
|
+
print({ renamed: { from: oldName, to: newName } }, opts.json === 'true')
|
|
1615
|
+
return
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1467
1618
|
throw new Error('Unknown branch subcommand')
|
|
1468
1619
|
}
|
|
1469
1620
|
|
|
@@ -1493,7 +1644,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
|
|
|
1493
1644
|
for (const rel of Object.keys(localMap)) {
|
|
1494
1645
|
if (!keep.has(rel)) {
|
|
1495
1646
|
const fp = path.join(root, rel)
|
|
1496
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1647
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1497
1648
|
}
|
|
1498
1649
|
}
|
|
1499
1650
|
const metaDir = path.join(root, '.vcs-next')
|
|
@@ -1521,6 +1672,11 @@ async function cmdCheckout(opts) {
|
|
|
1521
1672
|
return
|
|
1522
1673
|
}
|
|
1523
1674
|
if (!branch) throw new Error('Missing --branch or --commit')
|
|
1675
|
+
if (opts.create === 'true') {
|
|
1676
|
+
const body = { name: branch, baseBranch: meta.branch }
|
|
1677
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1678
|
+
await request('POST', url, body, token)
|
|
1679
|
+
}
|
|
1524
1680
|
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1525
1681
|
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1526
1682
|
}
|
|
@@ -1614,7 +1770,7 @@ async function cmdHead(opts) {
|
|
|
1614
1770
|
} else {
|
|
1615
1771
|
commitId = head.trim()
|
|
1616
1772
|
}
|
|
1617
|
-
} catch {}
|
|
1773
|
+
} catch { }
|
|
1618
1774
|
if (!commitId) {
|
|
1619
1775
|
const cfg = loadConfig()
|
|
1620
1776
|
const server = getServer(opts, cfg) || meta.server
|
|
@@ -1635,12 +1791,12 @@ async function cmdShow(opts) {
|
|
|
1635
1791
|
const token = getToken(opts, cfg) || meta.token
|
|
1636
1792
|
const commitId = opts.commit
|
|
1637
1793
|
if (!commitId) throw new Error('Missing --commit')
|
|
1638
|
-
|
|
1794
|
+
|
|
1639
1795
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1640
1796
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1641
1797
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1642
1798
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1643
|
-
|
|
1799
|
+
|
|
1644
1800
|
if (opts.json === 'true') {
|
|
1645
1801
|
print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
|
|
1646
1802
|
} else {
|
|
@@ -1652,7 +1808,7 @@ async function cmdShow(opts) {
|
|
|
1652
1808
|
process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
|
|
1653
1809
|
}
|
|
1654
1810
|
process.stdout.write(`\n${commit.message || ''}\n\n`)
|
|
1655
|
-
|
|
1811
|
+
|
|
1656
1812
|
// Show file changes
|
|
1657
1813
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
1658
1814
|
const changed = []
|
|
@@ -1683,34 +1839,34 @@ async function cmdRevert(opts) {
|
|
|
1683
1839
|
const token = getToken(opts, cfg) || meta.token
|
|
1684
1840
|
const commitId = opts.commit
|
|
1685
1841
|
if (!commitId) throw new Error('Missing --commit')
|
|
1686
|
-
|
|
1842
|
+
|
|
1687
1843
|
// Get the commit to revert
|
|
1688
1844
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1689
1845
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1690
1846
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1691
1847
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1692
|
-
|
|
1848
|
+
|
|
1693
1849
|
// Get current state
|
|
1694
1850
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1695
1851
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1696
1852
|
const localAfterPull = await collectLocal(dir)
|
|
1697
|
-
|
|
1853
|
+
|
|
1698
1854
|
// Revert: apply inverse of commit changes
|
|
1699
1855
|
const conflicts = []
|
|
1700
1856
|
const changes = []
|
|
1701
1857
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
|
|
1702
|
-
|
|
1858
|
+
|
|
1703
1859
|
for (const p of allPaths) {
|
|
1704
1860
|
const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
1705
1861
|
const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
1706
1862
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1707
1863
|
const local = localAfterPull[p]?.content || null
|
|
1708
|
-
|
|
1864
|
+
|
|
1709
1865
|
// Revert: we want to go from commit state back to parent state
|
|
1710
1866
|
// But check if current branch has changed this file
|
|
1711
1867
|
const commitChanged = base !== commitContent
|
|
1712
1868
|
const currentChanged = current !== base
|
|
1713
|
-
|
|
1869
|
+
|
|
1714
1870
|
if (commitChanged) {
|
|
1715
1871
|
// Commit changed this file - we want to revert it
|
|
1716
1872
|
if (currentChanged && current !== base) {
|
|
@@ -1732,7 +1888,7 @@ async function cmdRevert(opts) {
|
|
|
1732
1888
|
}
|
|
1733
1889
|
}
|
|
1734
1890
|
}
|
|
1735
|
-
|
|
1891
|
+
|
|
1736
1892
|
if (conflicts.length > 0) {
|
|
1737
1893
|
// Write conflict markers to files
|
|
1738
1894
|
for (const conflict of conflicts) {
|
|
@@ -1742,11 +1898,11 @@ async function cmdRevert(opts) {
|
|
|
1742
1898
|
conflict.current,
|
|
1743
1899
|
conflict.incoming,
|
|
1744
1900
|
meta.branch,
|
|
1745
|
-
`revert-${commitId.slice(0,7)}`
|
|
1901
|
+
`revert-${commitId.slice(0, 7)}`
|
|
1746
1902
|
)
|
|
1747
1903
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1748
1904
|
}
|
|
1749
|
-
|
|
1905
|
+
|
|
1750
1906
|
// Store conflict state in metadata
|
|
1751
1907
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1752
1908
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1755,9 +1911,9 @@ async function cmdRevert(opts) {
|
|
|
1755
1911
|
try {
|
|
1756
1912
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1757
1913
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1758
|
-
} catch {}
|
|
1914
|
+
} catch { }
|
|
1759
1915
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1760
|
-
|
|
1916
|
+
|
|
1761
1917
|
if (opts.json === 'true') {
|
|
1762
1918
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
|
|
1763
1919
|
} else {
|
|
@@ -1770,22 +1926,22 @@ async function cmdRevert(opts) {
|
|
|
1770
1926
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
1771
1927
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
1772
1928
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
1773
|
-
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
|
|
1929
|
+
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
|
|
1774
1930
|
}
|
|
1775
1931
|
return
|
|
1776
1932
|
}
|
|
1777
|
-
|
|
1933
|
+
|
|
1778
1934
|
// Apply changes
|
|
1779
1935
|
for (const ch of changes) {
|
|
1780
1936
|
const fp = path.join(dir, ch.path)
|
|
1781
1937
|
if (ch.type === 'delete') {
|
|
1782
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1938
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1783
1939
|
} else if (ch.type === 'write') {
|
|
1784
1940
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1785
1941
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1786
1942
|
}
|
|
1787
1943
|
}
|
|
1788
|
-
|
|
1944
|
+
|
|
1789
1945
|
// Update metadata
|
|
1790
1946
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1791
1947
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -1794,24 +1950,24 @@ async function cmdRevert(opts) {
|
|
|
1794
1950
|
try {
|
|
1795
1951
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1796
1952
|
localMeta = JSON.parse(s)
|
|
1797
|
-
} catch {}
|
|
1798
|
-
|
|
1953
|
+
} catch { }
|
|
1954
|
+
|
|
1799
1955
|
const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
|
|
1800
1956
|
const finalLocal = await collectLocal(dir)
|
|
1801
1957
|
const finalFiles = {}
|
|
1802
1958
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1803
1959
|
finalFiles[p] = v.content
|
|
1804
1960
|
}
|
|
1805
|
-
|
|
1961
|
+
|
|
1806
1962
|
localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
|
|
1807
1963
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1808
|
-
|
|
1964
|
+
|
|
1809
1965
|
if (opts.json === 'true') {
|
|
1810
1966
|
print({ reverted: commitId, changes: changes.length }, true)
|
|
1811
1967
|
} else {
|
|
1812
|
-
process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
|
|
1968
|
+
process.stdout.write(color(`Reverted ${commitId.slice(0, 7)}: ${changes.length} changes\n`, 'green'))
|
|
1813
1969
|
}
|
|
1814
|
-
|
|
1970
|
+
|
|
1815
1971
|
if (opts.noPush !== 'true') {
|
|
1816
1972
|
await cmdPush({ dir, json: opts.json, message: revertMsg })
|
|
1817
1973
|
}
|
|
@@ -1823,11 +1979,11 @@ async function cmdReset(opts) {
|
|
|
1823
1979
|
const cfg = loadConfig()
|
|
1824
1980
|
const server = getServer(opts, cfg) || meta.server
|
|
1825
1981
|
const token = getToken(opts, cfg) || meta.token
|
|
1826
|
-
|
|
1982
|
+
|
|
1827
1983
|
const commitId = opts.commit || 'HEAD'
|
|
1828
1984
|
const mode = opts.mode || 'mixed' // soft, mixed, hard
|
|
1829
1985
|
const filePath = opts.path
|
|
1830
|
-
|
|
1986
|
+
|
|
1831
1987
|
if (filePath) {
|
|
1832
1988
|
// Reset specific file (unstage)
|
|
1833
1989
|
const metaDir = path.join(dir, '.vcs-next')
|
|
@@ -1836,8 +1992,8 @@ async function cmdReset(opts) {
|
|
|
1836
1992
|
try {
|
|
1837
1993
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1838
1994
|
localMeta = JSON.parse(s)
|
|
1839
|
-
} catch {}
|
|
1840
|
-
|
|
1995
|
+
} catch { }
|
|
1996
|
+
|
|
1841
1997
|
// Restore file from base
|
|
1842
1998
|
const baseContent = localMeta.baseFiles[filePath]
|
|
1843
1999
|
if (baseContent !== undefined) {
|
|
@@ -1845,7 +2001,7 @@ async function cmdReset(opts) {
|
|
|
1845
2001
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1846
2002
|
await fs.promises.writeFile(fp, String(baseContent), 'utf8')
|
|
1847
2003
|
}
|
|
1848
|
-
|
|
2004
|
+
|
|
1849
2005
|
if (opts.json === 'true') {
|
|
1850
2006
|
print({ reset: filePath }, true)
|
|
1851
2007
|
} else {
|
|
@@ -1853,7 +2009,7 @@ async function cmdReset(opts) {
|
|
|
1853
2009
|
}
|
|
1854
2010
|
return
|
|
1855
2011
|
}
|
|
1856
|
-
|
|
2012
|
+
|
|
1857
2013
|
// Reset to commit
|
|
1858
2014
|
let targetSnap
|
|
1859
2015
|
if (commitId === 'HEAD') {
|
|
@@ -1861,7 +2017,7 @@ async function cmdReset(opts) {
|
|
|
1861
2017
|
} else {
|
|
1862
2018
|
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1863
2019
|
}
|
|
1864
|
-
|
|
2020
|
+
|
|
1865
2021
|
if (mode === 'hard') {
|
|
1866
2022
|
// Hard reset: discard all changes, reset to commit
|
|
1867
2023
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
@@ -1882,14 +2038,14 @@ async function cmdReset(opts) {
|
|
|
1882
2038
|
// Mixed reset: keep changes unstaged (default)
|
|
1883
2039
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1884
2040
|
}
|
|
1885
|
-
|
|
2041
|
+
|
|
1886
2042
|
// Update metadata
|
|
1887
2043
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1888
2044
|
const localPath = path.join(metaDir, 'local.json')
|
|
1889
2045
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1890
2046
|
const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
|
|
1891
2047
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1892
|
-
|
|
2048
|
+
|
|
1893
2049
|
if (opts.json === 'true') {
|
|
1894
2050
|
print({ reset: commitId, mode }, true)
|
|
1895
2051
|
} else {
|
|
@@ -1897,23 +2053,57 @@ async function cmdReset(opts) {
|
|
|
1897
2053
|
}
|
|
1898
2054
|
}
|
|
1899
2055
|
|
|
2056
|
+
async function cmdInit(opts) {
|
|
2057
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2058
|
+
const cfg = loadConfig()
|
|
2059
|
+
const server = getServer(opts, cfg)
|
|
2060
|
+
const token = getToken(opts, cfg)
|
|
2061
|
+
const repo = opts.repo || ''
|
|
2062
|
+
const branch = opts.branch || 'main'
|
|
2063
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2064
|
+
const gitDir = path.join(dir, '.git')
|
|
2065
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2066
|
+
await fs.promises.mkdir(path.join(gitDir, 'refs', 'heads'), { recursive: true })
|
|
2067
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8')
|
|
2068
|
+
await fs.promises.writeFile(path.join(gitDir, 'refs', 'heads', branch), '', 'utf8')
|
|
2069
|
+
const gitConfig = [
|
|
2070
|
+
'[core]',
|
|
2071
|
+
'\trepositoryformatversion = 0',
|
|
2072
|
+
'\tfilemode = true',
|
|
2073
|
+
'\tbare = false',
|
|
2074
|
+
'\tlogallrefupdates = true',
|
|
2075
|
+
'',
|
|
2076
|
+
'[vcs-next]',
|
|
2077
|
+
`\tserver = ${opts.server || server || ''}`,
|
|
2078
|
+
`\trepoId = ${repo}`,
|
|
2079
|
+
`\tbranch = ${branch}`,
|
|
2080
|
+
`\ttoken = ${opts.token || token || ''}`
|
|
2081
|
+
].join('\n')
|
|
2082
|
+
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
2083
|
+
const remoteMeta = { repoId: repo, branch, commitId: '', server: opts.server || server || '', token: opts.token || token || '' }
|
|
2084
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
|
|
2085
|
+
const localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
2086
|
+
await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
|
|
2087
|
+
print({ initialized: dir, branch }, opts.json === 'true')
|
|
2088
|
+
}
|
|
2089
|
+
|
|
1900
2090
|
async function cmdMv(opts) {
|
|
1901
2091
|
const dir = path.resolve(opts.dir || '.')
|
|
1902
2092
|
const from = opts.from
|
|
1903
2093
|
const to = opts.to
|
|
1904
2094
|
if (!from || !to) throw new Error('Missing --from and --to')
|
|
1905
|
-
|
|
2095
|
+
|
|
1906
2096
|
const fromPath = path.join(dir, from)
|
|
1907
2097
|
const toPath = path.join(dir, to)
|
|
1908
|
-
|
|
2098
|
+
|
|
1909
2099
|
// Check if source exists
|
|
1910
2100
|
const stat = await fs.promises.stat(fromPath).catch(() => null)
|
|
1911
2101
|
if (!stat) throw new Error(`Source file not found: ${from}`)
|
|
1912
|
-
|
|
2102
|
+
|
|
1913
2103
|
// Move file
|
|
1914
2104
|
await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
|
|
1915
2105
|
await fs.promises.rename(fromPath, toPath)
|
|
1916
|
-
|
|
2106
|
+
|
|
1917
2107
|
// If it's a tracked file, we need to update it in the next commit
|
|
1918
2108
|
// For now, just move it - the next commit will track the change
|
|
1919
2109
|
if (opts.json === 'true') {
|
|
@@ -1925,7 +2115,7 @@ async function cmdMv(opts) {
|
|
|
1925
2115
|
|
|
1926
2116
|
async function cmdAdd(opts) {
|
|
1927
2117
|
const dir = path.resolve(opts.dir || '.')
|
|
1928
|
-
|
|
2118
|
+
|
|
1929
2119
|
// Handle --all flag
|
|
1930
2120
|
if (opts.all === 'true' || opts.all === true) {
|
|
1931
2121
|
const meta = readRemoteMeta(dir)
|
|
@@ -1934,7 +2124,7 @@ async function cmdAdd(opts) {
|
|
|
1934
2124
|
const token = getToken(opts, cfg) || meta.token
|
|
1935
2125
|
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
1936
2126
|
const local = await collectLocal(dir)
|
|
1937
|
-
|
|
2127
|
+
|
|
1938
2128
|
// Stage all changes (add new, modified, delete removed)
|
|
1939
2129
|
const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
|
|
1940
2130
|
let stagedCount = 0
|
|
@@ -1945,7 +2135,7 @@ async function cmdAdd(opts) {
|
|
|
1945
2135
|
stagedCount++
|
|
1946
2136
|
}
|
|
1947
2137
|
}
|
|
1948
|
-
|
|
2138
|
+
|
|
1949
2139
|
if (opts.json === 'true') {
|
|
1950
2140
|
print({ staged: stagedCount }, true)
|
|
1951
2141
|
} else {
|
|
@@ -1953,7 +2143,7 @@ async function cmdAdd(opts) {
|
|
|
1953
2143
|
}
|
|
1954
2144
|
return
|
|
1955
2145
|
}
|
|
1956
|
-
|
|
2146
|
+
|
|
1957
2147
|
const p = opts.path
|
|
1958
2148
|
if (!p) throw new Error('Missing --path')
|
|
1959
2149
|
const abs = path.join(dir, p)
|
|
@@ -2001,6 +2191,169 @@ async function cmdAdd(opts) {
|
|
|
2001
2191
|
print({ added: p, dir }, opts.json === 'true')
|
|
2002
2192
|
}
|
|
2003
2193
|
|
|
2194
|
+
async function cmdRemote(sub, opts) {
|
|
2195
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2196
|
+
const meta = readRemoteMeta(dir)
|
|
2197
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2198
|
+
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
2199
|
+
let remoteMeta = meta
|
|
2200
|
+
try { const s = await fs.promises.readFile(remoteMetaPath, 'utf8'); remoteMeta = JSON.parse(s) } catch { }
|
|
2201
|
+
if (sub === 'show' || sub === undefined) {
|
|
2202
|
+
print(remoteMeta, opts.json === 'true')
|
|
2203
|
+
return
|
|
2204
|
+
}
|
|
2205
|
+
if (sub === 'set-url') {
|
|
2206
|
+
const server = opts.server || ''
|
|
2207
|
+
if (!server) throw new Error('Missing --server')
|
|
2208
|
+
remoteMeta.server = server
|
|
2209
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2210
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
2211
|
+
const gitDir = path.join(dir, '.git')
|
|
2212
|
+
const cfgPath = path.join(gitDir, 'config')
|
|
2213
|
+
let cfgText = ''
|
|
2214
|
+
try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
|
|
2215
|
+
const lines = cfgText.split('\n')
|
|
2216
|
+
let inSec = false
|
|
2217
|
+
const out = []
|
|
2218
|
+
for (const ln of lines) {
|
|
2219
|
+
if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
|
|
2220
|
+
if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
|
|
2221
|
+
if (inSec && ln.trim().startsWith('server =')) { out.push(`\tserver = ${server}`); continue }
|
|
2222
|
+
out.push(ln)
|
|
2223
|
+
}
|
|
2224
|
+
await fs.promises.writeFile(cfgPath, out.join('\n'))
|
|
2225
|
+
print({ server }, opts.json === 'true')
|
|
2226
|
+
return
|
|
2227
|
+
}
|
|
2228
|
+
if (sub === 'set-token') {
|
|
2229
|
+
const token = opts.token || ''
|
|
2230
|
+
if (!token) throw new Error('Missing --token')
|
|
2231
|
+
remoteMeta.token = token
|
|
2232
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
2233
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
2234
|
+
const gitDir = path.join(dir, '.git')
|
|
2235
|
+
const cfgPath = path.join(gitDir, 'config')
|
|
2236
|
+
let cfgText = ''
|
|
2237
|
+
try { cfgText = await fs.promises.readFile(cfgPath, 'utf8') } catch { }
|
|
2238
|
+
const lines = cfgText.split('\n')
|
|
2239
|
+
let inSec = false
|
|
2240
|
+
const out = []
|
|
2241
|
+
for (const ln of lines) {
|
|
2242
|
+
if (ln.trim() === '[vcs-next]') { inSec = true; out.push(ln); continue }
|
|
2243
|
+
if (ln.startsWith('[')) { inSec = false; out.push(ln); continue }
|
|
2244
|
+
if (inSec && ln.trim().startsWith('token =')) { out.push(`\ttoken = ${token}`); continue }
|
|
2245
|
+
out.push(ln)
|
|
2246
|
+
}
|
|
2247
|
+
await fs.promises.writeFile(cfgPath, out.join('\n'))
|
|
2248
|
+
print({ token: 'updated' }, opts.json === 'true')
|
|
2249
|
+
return
|
|
2250
|
+
}
|
|
2251
|
+
throw new Error('Unknown remote subcommand')
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
async function cmdConfig(sub, opts) {
|
|
2255
|
+
if (sub === 'list' || sub === undefined) {
|
|
2256
|
+
print(loadConfig(), opts.json === 'true')
|
|
2257
|
+
return
|
|
2258
|
+
}
|
|
2259
|
+
if (sub === 'get') {
|
|
2260
|
+
const key = opts.key
|
|
2261
|
+
if (!key) throw new Error('Missing --key')
|
|
2262
|
+
const cfg = loadConfig()
|
|
2263
|
+
const val = cfg[key]
|
|
2264
|
+
print({ [key]: val }, opts.json === 'true')
|
|
2265
|
+
return
|
|
2266
|
+
}
|
|
2267
|
+
if (sub === 'set') {
|
|
2268
|
+
const key = opts.key
|
|
2269
|
+
const value = opts.value
|
|
2270
|
+
if (!key) throw new Error('Missing --key')
|
|
2271
|
+
const next = saveConfig({ [key]: value })
|
|
2272
|
+
print({ [key]: next[key] }, opts.json === 'true')
|
|
2273
|
+
return
|
|
2274
|
+
}
|
|
2275
|
+
throw new Error('Unknown config subcommand')
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
async function cmdClean(opts) {
|
|
2279
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2280
|
+
const meta = readRemoteMeta(dir)
|
|
2281
|
+
const cfg = loadConfig()
|
|
2282
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2283
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2284
|
+
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
2285
|
+
const local = await collectLocal(dir)
|
|
2286
|
+
const keep = new Set(Object.keys(remoteSnap.files))
|
|
2287
|
+
const toDelete = []
|
|
2288
|
+
for (const p of Object.keys(local)) {
|
|
2289
|
+
if (!keep.has(p)) toDelete.push(p)
|
|
2290
|
+
}
|
|
2291
|
+
const force = opts.force === 'true'
|
|
2292
|
+
if (!force) {
|
|
2293
|
+
print({ untracked: toDelete }, opts.json === 'true')
|
|
2294
|
+
return
|
|
2295
|
+
}
|
|
2296
|
+
for (const rel of toDelete) {
|
|
2297
|
+
const fp = path.join(dir, rel)
|
|
2298
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
2299
|
+
}
|
|
2300
|
+
const pruneEmptyDirs = async (start) => {
|
|
2301
|
+
const entries = await fs.promises.readdir(start).catch(() => [])
|
|
2302
|
+
for (const name of entries) {
|
|
2303
|
+
if (name === '.git' || name === '.vcs-next') continue
|
|
2304
|
+
const p = path.join(start, name)
|
|
2305
|
+
const st = await fs.promises.stat(p).catch(() => null)
|
|
2306
|
+
if (!st) continue
|
|
2307
|
+
if (st.isDirectory()) {
|
|
2308
|
+
await pruneEmptyDirs(p)
|
|
2309
|
+
const left = await fs.promises.readdir(p).catch(() => [])
|
|
2310
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
await pruneEmptyDirs(dir)
|
|
2315
|
+
print({ cleaned: toDelete.length }, opts.json === 'true')
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
async function cmdRebase(opts) {
|
|
2319
|
+
const dir = path.resolve(opts.dir || '.')
|
|
2320
|
+
const meta = readRemoteMeta(dir)
|
|
2321
|
+
const cfg = loadConfig()
|
|
2322
|
+
const server = getServer(opts, cfg) || meta.server
|
|
2323
|
+
const token = getToken(opts, cfg) || meta.token
|
|
2324
|
+
const sourceBranch = opts.branch || ''
|
|
2325
|
+
const onto = opts.onto || meta.branch
|
|
2326
|
+
if (!sourceBranch) throw new Error('Missing --branch')
|
|
2327
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
2328
|
+
const commitsUrl = new URL(`/api/repositories/${meta.repoId}/commits`, server)
|
|
2329
|
+
commitsUrl.searchParams.set('branch', sourceBranch)
|
|
2330
|
+
const res = await fetch(commitsUrl.toString(), { headers })
|
|
2331
|
+
if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(t || 'commits fetch failed') }
|
|
2332
|
+
const list = await res.json()
|
|
2333
|
+
const ids = Array.isArray(list) ? list.map((c) => c.id || c._id || '').filter(Boolean) : []
|
|
2334
|
+
for (const id of ids) {
|
|
2335
|
+
await cmdCherryPick({ dir, commit: id, branch: onto, noPush: 'true' })
|
|
2336
|
+
const unresolved = await checkForUnresolvedConflicts(dir)
|
|
2337
|
+
if (unresolved.length > 0) {
|
|
2338
|
+
if (opts.json === 'true') {
|
|
2339
|
+
print({ error: 'Rebase stopped due to conflicts', conflicts: unresolved }, true)
|
|
2340
|
+
} else {
|
|
2341
|
+
process.stderr.write(color('Rebase stopped due to conflicts\n', 'red'))
|
|
2342
|
+
}
|
|
2343
|
+
return
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
2347
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
2348
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
2349
|
+
try { const s = await fs.promises.readFile(localPath, 'utf8'); localMeta = JSON.parse(s) } catch { }
|
|
2350
|
+
const message = opts.message || `rebase ${sourceBranch} onto ${onto}`
|
|
2351
|
+
if (opts.noPush !== 'true') {
|
|
2352
|
+
await cmdPush({ dir, json: opts.json, message })
|
|
2353
|
+
} else {
|
|
2354
|
+
print({ rebased: sourceBranch, onto }, opts.json === 'true')
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2004
2357
|
function help() {
|
|
2005
2358
|
const h = [
|
|
2006
2359
|
'Usage: resulgit <group> <command> [options]',
|
|
@@ -2012,10 +2365,10 @@ function help() {
|
|
|
2012
2365
|
' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
|
|
2013
2366
|
' repo list [--json]',
|
|
2014
2367
|
' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
' branch list|create|delete [--dir <path>] [--name <branch>] [--base <branch>]',
|
|
2368
|
+
' repo log --repo <id> [--branch <name>] [--json]',
|
|
2369
|
+
' repo head --repo <id> [--branch <name>] [--json]',
|
|
2370
|
+
' repo select [--workspace] (interactive select and clone/open)',
|
|
2371
|
+
' branch list|create|delete|rename [--dir <path>] [--name <branch>] [--base <branch>] [--old <name>] [--new <name>]',
|
|
2019
2372
|
' switch --branch <name> [--dir <path>]',
|
|
2020
2373
|
' current [--dir <path>] (show active repo/branch)',
|
|
2021
2374
|
' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
|
|
@@ -2023,29 +2376,35 @@ function help() {
|
|
|
2023
2376
|
' status [--dir <path>] [--json]',
|
|
2024
2377
|
' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
|
|
2025
2378
|
' commit --message <text> [--dir <path>] [--json]',
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2379
|
+
' push [--dir <path>] [--json]',
|
|
2380
|
+
' head [--dir <path>] [--json]',
|
|
2381
|
+
' rm --path <file> [--dir <path>] [--json]',
|
|
2029
2382
|
' pull [--dir <path>]',
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
'
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2383
|
+
' fetch [--dir <path>]',
|
|
2384
|
+
' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
|
|
2385
|
+
' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
|
|
2386
|
+
' restore --path <file> [--source <commit>] [--dir <path>]',
|
|
2387
|
+
' revert --commit <id> [--no-push] [--dir <path>]',
|
|
2388
|
+
' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
|
|
2389
|
+
' show --commit <id> [--dir <path>] [--json]',
|
|
2390
|
+
' mv --from <old> --to <new> [--dir <path>]',
|
|
2391
|
+
'',
|
|
2392
|
+
'Conflict Resolution:',
|
|
2393
|
+
' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
|
|
2394
|
+
' <<<<<<< HEAD (current changes)',
|
|
2395
|
+
' =======',
|
|
2396
|
+
' >>>>>>> incoming (incoming changes)',
|
|
2397
|
+
' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
|
|
2398
|
+
' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
|
|
2399
|
+
' clone <url> [--dest <dir>] | --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
|
|
2400
|
+
' init [--dir <path>] [--repo <id>] [--server <url>] [--branch <name>] [--token <tok>]',
|
|
2401
|
+
' remote show|set-url|set-token [--dir <path>] [--server <url>] [--token <tok>]',
|
|
2402
|
+
' config list|get|set [--key <k>] [--value <v>]',
|
|
2403
|
+
' clean [--dir <path>] [--force]',
|
|
2404
|
+
' workspace set-root --path <dir>',
|
|
2405
|
+
' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
|
|
2406
|
+
' checkout <branch>|--branch <name> | --commit <id> [--create] [--dir <path>]',
|
|
2407
|
+
' rebase --branch <name> [--onto <name>] [--no-push] [--dir <path>]',
|
|
2049
2408
|
'',
|
|
2050
2409
|
'Global options:',
|
|
2051
2410
|
' --server <url> Override default server',
|
|
@@ -2071,7 +2430,6 @@ async function main() {
|
|
|
2071
2430
|
return
|
|
2072
2431
|
}
|
|
2073
2432
|
if (cmd[0] === 'clone') {
|
|
2074
|
-
print({ url: 'Rajaram' }, opts.json === 'true')
|
|
2075
2433
|
if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
|
|
2076
2434
|
if (opts.url) {
|
|
2077
2435
|
await cmdCloneFromUrl(opts, cfg)
|
|
@@ -2104,6 +2462,10 @@ async function main() {
|
|
|
2104
2462
|
await cmdPull(opts)
|
|
2105
2463
|
return
|
|
2106
2464
|
}
|
|
2465
|
+
if (cmd[0] === 'fetch') {
|
|
2466
|
+
await cmdFetch(opts)
|
|
2467
|
+
return
|
|
2468
|
+
}
|
|
2107
2469
|
if (cmd[0] === 'branch') {
|
|
2108
2470
|
await cmdBranch(cmd[1], opts)
|
|
2109
2471
|
return
|
|
@@ -2148,6 +2510,26 @@ async function main() {
|
|
|
2148
2510
|
await cmdMv(opts)
|
|
2149
2511
|
return
|
|
2150
2512
|
}
|
|
2513
|
+
if (cmd[0] === 'init') {
|
|
2514
|
+
await cmdInit(opts)
|
|
2515
|
+
return
|
|
2516
|
+
}
|
|
2517
|
+
if (cmd[0] === 'remote') {
|
|
2518
|
+
await cmdRemote(cmd[1], opts)
|
|
2519
|
+
return
|
|
2520
|
+
}
|
|
2521
|
+
if (cmd[0] === 'config') {
|
|
2522
|
+
await cmdConfig(cmd[1], opts)
|
|
2523
|
+
return
|
|
2524
|
+
}
|
|
2525
|
+
if (cmd[0] === 'clean') {
|
|
2526
|
+
await cmdClean(opts)
|
|
2527
|
+
return
|
|
2528
|
+
}
|
|
2529
|
+
if (cmd[0] === 'rebase') {
|
|
2530
|
+
await cmdRebase(opts)
|
|
2531
|
+
return
|
|
2532
|
+
}
|
|
2151
2533
|
if (cmd[0] === 'cherry-pick') {
|
|
2152
2534
|
await cmdCherryPick(opts)
|
|
2153
2535
|
return
|
|
@@ -2316,7 +2698,7 @@ async function tuiSelectBranch(branches, current) {
|
|
|
2316
2698
|
process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
|
|
2317
2699
|
}
|
|
2318
2700
|
const cleanup = (listener) => {
|
|
2319
|
-
try { process.stdin.removeListener('data', listener) } catch {}
|
|
2701
|
+
try { process.stdin.removeListener('data', listener) } catch { }
|
|
2320
2702
|
process.stdin.setRawMode(false)
|
|
2321
2703
|
process.stdin.pause()
|
|
2322
2704
|
process.stdout.write('\x1b[?25h')
|