resulgit 1.0.0 → 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 +766 -349
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 = []
|
|
@@ -35,7 +53,7 @@ function loadConfig() {
|
|
|
35
53
|
const s = fs.readFileSync(file, 'utf8')
|
|
36
54
|
return JSON.parse(s)
|
|
37
55
|
} catch {
|
|
38
|
-
return { server: '
|
|
56
|
+
return { server: 'http://10.205.11.8:7513', token: '', workspaceRoot: path.join(os.homedir(), 'resulgit-workspace') }
|
|
39
57
|
}
|
|
40
58
|
}
|
|
41
59
|
|
|
@@ -49,7 +67,7 @@ function saveConfig(update) {
|
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
function getServer(opts, cfg) {
|
|
52
|
-
return opts.server || cfg.server || '
|
|
70
|
+
return opts.server || cfg.server || 'http://10.205.11.8:7513'
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
function getToken(opts, cfg) {
|
|
@@ -106,10 +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
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
113
138
|
return
|
|
114
139
|
}
|
|
115
140
|
if (sub === 'register') {
|
|
@@ -120,10 +145,17 @@ async function cmdAuth(sub, opts) {
|
|
|
120
145
|
const displayName = opts.displayName || username
|
|
121
146
|
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
122
147
|
const url = new URL('/api/auth/register', server).toString()
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
|
127
159
|
return
|
|
128
160
|
}
|
|
129
161
|
throw new Error('Unknown auth subcommand')
|
|
@@ -133,10 +165,17 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
133
165
|
const server = getServer(opts, cfg)
|
|
134
166
|
const token = getToken(opts, cfg)
|
|
135
167
|
if (sub === 'list') {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
}
|
|
140
179
|
return
|
|
141
180
|
}
|
|
142
181
|
if (sub === 'create') {
|
|
@@ -147,14 +186,21 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
147
186
|
initializeWithReadme: opts.init === 'true'
|
|
148
187
|
}
|
|
149
188
|
if (!body.name) throw new Error('Missing --name')
|
|
150
|
-
const
|
|
151
|
-
const data = await request('POST', url, body, token)
|
|
152
|
-
print(data, opts.json === 'true')
|
|
189
|
+
const spinner = createSpinner(`Creating repository '${body.name}'...`, opts.json)
|
|
153
190
|
try {
|
|
154
|
-
const
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
204
|
return
|
|
159
205
|
}
|
|
160
206
|
if (sub === 'log') {
|
|
@@ -188,6 +234,34 @@ async function cmdRepo(sub, opts, cfg) {
|
|
|
188
234
|
throw new Error('Unknown repo subcommand')
|
|
189
235
|
}
|
|
190
236
|
|
|
237
|
+
function parseCloneUrl(urlStr) {
|
|
238
|
+
try {
|
|
239
|
+
const u = new URL(String(urlStr))
|
|
240
|
+
const parts = u.pathname.split('/').filter(Boolean)
|
|
241
|
+
const idx = parts.findIndex((p) => p === 'repositories')
|
|
242
|
+
const repo = idx >= 0 ? (parts[idx + 1] || '') : ''
|
|
243
|
+
const branch = u.searchParams.get('branch') || ''
|
|
244
|
+
const server = u.origin
|
|
245
|
+
return { server, repo, branch }
|
|
246
|
+
} catch {
|
|
247
|
+
return { server: '', repo: '', branch: '' }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function cmdCloneFromUrl(opts, cfg) {
|
|
252
|
+
const parsed = parseCloneUrl(opts.url || '')
|
|
253
|
+
if (!parsed.repo) throw new Error('Invalid clone URL')
|
|
254
|
+
const server = parsed.server || getServer(opts, cfg)
|
|
255
|
+
const token = getToken(opts, cfg)
|
|
256
|
+
let branch = parsed.branch
|
|
257
|
+
if (!branch) {
|
|
258
|
+
const info = await request('GET', new URL(`/api/repositories/${parsed.repo}/branches`, server).toString(), null, token)
|
|
259
|
+
branch = info.defaultBranch || 'main'
|
|
260
|
+
}
|
|
261
|
+
const nextOpts = { repo: parsed.repo, branch, dest: opts.dest, server }
|
|
262
|
+
await cmdClone(nextOpts, cfg)
|
|
263
|
+
}
|
|
264
|
+
|
|
191
265
|
async function cmdClone(opts, cfg) {
|
|
192
266
|
const server = getServer(opts, cfg)
|
|
193
267
|
const token = getToken(opts, cfg)
|
|
@@ -195,91 +269,105 @@ async function cmdClone(opts, cfg) {
|
|
|
195
269
|
const branch = opts.branch
|
|
196
270
|
let dest = opts.dest
|
|
197
271
|
if (!repo || !branch) throw new Error('Missing --repo and --branch')
|
|
272
|
+
const spinner = createSpinner('Initializing clone...', opts.json)
|
|
198
273
|
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 {
|
|
208
288
|
dest = String(repo)
|
|
209
289
|
}
|
|
210
|
-
} catch {
|
|
211
|
-
dest = String(repo)
|
|
212
290
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
222
|
-
const data = await res.json()
|
|
223
|
-
const files = data.files || {}
|
|
224
|
-
const root = dest
|
|
225
|
-
for (const [p, content] of Object.entries(files)) {
|
|
226
|
-
const fullPath = path.join(root, p)
|
|
227
|
-
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
228
|
-
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
229
|
-
}
|
|
230
|
-
const metaDir = path.join(root, '.vcs-next')
|
|
231
|
-
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
232
|
-
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
233
|
-
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
234
|
-
const gitDir = path.join(root, '.git')
|
|
235
|
-
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
236
|
-
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
237
|
-
const headContent = `ref: refs/heads/${branch}\n`
|
|
238
|
-
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
239
|
-
const commitId = data.commitId || ''
|
|
240
|
-
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
241
|
-
const gitConfig = [
|
|
242
|
-
'[core]',
|
|
243
|
-
'\trepositoryformatversion = 0',
|
|
244
|
-
'\tfilemode = true',
|
|
245
|
-
'\tbare = false',
|
|
246
|
-
'\tlogallrefupdates = true',
|
|
247
|
-
'',
|
|
248
|
-
'[vcs-next]',
|
|
249
|
-
`\tserver = ${server}`,
|
|
250
|
-
`\trepoId = ${repo}`,
|
|
251
|
-
`\tbranch = ${branch}`,
|
|
252
|
-
`\ttoken = ${token || ''}`
|
|
253
|
-
].join('\n')
|
|
254
|
-
await fs.promises.writeFile(path.join(gitDir, 'config'), gitConfig, 'utf8')
|
|
255
|
-
await fs.promises.writeFile(path.join(gitDir, 'vcs-next.json'), JSON.stringify({ ...meta }, null, 2))
|
|
256
|
-
try {
|
|
257
|
-
const branchesUrl = new URL(`/api/repositories/${repo}/branches`, server)
|
|
258
|
-
const branchesRes = await fetch(branchesUrl.toString(), { headers })
|
|
259
|
-
if (branchesRes.ok) {
|
|
260
|
-
const branchesData = await branchesRes.json()
|
|
261
|
-
const branches = Array.isArray(branchesData.branches) ? branchesData.branches : []
|
|
262
|
-
const allRefs = {}
|
|
263
|
-
for (const b of branches) {
|
|
264
|
-
const name = b.name || ''
|
|
265
|
-
const id = b.commitId || ''
|
|
266
|
-
if (!name) continue
|
|
267
|
-
allRefs[name] = id
|
|
268
|
-
await fs.promises.writeFile(path.join(refsHeadsDir, name), id, 'utf8')
|
|
269
|
-
}
|
|
270
|
-
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-refs.json'), JSON.stringify(allRefs, null, 2))
|
|
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}`)
|
|
271
299
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
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')
|
|
280
309
|
}
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}
|
|
283
371
|
}
|
|
284
372
|
|
|
285
373
|
function readRemoteMeta(dir) {
|
|
@@ -295,7 +383,7 @@ function readRemoteMeta(dir) {
|
|
|
295
383
|
const s = fs.readFileSync(fp, 'utf8')
|
|
296
384
|
const meta = JSON.parse(s)
|
|
297
385
|
if (meta.repoId && meta.branch) return meta
|
|
298
|
-
} catch {}
|
|
386
|
+
} catch { }
|
|
299
387
|
}
|
|
300
388
|
const parent = path.dirname(cur)
|
|
301
389
|
if (parent === cur) break
|
|
@@ -392,7 +480,7 @@ async function cmdRestore(opts) {
|
|
|
392
480
|
const cfg = loadConfig()
|
|
393
481
|
const server = getServer(opts, cfg) || meta.server
|
|
394
482
|
const token = getToken(opts, cfg) || meta.token
|
|
395
|
-
|
|
483
|
+
|
|
396
484
|
const sourceCommit = opts.source || 'HEAD'
|
|
397
485
|
let sourceSnap
|
|
398
486
|
if (sourceCommit === 'HEAD') {
|
|
@@ -400,7 +488,7 @@ async function cmdRestore(opts) {
|
|
|
400
488
|
} else {
|
|
401
489
|
sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
|
|
402
490
|
}
|
|
403
|
-
|
|
491
|
+
|
|
404
492
|
const sourceContent = sourceSnap.files[filePath]
|
|
405
493
|
if (sourceContent === undefined) {
|
|
406
494
|
// File doesn't exist in source - delete it
|
|
@@ -435,17 +523,17 @@ async function cmdDiff(opts) {
|
|
|
435
523
|
const cfg = loadConfig()
|
|
436
524
|
const server = getServer(opts, cfg) || meta.server
|
|
437
525
|
const token = getToken(opts, cfg) || meta.token
|
|
438
|
-
|
|
526
|
+
|
|
439
527
|
const filePath = opts.path
|
|
440
528
|
const commitId = opts.commit
|
|
441
|
-
|
|
529
|
+
|
|
442
530
|
if (commitId) {
|
|
443
531
|
// Show diff for specific commit
|
|
444
532
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
445
533
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
446
534
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
447
535
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
448
|
-
|
|
536
|
+
|
|
449
537
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
|
|
450
538
|
for (const p of files) {
|
|
451
539
|
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
@@ -481,18 +569,18 @@ async function cmdDiff(opts) {
|
|
|
481
569
|
}
|
|
482
570
|
return
|
|
483
571
|
}
|
|
484
|
-
|
|
572
|
+
|
|
485
573
|
// Show diff for working directory
|
|
486
574
|
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
487
575
|
const local = await collectLocal(dir)
|
|
488
576
|
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
|
|
489
|
-
|
|
577
|
+
|
|
490
578
|
for (const p of files) {
|
|
491
579
|
const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
|
|
492
580
|
const localContent = local[p]?.content || null
|
|
493
581
|
const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
|
|
494
582
|
const localId = local[p]?.id
|
|
495
|
-
|
|
583
|
+
|
|
496
584
|
if (remoteId !== localId) {
|
|
497
585
|
if (opts.json === 'true') {
|
|
498
586
|
print({ path: p, remote: remoteContent, local: localContent }, true)
|
|
@@ -549,41 +637,51 @@ async function cmdCommit(opts) {
|
|
|
549
637
|
const dir = path.resolve(opts.dir || '.')
|
|
550
638
|
const message = opts.message || ''
|
|
551
639
|
if (!message) throw new Error('Missing --message')
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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'))
|
|
563
657
|
}
|
|
564
|
-
|
|
658
|
+
return
|
|
565
659
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
583
684
|
}
|
|
584
|
-
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
585
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
586
|
-
print({ pendingCommit: message }, opts.json === 'true')
|
|
587
685
|
}
|
|
588
686
|
|
|
589
687
|
async function pullToDir(repo, branch, dir, server, token) {
|
|
@@ -608,7 +706,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
608
706
|
for (const rel of Object.keys(localMap)) {
|
|
609
707
|
if (!keep.has(rel)) {
|
|
610
708
|
const fp = path.join(root, rel)
|
|
611
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
709
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
612
710
|
}
|
|
613
711
|
}
|
|
614
712
|
const pruneEmptyDirs = async (start) => {
|
|
@@ -621,7 +719,7 @@ async function pullToDir(repo, branch, dir, server, token) {
|
|
|
621
719
|
if (st.isDirectory()) {
|
|
622
720
|
await pruneEmptyDirs(p)
|
|
623
721
|
const left = await fs.promises.readdir(p).catch(() => [])
|
|
624
|
-
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
|
|
722
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch { } }
|
|
625
723
|
}
|
|
626
724
|
}
|
|
627
725
|
}
|
|
@@ -647,8 +745,62 @@ async function cmdPull(opts) {
|
|
|
647
745
|
const cfg = loadConfig()
|
|
648
746
|
const server = getServer(opts, cfg) || meta.server
|
|
649
747
|
const token = getToken(opts, cfg) || meta.token
|
|
650
|
-
|
|
651
|
-
|
|
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')
|
|
652
804
|
}
|
|
653
805
|
|
|
654
806
|
async function fetchRemoteSnapshot(server, repo, branch, token) {
|
|
@@ -719,57 +871,57 @@ async function cmdCherryPick(opts) {
|
|
|
719
871
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
720
872
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
721
873
|
if (m) current = m[1]
|
|
722
|
-
} catch {}
|
|
874
|
+
} catch { }
|
|
723
875
|
targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
|
|
724
876
|
}
|
|
725
877
|
const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
|
|
726
878
|
if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
|
|
727
|
-
|
|
879
|
+
|
|
728
880
|
// Get the current state of the target branch (what we're cherry-picking onto)
|
|
729
881
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
|
|
730
|
-
|
|
882
|
+
|
|
731
883
|
// Get the commit being cherry-picked and its parent
|
|
732
884
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
733
885
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
734
886
|
const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
735
887
|
const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
736
|
-
|
|
888
|
+
|
|
737
889
|
// Pull the target branch to ensure we're working with the latest state
|
|
738
890
|
await pullToDir(meta.repoId, targetBranch, dir, server, token)
|
|
739
|
-
|
|
891
|
+
|
|
740
892
|
// Re-collect local state after pull (now it matches the target branch)
|
|
741
893
|
const localAfterPull = await collectLocal(dir)
|
|
742
|
-
|
|
894
|
+
|
|
743
895
|
// Update local metadata to reflect the pulled state
|
|
744
896
|
const metaDirInit = path.join(dir, '.vcs-next')
|
|
745
897
|
await fs.promises.mkdir(metaDirInit, { recursive: true })
|
|
746
898
|
const localPathInit = path.join(metaDirInit, 'local.json')
|
|
747
899
|
let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
748
|
-
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 { }
|
|
749
901
|
localMetaInit.baseCommitId = currentSnap.commitId
|
|
750
902
|
localMetaInit.baseFiles = currentSnap.files
|
|
751
903
|
await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
|
|
752
|
-
|
|
904
|
+
|
|
753
905
|
// Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
|
|
754
906
|
const conflicts = []
|
|
755
907
|
const changes = []
|
|
756
908
|
const mergedFiles = {}
|
|
757
909
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
|
|
758
|
-
|
|
910
|
+
|
|
759
911
|
for (const p of allPaths) {
|
|
760
912
|
const b = baseSnap.files[p] // base: parent of commit being cherry-picked
|
|
761
913
|
const n = targetSnap.files[p] // next: the commit being cherry-picked
|
|
762
914
|
const c = currentSnap.files[p] // current: current state of target branch (from remote)
|
|
763
|
-
|
|
915
|
+
|
|
764
916
|
// For cherry-pick, we need to apply the changes from the commit onto the current branch
|
|
765
917
|
// The logic: if the commit changed something from its parent, apply that change to current
|
|
766
918
|
const baseContent = b !== undefined ? String(b) : null
|
|
767
919
|
const commitContent = n !== undefined ? String(n) : null
|
|
768
920
|
const currentContent = c !== undefined ? String(c) : null
|
|
769
|
-
|
|
921
|
+
|
|
770
922
|
// Check if commit changed this file from its parent
|
|
771
923
|
const commitChanged = baseContent !== commitContent
|
|
772
|
-
|
|
924
|
+
|
|
773
925
|
if (commitChanged) {
|
|
774
926
|
// The commit modified this file - we want to apply that change
|
|
775
927
|
// Conflict detection:
|
|
@@ -778,15 +930,15 @@ async function cmdCherryPick(opts) {
|
|
|
778
930
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
|
|
779
931
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
780
932
|
// - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
|
|
781
|
-
|
|
933
|
+
|
|
782
934
|
const fileWasAdded = baseContent === null && commitContent !== null
|
|
783
935
|
const fileWasDeleted = baseContent !== null && commitContent === null
|
|
784
936
|
const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
|
|
785
|
-
|
|
937
|
+
|
|
786
938
|
// Check for conflicts: current branch changed the file differently than the commit
|
|
787
939
|
const currentChangedFromBase = currentContent !== baseContent
|
|
788
940
|
const currentDiffersFromCommit = currentContent !== commitContent
|
|
789
|
-
|
|
941
|
+
|
|
790
942
|
// Conflict detection:
|
|
791
943
|
// - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
|
|
792
944
|
// - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
|
|
@@ -794,7 +946,7 @@ async function cmdCherryPick(opts) {
|
|
|
794
946
|
// - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
|
|
795
947
|
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
796
948
|
// - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
|
|
797
|
-
|
|
949
|
+
|
|
798
950
|
// Conflict if:
|
|
799
951
|
// 1. Current branch changed the file from base (current !== base)
|
|
800
952
|
// 2. AND current differs from what commit wants (current !== commit)
|
|
@@ -804,7 +956,7 @@ async function cmdCherryPick(opts) {
|
|
|
804
956
|
// this is always safe - no conflict. The file is simply being added.
|
|
805
957
|
const safeAddCase = fileWasAdded && currentContent === null
|
|
806
958
|
const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
|
|
807
|
-
|
|
959
|
+
|
|
808
960
|
// Only conflict if file exists in current AND was changed differently
|
|
809
961
|
if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
|
|
810
962
|
// Conflict: both changed the file differently
|
|
@@ -834,7 +986,7 @@ async function cmdCherryPick(opts) {
|
|
|
834
986
|
}
|
|
835
987
|
}
|
|
836
988
|
}
|
|
837
|
-
|
|
989
|
+
|
|
838
990
|
if (conflicts.length > 0) {
|
|
839
991
|
// Write conflict markers to files
|
|
840
992
|
for (const conflict of conflicts) {
|
|
@@ -844,11 +996,11 @@ async function cmdCherryPick(opts) {
|
|
|
844
996
|
conflict.current,
|
|
845
997
|
conflict.incoming,
|
|
846
998
|
meta.branch,
|
|
847
|
-
`cherry-pick-${commitId.slice(0,7)}`
|
|
999
|
+
`cherry-pick-${commitId.slice(0, 7)}`
|
|
848
1000
|
)
|
|
849
1001
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
850
1002
|
}
|
|
851
|
-
|
|
1003
|
+
|
|
852
1004
|
// Store conflict state in metadata
|
|
853
1005
|
const metaDir = path.join(dir, '.vcs-next')
|
|
854
1006
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -857,9 +1009,9 @@ async function cmdCherryPick(opts) {
|
|
|
857
1009
|
try {
|
|
858
1010
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
859
1011
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
860
|
-
} catch {}
|
|
1012
|
+
} catch { }
|
|
861
1013
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
862
|
-
|
|
1014
|
+
|
|
863
1015
|
if (opts.json === 'true') {
|
|
864
1016
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
|
|
865
1017
|
} else {
|
|
@@ -872,22 +1024,22 @@ async function cmdCherryPick(opts) {
|
|
|
872
1024
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
873
1025
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
874
1026
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
875
|
-
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'))
|
|
876
1028
|
}
|
|
877
1029
|
return
|
|
878
1030
|
}
|
|
879
|
-
|
|
1031
|
+
|
|
880
1032
|
// Apply the changes to the filesystem
|
|
881
1033
|
for (const ch of changes) {
|
|
882
1034
|
const fp = path.join(dir, ch.path)
|
|
883
1035
|
if (ch.type === 'delete') {
|
|
884
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1036
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
885
1037
|
} else if (ch.type === 'write') {
|
|
886
1038
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
887
1039
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
888
1040
|
}
|
|
889
1041
|
}
|
|
890
|
-
|
|
1042
|
+
|
|
891
1043
|
// Update local metadata with the pending commit
|
|
892
1044
|
const metaDir = path.join(dir, '.vcs-next')
|
|
893
1045
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -896,43 +1048,43 @@ async function cmdCherryPick(opts) {
|
|
|
896
1048
|
try {
|
|
897
1049
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
898
1050
|
localMeta = JSON.parse(s)
|
|
899
|
-
} catch {}
|
|
900
|
-
|
|
901
|
-
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
|
|
902
|
-
|
|
1051
|
+
} catch { }
|
|
1052
|
+
|
|
1053
|
+
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0, 7)}: ${commit.message || ''}`
|
|
1054
|
+
|
|
903
1055
|
// Collect final state after applying changes
|
|
904
1056
|
const finalLocal = await collectLocal(dir)
|
|
905
1057
|
const finalFiles = {}
|
|
906
1058
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
907
1059
|
finalFiles[p] = v.content
|
|
908
1060
|
}
|
|
909
|
-
|
|
1061
|
+
|
|
910
1062
|
// Set pending commit with the actual files
|
|
911
1063
|
localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
|
|
912
1064
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
913
|
-
|
|
1065
|
+
|
|
914
1066
|
// Ensure remote.json has the correct target branch before pushing
|
|
915
1067
|
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
916
1068
|
let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
|
|
917
1069
|
try {
|
|
918
1070
|
const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
|
|
919
1071
|
remoteMeta = { ...JSON.parse(s), branch: targetBranch }
|
|
920
|
-
} catch {}
|
|
1072
|
+
} catch { }
|
|
921
1073
|
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
922
|
-
|
|
1074
|
+
|
|
923
1075
|
// Also update .git metadata
|
|
924
1076
|
const gitDir = path.join(dir, '.git')
|
|
925
1077
|
const gitMetaPath = path.join(gitDir, 'vcs-next.json')
|
|
926
1078
|
await fs.promises.mkdir(gitDir, { recursive: true })
|
|
927
1079
|
await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
928
1080
|
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
|
|
929
|
-
|
|
1081
|
+
|
|
930
1082
|
if (opts.json === 'true') {
|
|
931
1083
|
print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
|
|
932
1084
|
} else {
|
|
933
|
-
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'))
|
|
934
1086
|
}
|
|
935
|
-
|
|
1087
|
+
|
|
936
1088
|
if (opts.noPush !== 'true') {
|
|
937
1089
|
await cmdPush({ dir, json: opts.json, message: cherryMsg })
|
|
938
1090
|
}
|
|
@@ -955,7 +1107,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
955
1107
|
const incoming = String(incomingContent || '')
|
|
956
1108
|
const currentLines = current.split(/\r?\n/)
|
|
957
1109
|
const incomingLines = incoming.split(/\r?\n/)
|
|
958
|
-
|
|
1110
|
+
|
|
959
1111
|
// Simple conflict marker format
|
|
960
1112
|
const markers = [
|
|
961
1113
|
`<<<<<<< ${currentLabel || 'HEAD'}`,
|
|
@@ -964,7 +1116,7 @@ function writeConflictMarkers(currentContent, incomingContent, currentLabel, inc
|
|
|
964
1116
|
...incomingLines,
|
|
965
1117
|
`>>>>>>> ${incomingLabel || 'incoming'}`
|
|
966
1118
|
]
|
|
967
|
-
|
|
1119
|
+
|
|
968
1120
|
return markers.join('\n')
|
|
969
1121
|
}
|
|
970
1122
|
|
|
@@ -990,73 +1142,87 @@ async function cmdPush(opts) {
|
|
|
990
1142
|
const metaDir = path.join(dir, '.vcs-next')
|
|
991
1143
|
const localPath = path.join(metaDir, 'local.json')
|
|
992
1144
|
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1145
|
+
const spinner = createSpinner('Preparing to push...', opts.json)
|
|
993
1146
|
try {
|
|
994
1147
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
995
1148
|
localMeta = JSON.parse(s)
|
|
996
|
-
} catch {}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
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'))
|
|
1008
1166
|
}
|
|
1009
|
-
|
|
1167
|
+
return
|
|
1010
1168
|
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
const
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
if (
|
|
1034
|
-
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
+
}
|
|
1038
1198
|
}
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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
|
+
}
|
|
1048
1209
|
}
|
|
1210
|
+
return
|
|
1049
1211
|
}
|
|
1050
|
-
|
|
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
|
|
1051
1225
|
}
|
|
1052
|
-
const body = { message: localMeta.pendingCommit?.message || (opts.message || 'Push'), files: merged, branchName: remoteMeta.branch }
|
|
1053
|
-
const url = new URL(`/api/repositories/${remoteMeta.repoId}/commits`, server).toString()
|
|
1054
|
-
const data = await request('POST', url, body, token)
|
|
1055
|
-
localMeta.baseCommitId = data.id || remote.commitId || ''
|
|
1056
|
-
localMeta.baseFiles = merged
|
|
1057
|
-
localMeta.pendingCommit = null
|
|
1058
|
-
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1059
|
-
print({ pushed: localMeta.baseCommitId }, opts.json === 'true')
|
|
1060
1226
|
}
|
|
1061
1227
|
|
|
1062
1228
|
async function cmdMerge(opts) {
|
|
@@ -1067,13 +1233,13 @@ async function cmdMerge(opts) {
|
|
|
1067
1233
|
const token = getToken(opts, cfg) || meta.token
|
|
1068
1234
|
const sourceBranch = opts.branch || ''
|
|
1069
1235
|
if (!sourceBranch) throw new Error('Missing --branch')
|
|
1070
|
-
|
|
1236
|
+
|
|
1071
1237
|
// Get current branch state
|
|
1072
1238
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1073
|
-
|
|
1239
|
+
|
|
1074
1240
|
// Get source branch state
|
|
1075
1241
|
const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
|
|
1076
|
-
|
|
1242
|
+
|
|
1077
1243
|
// Find common ancestor (merge base)
|
|
1078
1244
|
// For simplicity, we'll use the current branch's base commit as the merge base
|
|
1079
1245
|
// In a full implementation, we'd find the actual common ancestor
|
|
@@ -1081,29 +1247,29 @@ async function cmdMerge(opts) {
|
|
|
1081
1247
|
const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
|
|
1082
1248
|
const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
|
|
1083
1249
|
if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
|
|
1084
|
-
|
|
1250
|
+
|
|
1085
1251
|
// Get base commit (for now, use current branch's commit as base)
|
|
1086
1252
|
// In real Git, we'd find the merge base commit
|
|
1087
1253
|
const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1088
|
-
|
|
1254
|
+
|
|
1089
1255
|
// Pull current branch to ensure we're up to date
|
|
1090
1256
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1091
1257
|
const localAfterPull = await collectLocal(dir)
|
|
1092
|
-
|
|
1258
|
+
|
|
1093
1259
|
// Three-way merge: base, current (target), source
|
|
1094
1260
|
const conflicts = []
|
|
1095
1261
|
const changes = []
|
|
1096
1262
|
const mergedFiles = {}
|
|
1097
1263
|
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
|
|
1098
|
-
|
|
1264
|
+
|
|
1099
1265
|
for (const p of allPaths) {
|
|
1100
1266
|
const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
|
|
1101
1267
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1102
1268
|
const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
|
|
1103
|
-
|
|
1269
|
+
|
|
1104
1270
|
const currentChanged = current !== base
|
|
1105
1271
|
const sourceChanged = source !== base
|
|
1106
|
-
|
|
1272
|
+
|
|
1107
1273
|
if (currentChanged && sourceChanged && current !== source) {
|
|
1108
1274
|
// Conflict: both branches changed the file differently
|
|
1109
1275
|
const line = firstDiffLine(current || '', source || '')
|
|
@@ -1137,7 +1303,7 @@ async function cmdMerge(opts) {
|
|
|
1137
1303
|
}
|
|
1138
1304
|
}
|
|
1139
1305
|
}
|
|
1140
|
-
|
|
1306
|
+
|
|
1141
1307
|
if (conflicts.length > 0) {
|
|
1142
1308
|
// Write conflict markers to files
|
|
1143
1309
|
for (const conflict of conflicts) {
|
|
@@ -1151,7 +1317,7 @@ async function cmdMerge(opts) {
|
|
|
1151
1317
|
)
|
|
1152
1318
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1153
1319
|
}
|
|
1154
|
-
|
|
1320
|
+
|
|
1155
1321
|
// Store conflict state in metadata
|
|
1156
1322
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1157
1323
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1160,9 +1326,9 @@ async function cmdMerge(opts) {
|
|
|
1160
1326
|
try {
|
|
1161
1327
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1162
1328
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1163
|
-
} catch {}
|
|
1329
|
+
} catch { }
|
|
1164
1330
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1165
|
-
|
|
1331
|
+
|
|
1166
1332
|
const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
|
|
1167
1333
|
if (opts.json === 'true') {
|
|
1168
1334
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
|
|
@@ -1180,18 +1346,18 @@ async function cmdMerge(opts) {
|
|
|
1180
1346
|
}
|
|
1181
1347
|
return
|
|
1182
1348
|
}
|
|
1183
|
-
|
|
1349
|
+
|
|
1184
1350
|
// Apply changes to filesystem
|
|
1185
1351
|
for (const ch of changes) {
|
|
1186
1352
|
const fp = path.join(dir, ch.path)
|
|
1187
1353
|
if (ch.type === 'delete') {
|
|
1188
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1354
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1189
1355
|
} else if (ch.type === 'write') {
|
|
1190
1356
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1191
1357
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1192
1358
|
}
|
|
1193
1359
|
}
|
|
1194
|
-
|
|
1360
|
+
|
|
1195
1361
|
// Update local metadata
|
|
1196
1362
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1197
1363
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1200,27 +1366,27 @@ async function cmdMerge(opts) {
|
|
|
1200
1366
|
try {
|
|
1201
1367
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1202
1368
|
localMeta = JSON.parse(s)
|
|
1203
|
-
} catch {}
|
|
1204
|
-
|
|
1369
|
+
} catch { }
|
|
1370
|
+
|
|
1205
1371
|
const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
|
|
1206
|
-
|
|
1372
|
+
|
|
1207
1373
|
// Collect final state
|
|
1208
1374
|
const finalLocal = await collectLocal(dir)
|
|
1209
1375
|
const finalFiles = {}
|
|
1210
1376
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1211
1377
|
finalFiles[p] = v.content
|
|
1212
1378
|
}
|
|
1213
|
-
|
|
1379
|
+
|
|
1214
1380
|
// Set pending commit
|
|
1215
1381
|
localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
|
|
1216
1382
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1217
|
-
|
|
1383
|
+
|
|
1218
1384
|
if (opts.json === 'true') {
|
|
1219
1385
|
print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
|
|
1220
1386
|
} else {
|
|
1221
1387
|
process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1222
1388
|
}
|
|
1223
|
-
|
|
1389
|
+
|
|
1224
1390
|
if (opts.noPush !== 'true') {
|
|
1225
1391
|
await cmdPush({ dir, json: opts.json, message: mergeMsg })
|
|
1226
1392
|
}
|
|
@@ -1231,7 +1397,7 @@ async function cmdStash(sub, opts) {
|
|
|
1231
1397
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1232
1398
|
const stashDir = path.join(metaDir, 'stash')
|
|
1233
1399
|
await fs.promises.mkdir(stashDir, { recursive: true })
|
|
1234
|
-
|
|
1400
|
+
|
|
1235
1401
|
if (sub === 'list' || sub === undefined) {
|
|
1236
1402
|
try {
|
|
1237
1403
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1264,7 +1430,7 @@ async function cmdStash(sub, opts) {
|
|
|
1264
1430
|
}
|
|
1265
1431
|
return
|
|
1266
1432
|
}
|
|
1267
|
-
|
|
1433
|
+
|
|
1268
1434
|
if (sub === 'save' || (sub === undefined && !opts.list)) {
|
|
1269
1435
|
const message = opts.message || 'WIP'
|
|
1270
1436
|
const local = await collectLocal(dir)
|
|
@@ -1275,14 +1441,14 @@ async function cmdStash(sub, opts) {
|
|
|
1275
1441
|
const stashId = Date.now().toString()
|
|
1276
1442
|
const stash = { message, files, createdAt: Date.now() }
|
|
1277
1443
|
await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
|
|
1278
|
-
|
|
1444
|
+
|
|
1279
1445
|
// Restore to base state (discard local changes)
|
|
1280
1446
|
const meta = readRemoteMeta(dir)
|
|
1281
1447
|
const cfg = loadConfig()
|
|
1282
1448
|
const server = getServer(opts, cfg) || meta.server
|
|
1283
1449
|
const token = getToken(opts, cfg) || meta.token
|
|
1284
1450
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1285
|
-
|
|
1451
|
+
|
|
1286
1452
|
if (opts.json === 'true') {
|
|
1287
1453
|
print({ stashId, message }, true)
|
|
1288
1454
|
} else {
|
|
@@ -1290,7 +1456,7 @@ async function cmdStash(sub, opts) {
|
|
|
1290
1456
|
}
|
|
1291
1457
|
return
|
|
1292
1458
|
}
|
|
1293
|
-
|
|
1459
|
+
|
|
1294
1460
|
if (sub === 'pop' || sub === 'apply') {
|
|
1295
1461
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1296
1462
|
try {
|
|
@@ -1306,19 +1472,19 @@ async function cmdStash(sub, opts) {
|
|
|
1306
1472
|
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1307
1473
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1308
1474
|
const stash = stashes[stashIndex]
|
|
1309
|
-
|
|
1475
|
+
|
|
1310
1476
|
// Apply stash files
|
|
1311
1477
|
for (const [p, content] of Object.entries(stash.files || {})) {
|
|
1312
1478
|
const fp = path.join(dir, p)
|
|
1313
1479
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1314
1480
|
await fs.promises.writeFile(fp, content, 'utf8')
|
|
1315
1481
|
}
|
|
1316
|
-
|
|
1482
|
+
|
|
1317
1483
|
if (sub === 'pop') {
|
|
1318
1484
|
// Remove stash
|
|
1319
1485
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1320
1486
|
}
|
|
1321
|
-
|
|
1487
|
+
|
|
1322
1488
|
if (opts.json === 'true') {
|
|
1323
1489
|
print({ applied: stash.id, message: stash.message }, true)
|
|
1324
1490
|
} else {
|
|
@@ -1329,7 +1495,7 @@ async function cmdStash(sub, opts) {
|
|
|
1329
1495
|
}
|
|
1330
1496
|
return
|
|
1331
1497
|
}
|
|
1332
|
-
|
|
1498
|
+
|
|
1333
1499
|
if (sub === 'drop') {
|
|
1334
1500
|
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1335
1501
|
try {
|
|
@@ -1346,7 +1512,7 @@ async function cmdStash(sub, opts) {
|
|
|
1346
1512
|
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1347
1513
|
const stash = stashes[stashIndex]
|
|
1348
1514
|
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1349
|
-
|
|
1515
|
+
|
|
1350
1516
|
if (opts.json === 'true') {
|
|
1351
1517
|
print({ dropped: stash.id }, true)
|
|
1352
1518
|
} else {
|
|
@@ -1357,7 +1523,7 @@ async function cmdStash(sub, opts) {
|
|
|
1357
1523
|
}
|
|
1358
1524
|
return
|
|
1359
1525
|
}
|
|
1360
|
-
|
|
1526
|
+
|
|
1361
1527
|
if (sub === 'clear') {
|
|
1362
1528
|
try {
|
|
1363
1529
|
const files = await fs.promises.readdir(stashDir)
|
|
@@ -1374,7 +1540,7 @@ async function cmdStash(sub, opts) {
|
|
|
1374
1540
|
}
|
|
1375
1541
|
return
|
|
1376
1542
|
}
|
|
1377
|
-
|
|
1543
|
+
|
|
1378
1544
|
throw new Error('Unknown stash subcommand')
|
|
1379
1545
|
}
|
|
1380
1546
|
|
|
@@ -1396,7 +1562,7 @@ async function cmdBranch(sub, opts) {
|
|
|
1396
1562
|
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
1397
1563
|
const m = head.match(/refs\/heads\/(.+)/)
|
|
1398
1564
|
if (m) current = m[1]
|
|
1399
|
-
} catch {}
|
|
1565
|
+
} catch { }
|
|
1400
1566
|
process.stdout.write(color('Branches:\n', 'bold'))
|
|
1401
1567
|
const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
|
|
1402
1568
|
for (const b of list) {
|
|
@@ -1434,7 +1600,21 @@ async function cmdBranch(sub, opts) {
|
|
|
1434
1600
|
print(data, opts.json === 'true')
|
|
1435
1601
|
return
|
|
1436
1602
|
}
|
|
1437
|
-
|
|
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
|
+
|
|
1438
1618
|
throw new Error('Unknown branch subcommand')
|
|
1439
1619
|
}
|
|
1440
1620
|
|
|
@@ -1464,7 +1644,7 @@ async function checkoutCommit(meta, dir, commitId, server, token) {
|
|
|
1464
1644
|
for (const rel of Object.keys(localMap)) {
|
|
1465
1645
|
if (!keep.has(rel)) {
|
|
1466
1646
|
const fp = path.join(root, rel)
|
|
1467
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1647
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1468
1648
|
}
|
|
1469
1649
|
}
|
|
1470
1650
|
const metaDir = path.join(root, '.vcs-next')
|
|
@@ -1492,6 +1672,11 @@ async function cmdCheckout(opts) {
|
|
|
1492
1672
|
return
|
|
1493
1673
|
}
|
|
1494
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
|
+
}
|
|
1495
1680
|
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1496
1681
|
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1497
1682
|
}
|
|
@@ -1585,7 +1770,7 @@ async function cmdHead(opts) {
|
|
|
1585
1770
|
} else {
|
|
1586
1771
|
commitId = head.trim()
|
|
1587
1772
|
}
|
|
1588
|
-
} catch {}
|
|
1773
|
+
} catch { }
|
|
1589
1774
|
if (!commitId) {
|
|
1590
1775
|
const cfg = loadConfig()
|
|
1591
1776
|
const server = getServer(opts, cfg) || meta.server
|
|
@@ -1606,12 +1791,12 @@ async function cmdShow(opts) {
|
|
|
1606
1791
|
const token = getToken(opts, cfg) || meta.token
|
|
1607
1792
|
const commitId = opts.commit
|
|
1608
1793
|
if (!commitId) throw new Error('Missing --commit')
|
|
1609
|
-
|
|
1794
|
+
|
|
1610
1795
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1611
1796
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1612
1797
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1613
1798
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1614
|
-
|
|
1799
|
+
|
|
1615
1800
|
if (opts.json === 'true') {
|
|
1616
1801
|
print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
|
|
1617
1802
|
} else {
|
|
@@ -1623,7 +1808,7 @@ async function cmdShow(opts) {
|
|
|
1623
1808
|
process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
|
|
1624
1809
|
}
|
|
1625
1810
|
process.stdout.write(`\n${commit.message || ''}\n\n`)
|
|
1626
|
-
|
|
1811
|
+
|
|
1627
1812
|
// Show file changes
|
|
1628
1813
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
1629
1814
|
const changed = []
|
|
@@ -1654,34 +1839,34 @@ async function cmdRevert(opts) {
|
|
|
1654
1839
|
const token = getToken(opts, cfg) || meta.token
|
|
1655
1840
|
const commitId = opts.commit
|
|
1656
1841
|
if (!commitId) throw new Error('Missing --commit')
|
|
1657
|
-
|
|
1842
|
+
|
|
1658
1843
|
// Get the commit to revert
|
|
1659
1844
|
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1660
1845
|
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1661
1846
|
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1662
1847
|
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1663
|
-
|
|
1848
|
+
|
|
1664
1849
|
// Get current state
|
|
1665
1850
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1666
1851
|
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1667
1852
|
const localAfterPull = await collectLocal(dir)
|
|
1668
|
-
|
|
1853
|
+
|
|
1669
1854
|
// Revert: apply inverse of commit changes
|
|
1670
1855
|
const conflicts = []
|
|
1671
1856
|
const changes = []
|
|
1672
1857
|
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
|
|
1673
|
-
|
|
1858
|
+
|
|
1674
1859
|
for (const p of allPaths) {
|
|
1675
1860
|
const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
1676
1861
|
const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
1677
1862
|
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1678
1863
|
const local = localAfterPull[p]?.content || null
|
|
1679
|
-
|
|
1864
|
+
|
|
1680
1865
|
// Revert: we want to go from commit state back to parent state
|
|
1681
1866
|
// But check if current branch has changed this file
|
|
1682
1867
|
const commitChanged = base !== commitContent
|
|
1683
1868
|
const currentChanged = current !== base
|
|
1684
|
-
|
|
1869
|
+
|
|
1685
1870
|
if (commitChanged) {
|
|
1686
1871
|
// Commit changed this file - we want to revert it
|
|
1687
1872
|
if (currentChanged && current !== base) {
|
|
@@ -1703,7 +1888,7 @@ async function cmdRevert(opts) {
|
|
|
1703
1888
|
}
|
|
1704
1889
|
}
|
|
1705
1890
|
}
|
|
1706
|
-
|
|
1891
|
+
|
|
1707
1892
|
if (conflicts.length > 0) {
|
|
1708
1893
|
// Write conflict markers to files
|
|
1709
1894
|
for (const conflict of conflicts) {
|
|
@@ -1713,11 +1898,11 @@ async function cmdRevert(opts) {
|
|
|
1713
1898
|
conflict.current,
|
|
1714
1899
|
conflict.incoming,
|
|
1715
1900
|
meta.branch,
|
|
1716
|
-
`revert-${commitId.slice(0,7)}`
|
|
1901
|
+
`revert-${commitId.slice(0, 7)}`
|
|
1717
1902
|
)
|
|
1718
1903
|
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1719
1904
|
}
|
|
1720
|
-
|
|
1905
|
+
|
|
1721
1906
|
// Store conflict state in metadata
|
|
1722
1907
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1723
1908
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
@@ -1726,9 +1911,9 @@ async function cmdRevert(opts) {
|
|
|
1726
1911
|
try {
|
|
1727
1912
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1728
1913
|
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1729
|
-
} catch {}
|
|
1914
|
+
} catch { }
|
|
1730
1915
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1731
|
-
|
|
1916
|
+
|
|
1732
1917
|
if (opts.json === 'true') {
|
|
1733
1918
|
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
|
|
1734
1919
|
} else {
|
|
@@ -1741,22 +1926,22 @@ async function cmdRevert(opts) {
|
|
|
1741
1926
|
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
1742
1927
|
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
1743
1928
|
process.stdout.write(color(` =======\n`, 'dim'))
|
|
1744
|
-
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
|
|
1929
|
+
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0, 7)}\n`, 'dim'))
|
|
1745
1930
|
}
|
|
1746
1931
|
return
|
|
1747
1932
|
}
|
|
1748
|
-
|
|
1933
|
+
|
|
1749
1934
|
// Apply changes
|
|
1750
1935
|
for (const ch of changes) {
|
|
1751
1936
|
const fp = path.join(dir, ch.path)
|
|
1752
1937
|
if (ch.type === 'delete') {
|
|
1753
|
-
try { await fs.promises.unlink(fp) } catch {}
|
|
1938
|
+
try { await fs.promises.unlink(fp) } catch { }
|
|
1754
1939
|
} else if (ch.type === 'write') {
|
|
1755
1940
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1756
1941
|
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1757
1942
|
}
|
|
1758
1943
|
}
|
|
1759
|
-
|
|
1944
|
+
|
|
1760
1945
|
// Update metadata
|
|
1761
1946
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1762
1947
|
const localPath = path.join(metaDir, 'local.json')
|
|
@@ -1765,24 +1950,24 @@ async function cmdRevert(opts) {
|
|
|
1765
1950
|
try {
|
|
1766
1951
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1767
1952
|
localMeta = JSON.parse(s)
|
|
1768
|
-
} catch {}
|
|
1769
|
-
|
|
1953
|
+
} catch { }
|
|
1954
|
+
|
|
1770
1955
|
const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
|
|
1771
1956
|
const finalLocal = await collectLocal(dir)
|
|
1772
1957
|
const finalFiles = {}
|
|
1773
1958
|
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1774
1959
|
finalFiles[p] = v.content
|
|
1775
1960
|
}
|
|
1776
|
-
|
|
1961
|
+
|
|
1777
1962
|
localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
|
|
1778
1963
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1779
|
-
|
|
1964
|
+
|
|
1780
1965
|
if (opts.json === 'true') {
|
|
1781
1966
|
print({ reverted: commitId, changes: changes.length }, true)
|
|
1782
1967
|
} else {
|
|
1783
|
-
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'))
|
|
1784
1969
|
}
|
|
1785
|
-
|
|
1970
|
+
|
|
1786
1971
|
if (opts.noPush !== 'true') {
|
|
1787
1972
|
await cmdPush({ dir, json: opts.json, message: revertMsg })
|
|
1788
1973
|
}
|
|
@@ -1794,11 +1979,11 @@ async function cmdReset(opts) {
|
|
|
1794
1979
|
const cfg = loadConfig()
|
|
1795
1980
|
const server = getServer(opts, cfg) || meta.server
|
|
1796
1981
|
const token = getToken(opts, cfg) || meta.token
|
|
1797
|
-
|
|
1982
|
+
|
|
1798
1983
|
const commitId = opts.commit || 'HEAD'
|
|
1799
1984
|
const mode = opts.mode || 'mixed' // soft, mixed, hard
|
|
1800
1985
|
const filePath = opts.path
|
|
1801
|
-
|
|
1986
|
+
|
|
1802
1987
|
if (filePath) {
|
|
1803
1988
|
// Reset specific file (unstage)
|
|
1804
1989
|
const metaDir = path.join(dir, '.vcs-next')
|
|
@@ -1807,8 +1992,8 @@ async function cmdReset(opts) {
|
|
|
1807
1992
|
try {
|
|
1808
1993
|
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1809
1994
|
localMeta = JSON.parse(s)
|
|
1810
|
-
} catch {}
|
|
1811
|
-
|
|
1995
|
+
} catch { }
|
|
1996
|
+
|
|
1812
1997
|
// Restore file from base
|
|
1813
1998
|
const baseContent = localMeta.baseFiles[filePath]
|
|
1814
1999
|
if (baseContent !== undefined) {
|
|
@@ -1816,7 +2001,7 @@ async function cmdReset(opts) {
|
|
|
1816
2001
|
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1817
2002
|
await fs.promises.writeFile(fp, String(baseContent), 'utf8')
|
|
1818
2003
|
}
|
|
1819
|
-
|
|
2004
|
+
|
|
1820
2005
|
if (opts.json === 'true') {
|
|
1821
2006
|
print({ reset: filePath }, true)
|
|
1822
2007
|
} else {
|
|
@@ -1824,7 +2009,7 @@ async function cmdReset(opts) {
|
|
|
1824
2009
|
}
|
|
1825
2010
|
return
|
|
1826
2011
|
}
|
|
1827
|
-
|
|
2012
|
+
|
|
1828
2013
|
// Reset to commit
|
|
1829
2014
|
let targetSnap
|
|
1830
2015
|
if (commitId === 'HEAD') {
|
|
@@ -1832,7 +2017,7 @@ async function cmdReset(opts) {
|
|
|
1832
2017
|
} else {
|
|
1833
2018
|
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1834
2019
|
}
|
|
1835
|
-
|
|
2020
|
+
|
|
1836
2021
|
if (mode === 'hard') {
|
|
1837
2022
|
// Hard reset: discard all changes, reset to commit
|
|
1838
2023
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
@@ -1853,14 +2038,14 @@ async function cmdReset(opts) {
|
|
|
1853
2038
|
// Mixed reset: keep changes unstaged (default)
|
|
1854
2039
|
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1855
2040
|
}
|
|
1856
|
-
|
|
2041
|
+
|
|
1857
2042
|
// Update metadata
|
|
1858
2043
|
const metaDir = path.join(dir, '.vcs-next')
|
|
1859
2044
|
const localPath = path.join(metaDir, 'local.json')
|
|
1860
2045
|
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1861
2046
|
const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
|
|
1862
2047
|
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1863
|
-
|
|
2048
|
+
|
|
1864
2049
|
if (opts.json === 'true') {
|
|
1865
2050
|
print({ reset: commitId, mode }, true)
|
|
1866
2051
|
} else {
|
|
@@ -1868,23 +2053,57 @@ async function cmdReset(opts) {
|
|
|
1868
2053
|
}
|
|
1869
2054
|
}
|
|
1870
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
|
+
|
|
1871
2090
|
async function cmdMv(opts) {
|
|
1872
2091
|
const dir = path.resolve(opts.dir || '.')
|
|
1873
2092
|
const from = opts.from
|
|
1874
2093
|
const to = opts.to
|
|
1875
2094
|
if (!from || !to) throw new Error('Missing --from and --to')
|
|
1876
|
-
|
|
2095
|
+
|
|
1877
2096
|
const fromPath = path.join(dir, from)
|
|
1878
2097
|
const toPath = path.join(dir, to)
|
|
1879
|
-
|
|
2098
|
+
|
|
1880
2099
|
// Check if source exists
|
|
1881
2100
|
const stat = await fs.promises.stat(fromPath).catch(() => null)
|
|
1882
2101
|
if (!stat) throw new Error(`Source file not found: ${from}`)
|
|
1883
|
-
|
|
2102
|
+
|
|
1884
2103
|
// Move file
|
|
1885
2104
|
await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
|
|
1886
2105
|
await fs.promises.rename(fromPath, toPath)
|
|
1887
|
-
|
|
2106
|
+
|
|
1888
2107
|
// If it's a tracked file, we need to update it in the next commit
|
|
1889
2108
|
// For now, just move it - the next commit will track the change
|
|
1890
2109
|
if (opts.json === 'true') {
|
|
@@ -1896,7 +2115,7 @@ async function cmdMv(opts) {
|
|
|
1896
2115
|
|
|
1897
2116
|
async function cmdAdd(opts) {
|
|
1898
2117
|
const dir = path.resolve(opts.dir || '.')
|
|
1899
|
-
|
|
2118
|
+
|
|
1900
2119
|
// Handle --all flag
|
|
1901
2120
|
if (opts.all === 'true' || opts.all === true) {
|
|
1902
2121
|
const meta = readRemoteMeta(dir)
|
|
@@ -1905,7 +2124,7 @@ async function cmdAdd(opts) {
|
|
|
1905
2124
|
const token = getToken(opts, cfg) || meta.token
|
|
1906
2125
|
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
1907
2126
|
const local = await collectLocal(dir)
|
|
1908
|
-
|
|
2127
|
+
|
|
1909
2128
|
// Stage all changes (add new, modified, delete removed)
|
|
1910
2129
|
const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
|
|
1911
2130
|
let stagedCount = 0
|
|
@@ -1916,7 +2135,7 @@ async function cmdAdd(opts) {
|
|
|
1916
2135
|
stagedCount++
|
|
1917
2136
|
}
|
|
1918
2137
|
}
|
|
1919
|
-
|
|
2138
|
+
|
|
1920
2139
|
if (opts.json === 'true') {
|
|
1921
2140
|
print({ staged: stagedCount }, true)
|
|
1922
2141
|
} else {
|
|
@@ -1924,7 +2143,7 @@ async function cmdAdd(opts) {
|
|
|
1924
2143
|
}
|
|
1925
2144
|
return
|
|
1926
2145
|
}
|
|
1927
|
-
|
|
2146
|
+
|
|
1928
2147
|
const p = opts.path
|
|
1929
2148
|
if (!p) throw new Error('Missing --path')
|
|
1930
2149
|
const abs = path.join(dir, p)
|
|
@@ -1972,6 +2191,169 @@ async function cmdAdd(opts) {
|
|
|
1972
2191
|
print({ added: p, dir }, opts.json === 'true')
|
|
1973
2192
|
}
|
|
1974
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
|
+
}
|
|
1975
2357
|
function help() {
|
|
1976
2358
|
const h = [
|
|
1977
2359
|
'Usage: resulgit <group> <command> [options]',
|
|
@@ -1983,10 +2365,10 @@ function help() {
|
|
|
1983
2365
|
' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
|
|
1984
2366
|
' repo list [--json]',
|
|
1985
2367
|
' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
' 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>]',
|
|
1990
2372
|
' switch --branch <name> [--dir <path>]',
|
|
1991
2373
|
' current [--dir <path>] (show active repo/branch)',
|
|
1992
2374
|
' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
|
|
@@ -1994,29 +2376,35 @@ function help() {
|
|
|
1994
2376
|
' status [--dir <path>] [--json]',
|
|
1995
2377
|
' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
|
|
1996
2378
|
' commit --message <text> [--dir <path>] [--json]',
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2379
|
+
' push [--dir <path>] [--json]',
|
|
2380
|
+
' head [--dir <path>] [--json]',
|
|
2381
|
+
' rm --path <file> [--dir <path>] [--json]',
|
|
2000
2382
|
' pull [--dir <path>]',
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
'
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
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>]',
|
|
2020
2408
|
'',
|
|
2021
2409
|
'Global options:',
|
|
2022
2410
|
' --server <url> Override default server',
|
|
@@ -2042,7 +2430,12 @@ async function main() {
|
|
|
2042
2430
|
return
|
|
2043
2431
|
}
|
|
2044
2432
|
if (cmd[0] === 'clone') {
|
|
2045
|
-
|
|
2433
|
+
if (!opts.url && cmd[1] && !cmd[1].startsWith('--')) opts.url = cmd[1]
|
|
2434
|
+
if (opts.url) {
|
|
2435
|
+
await cmdCloneFromUrl(opts, cfg)
|
|
2436
|
+
} else {
|
|
2437
|
+
await cmdClone(opts, cfg)
|
|
2438
|
+
}
|
|
2046
2439
|
return
|
|
2047
2440
|
}
|
|
2048
2441
|
if (cmd[0] === 'status') {
|
|
@@ -2069,6 +2462,10 @@ async function main() {
|
|
|
2069
2462
|
await cmdPull(opts)
|
|
2070
2463
|
return
|
|
2071
2464
|
}
|
|
2465
|
+
if (cmd[0] === 'fetch') {
|
|
2466
|
+
await cmdFetch(opts)
|
|
2467
|
+
return
|
|
2468
|
+
}
|
|
2072
2469
|
if (cmd[0] === 'branch') {
|
|
2073
2470
|
await cmdBranch(cmd[1], opts)
|
|
2074
2471
|
return
|
|
@@ -2113,6 +2510,26 @@ async function main() {
|
|
|
2113
2510
|
await cmdMv(opts)
|
|
2114
2511
|
return
|
|
2115
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
|
+
}
|
|
2116
2533
|
if (cmd[0] === 'cherry-pick') {
|
|
2117
2534
|
await cmdCherryPick(opts)
|
|
2118
2535
|
return
|
|
@@ -2281,7 +2698,7 @@ async function tuiSelectBranch(branches, current) {
|
|
|
2281
2698
|
process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
|
|
2282
2699
|
}
|
|
2283
2700
|
const cleanup = (listener) => {
|
|
2284
|
-
try { process.stdin.removeListener('data', listener) } catch {}
|
|
2701
|
+
try { process.stdin.removeListener('data', listener) } catch { }
|
|
2285
2702
|
process.stdin.setRawMode(false)
|
|
2286
2703
|
process.stdin.pause()
|
|
2287
2704
|
process.stdout.write('\x1b[?25h')
|