resulgit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +143 -0
- package/package.json +38 -0
- package/resulgit.js +2309 -0
package/resulgit.js
ADDED
|
@@ -0,0 +1,2309 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const os = require('os')
|
|
5
|
+
const crypto = require('crypto')
|
|
6
|
+
const COLORS = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m' }
|
|
7
|
+
function color(str, c) { return (COLORS[c] || '') + String(str) + COLORS.reset }
|
|
8
|
+
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const tokens = argv.slice(2)
|
|
11
|
+
const cmd = []
|
|
12
|
+
const opts = {}
|
|
13
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
14
|
+
const t = tokens[i]
|
|
15
|
+
if (t.startsWith('--')) {
|
|
16
|
+
const key = t.slice(2)
|
|
17
|
+
const val = tokens[i + 1] && !tokens[i + 1].startsWith('--') ? tokens[++i] : 'true'
|
|
18
|
+
opts[key] = val
|
|
19
|
+
} else if (cmd.length < 2) {
|
|
20
|
+
cmd.push(t)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { cmd, opts }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function configPath() {
|
|
27
|
+
const dir = path.join(os.homedir(), '.resulgit')
|
|
28
|
+
const file = path.join(dir, 'config.json')
|
|
29
|
+
return { dir, file }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadConfig() {
|
|
33
|
+
const { dir, file } = configPath()
|
|
34
|
+
try {
|
|
35
|
+
const s = fs.readFileSync(file, 'utf8')
|
|
36
|
+
return JSON.parse(s)
|
|
37
|
+
} catch {
|
|
38
|
+
return { server: 'https://delightful-dango-ee4842.netlify.app', token: '', workspaceRoot: path.join(os.homedir(), 'resulgit-workspace') }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function saveConfig(update) {
|
|
43
|
+
const { dir, file } = configPath()
|
|
44
|
+
const cur = loadConfig()
|
|
45
|
+
const next = { ...cur, ...update }
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
47
|
+
fs.writeFileSync(file, JSON.stringify(next, null, 2))
|
|
48
|
+
return next
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getServer(opts, cfg) {
|
|
52
|
+
return opts.server || cfg.server || 'https://delightful-dango-ee4842.netlify.app'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getToken(opts, cfg) {
|
|
56
|
+
return opts.token || cfg.token || ''
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function request(method, url, body, token) {
|
|
60
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
61
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
62
|
+
const res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined })
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
const text = await res.text()
|
|
65
|
+
throw new Error(`${res.status} ${res.statusText} ${text}`)
|
|
66
|
+
}
|
|
67
|
+
const ct = res.headers.get('content-type') || ''
|
|
68
|
+
if (ct.includes('application/json')) return res.json()
|
|
69
|
+
return res.text()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function print(obj, json) {
|
|
73
|
+
if (json) {
|
|
74
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + '\n')
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (Array.isArray(obj)) {
|
|
78
|
+
for (const o of obj) process.stdout.write(`${o.id || ''}\t${o.name || ''}\n`)
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (typeof obj === 'string') {
|
|
82
|
+
process.stdout.write(obj + '\n')
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
process.stdout.write(Object.entries(obj).map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`).join('\n') + '\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function cmdAuth(sub, opts) {
|
|
89
|
+
if (sub === 'set-token') {
|
|
90
|
+
const token = opts.token || ''
|
|
91
|
+
if (!token) throw new Error('Missing --token')
|
|
92
|
+
const cfg = saveConfig({ token })
|
|
93
|
+
print({ token: cfg.token }, opts.json === 'true')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (sub === 'set-server') {
|
|
97
|
+
const server = opts.server || ''
|
|
98
|
+
if (!server) throw new Error('Missing --server')
|
|
99
|
+
const cfg = saveConfig({ server })
|
|
100
|
+
print({ server: cfg.server }, opts.json === 'true')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (sub === 'login') {
|
|
104
|
+
const server = opts.server || loadConfig().server
|
|
105
|
+
const email = opts.email
|
|
106
|
+
const password = opts.password
|
|
107
|
+
if (!email || !password) throw new Error('Missing --email and --password')
|
|
108
|
+
const url = new URL('/api/auth/login', server).toString()
|
|
109
|
+
const res = await request('POST', url, { email, password }, '')
|
|
110
|
+
const token = res.token || ''
|
|
111
|
+
if (token) saveConfig({ token })
|
|
112
|
+
print(res, opts.json === 'true')
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (sub === 'register') {
|
|
116
|
+
const server = opts.server || loadConfig().server
|
|
117
|
+
const username = opts.username
|
|
118
|
+
const email = opts.email
|
|
119
|
+
const password = opts.password
|
|
120
|
+
const displayName = opts.displayName || username
|
|
121
|
+
if (!username || !email || !password) throw new Error('Missing --username --email --password')
|
|
122
|
+
const url = new URL('/api/auth/register', server).toString()
|
|
123
|
+
const res = await request('POST', url, { username, email, password, displayName }, '')
|
|
124
|
+
const token = res.token || ''
|
|
125
|
+
if (token) saveConfig({ token })
|
|
126
|
+
print(res, opts.json === 'true')
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
throw new Error('Unknown auth subcommand')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function cmdRepo(sub, opts, cfg) {
|
|
133
|
+
const server = getServer(opts, cfg)
|
|
134
|
+
const token = getToken(opts, cfg)
|
|
135
|
+
if (sub === 'list') {
|
|
136
|
+
const url = new URL('/api/repositories', server).toString()
|
|
137
|
+
const data = await request('GET', url, null, token)
|
|
138
|
+
print(data, opts.json === 'true')
|
|
139
|
+
await tuiSelectRepo(data || [], cfg, opts)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
if (sub === 'create') {
|
|
143
|
+
const body = {
|
|
144
|
+
name: opts.name || '',
|
|
145
|
+
description: opts.description || '',
|
|
146
|
+
visibility: opts.visibility || 'private',
|
|
147
|
+
initializeWithReadme: opts.init === 'true'
|
|
148
|
+
}
|
|
149
|
+
if (!body.name) throw new Error('Missing --name')
|
|
150
|
+
const url = new URL('/api/repositories', server).toString()
|
|
151
|
+
const data = await request('POST', url, body, token)
|
|
152
|
+
print(data, opts.json === 'true')
|
|
153
|
+
try {
|
|
154
|
+
const repoId = String(data.id || '')
|
|
155
|
+
const branch = String(data.defaultBranch || 'main')
|
|
156
|
+
if (repoId) { await cmdClone({ repo: repoId, branch, dest: opts.dest }, cfg) }
|
|
157
|
+
} catch {}
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
if (sub === 'log') {
|
|
161
|
+
const repo = opts.repo
|
|
162
|
+
const branch = opts.branch
|
|
163
|
+
if (!repo) throw new Error('Missing --repo')
|
|
164
|
+
const u = new URL(`/api/repositories/${repo}/commits`, server)
|
|
165
|
+
if (branch) u.searchParams.set('branch', branch)
|
|
166
|
+
const data = await request('GET', u.toString(), null, token)
|
|
167
|
+
print(data, opts.json === 'true')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
if (sub === 'head') {
|
|
171
|
+
const repo = opts.repo
|
|
172
|
+
const branchOpt = opts.branch
|
|
173
|
+
if (!repo) throw new Error('Missing --repo')
|
|
174
|
+
const info = await request('GET', new URL(`/api/repositories/${repo}/branches`, server).toString(), null, token)
|
|
175
|
+
let branch = branchOpt || info.defaultBranch || 'main'
|
|
176
|
+
const found = (info.branches || []).find(b => b.name === branch)
|
|
177
|
+
const commitId = found ? (found.commitId || '') : ''
|
|
178
|
+
const out = { branch, commitId }
|
|
179
|
+
print(opts.json === 'true' ? out : (commitId || ''), opts.json === 'true')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (sub === 'select') {
|
|
183
|
+
const url = new URL('/api/repositories', server).toString()
|
|
184
|
+
const repos = await request('GET', url, null, token)
|
|
185
|
+
await tuiSelectRepo(repos || [], cfg, opts)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
throw new Error('Unknown repo subcommand')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function cmdClone(opts, cfg) {
|
|
192
|
+
const server = getServer(opts, cfg)
|
|
193
|
+
const token = getToken(opts, cfg)
|
|
194
|
+
const repo = opts.repo
|
|
195
|
+
const branch = opts.branch
|
|
196
|
+
let dest = opts.dest
|
|
197
|
+
if (!repo || !branch) throw new Error('Missing --repo and --branch')
|
|
198
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
199
|
+
if (!dest) {
|
|
200
|
+
try {
|
|
201
|
+
const infoUrl = new URL(`/api/repositories/${repo}`, server)
|
|
202
|
+
const infoRes = await fetch(infoUrl.toString(), { headers })
|
|
203
|
+
if (infoRes.ok) {
|
|
204
|
+
const info = await infoRes.json()
|
|
205
|
+
const name = info.name || String(repo)
|
|
206
|
+
dest = name
|
|
207
|
+
} else {
|
|
208
|
+
dest = String(repo)
|
|
209
|
+
}
|
|
210
|
+
} catch {
|
|
211
|
+
dest = String(repo)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
dest = path.resolve(dest)
|
|
215
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
216
|
+
url.searchParams.set('branch', branch)
|
|
217
|
+
const res = await fetch(url.toString(), { headers })
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const text = await res.text()
|
|
220
|
+
throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
|
|
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))
|
|
271
|
+
}
|
|
272
|
+
} catch {}
|
|
273
|
+
try {
|
|
274
|
+
const commitsUrl = new URL(`/api/repositories/${repo}/commits`, server)
|
|
275
|
+
commitsUrl.searchParams.set('branch', branch)
|
|
276
|
+
const commitsRes = await fetch(commitsUrl.toString(), { headers })
|
|
277
|
+
if (commitsRes.ok) {
|
|
278
|
+
const commitsList = await commitsRes.json()
|
|
279
|
+
await fs.promises.writeFile(path.join(gitDir, 'vcs-next-commits.json'), JSON.stringify(commitsList || [], null, 2))
|
|
280
|
+
}
|
|
281
|
+
} catch {}
|
|
282
|
+
print('Clone complete', opts.json === 'true')
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function readRemoteMeta(dir) {
|
|
286
|
+
const abs = path.resolve(dir)
|
|
287
|
+
const tryFiles = (d) => [
|
|
288
|
+
path.join(d, '.vcs-next', 'remote.json'),
|
|
289
|
+
path.join(d, '.git', 'vcs-next.json')
|
|
290
|
+
]
|
|
291
|
+
let cur = abs
|
|
292
|
+
while (true) {
|
|
293
|
+
for (const fp of tryFiles(cur)) {
|
|
294
|
+
try {
|
|
295
|
+
const s = fs.readFileSync(fp, 'utf8')
|
|
296
|
+
const meta = JSON.parse(s)
|
|
297
|
+
if (meta.repoId && meta.branch) return meta
|
|
298
|
+
} catch {}
|
|
299
|
+
}
|
|
300
|
+
const parent = path.dirname(cur)
|
|
301
|
+
if (parent === cur) break
|
|
302
|
+
cur = parent
|
|
303
|
+
}
|
|
304
|
+
const hint = `${abs}`
|
|
305
|
+
const err = new Error(`Not a cloned repository: ${hint}. Use --dir <path> or run inside a cloned repo.`)
|
|
306
|
+
throw err
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function hashContent(buf) {
|
|
310
|
+
return crypto.createHash('sha1').update(buf).digest('hex')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function collectLocal(dir) {
|
|
314
|
+
const out = {}
|
|
315
|
+
const base = path.resolve(dir)
|
|
316
|
+
async function walk(cur, rel) {
|
|
317
|
+
const entries = await fs.promises.readdir(cur, { withFileTypes: true })
|
|
318
|
+
for (const e of entries) {
|
|
319
|
+
if (e.name === '.git' || e.name === '.vcs-next') continue
|
|
320
|
+
const abs = path.join(cur, e.name)
|
|
321
|
+
const rp = rel ? rel + '/' + e.name : e.name
|
|
322
|
+
if (e.isDirectory()) {
|
|
323
|
+
await walk(abs, rp)
|
|
324
|
+
} else if (e.isFile()) {
|
|
325
|
+
const buf = await fs.promises.readFile(abs)
|
|
326
|
+
out[rp] = { content: buf.toString('utf8'), id: hashContent(buf) }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
await walk(base, '')
|
|
331
|
+
return out
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function fetchRemoteFilesMap(server, repo, branch, token) {
|
|
335
|
+
const u = new URL(`/api/repositories/${repo}/files/all`, server)
|
|
336
|
+
u.searchParams.set('branchId', branch)
|
|
337
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
338
|
+
const res = await fetch(u.toString(), { headers })
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
const data = await res.json()
|
|
341
|
+
const map = {}
|
|
342
|
+
for (const it of data.entries || []) map[it.path] = it.id
|
|
343
|
+
return { map, headCommitId: data.commitId }
|
|
344
|
+
}
|
|
345
|
+
const su = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
346
|
+
su.searchParams.set('branch', branch)
|
|
347
|
+
const sres = await fetch(su.toString(), { headers })
|
|
348
|
+
if (sres.ok) {
|
|
349
|
+
const data = await sres.json()
|
|
350
|
+
const files = data.files || {}
|
|
351
|
+
const map = {}
|
|
352
|
+
for (const [p, content] of Object.entries(files)) {
|
|
353
|
+
const id = hashContent(Buffer.from(String(content)))
|
|
354
|
+
map[p] = id
|
|
355
|
+
}
|
|
356
|
+
return { map, headCommitId: data.commitId }
|
|
357
|
+
}
|
|
358
|
+
const rtext = await res.text().catch(() => '')
|
|
359
|
+
const stext = await sres.text().catch(() => '')
|
|
360
|
+
throw new Error(`remote fetch failed: ${res.status} ${res.statusText} ${rtext}; snapshot failed: ${sres.status} ${sres.statusText} ${stext}`)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function cmdStatus(opts) {
|
|
364
|
+
const dir = path.resolve(opts.dir || '.')
|
|
365
|
+
const meta = readRemoteMeta(dir)
|
|
366
|
+
const cfg = loadConfig()
|
|
367
|
+
const server = getServer(opts, cfg) || meta.server
|
|
368
|
+
const token = getToken(opts, cfg) || meta.token
|
|
369
|
+
const local = await collectLocal(dir)
|
|
370
|
+
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
371
|
+
const added = []
|
|
372
|
+
const modified = []
|
|
373
|
+
const deleted = []
|
|
374
|
+
const remotePaths = new Set(Object.keys(remote.map))
|
|
375
|
+
const localPaths = new Set(Object.keys(local))
|
|
376
|
+
for (const p of localPaths) {
|
|
377
|
+
if (!remotePaths.has(p)) added.push(p)
|
|
378
|
+
else if (remote.map[p] !== local[p].id) modified.push(p)
|
|
379
|
+
}
|
|
380
|
+
for (const p of remotePaths) {
|
|
381
|
+
if (!localPaths.has(p)) deleted.push(p)
|
|
382
|
+
}
|
|
383
|
+
const out = { branch: meta.branch, head: remote.headCommitId, added, modified, deleted }
|
|
384
|
+
print(out, opts.json === 'true')
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function cmdRestore(opts) {
|
|
388
|
+
const dir = path.resolve(opts.dir || '.')
|
|
389
|
+
const filePath = opts.path
|
|
390
|
+
if (!filePath) throw new Error('Missing --path')
|
|
391
|
+
const meta = readRemoteMeta(dir)
|
|
392
|
+
const cfg = loadConfig()
|
|
393
|
+
const server = getServer(opts, cfg) || meta.server
|
|
394
|
+
const token = getToken(opts, cfg) || meta.token
|
|
395
|
+
|
|
396
|
+
const sourceCommit = opts.source || 'HEAD'
|
|
397
|
+
let sourceSnap
|
|
398
|
+
if (sourceCommit === 'HEAD') {
|
|
399
|
+
sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
400
|
+
} else {
|
|
401
|
+
sourceSnap = await fetchSnapshotByCommit(server, meta.repoId, sourceCommit, token)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const sourceContent = sourceSnap.files[filePath]
|
|
405
|
+
if (sourceContent === undefined) {
|
|
406
|
+
// File doesn't exist in source - delete it
|
|
407
|
+
const fp = path.join(dir, filePath)
|
|
408
|
+
try {
|
|
409
|
+
await fs.promises.unlink(fp)
|
|
410
|
+
if (opts.json === 'true') {
|
|
411
|
+
print({ restored: filePath, action: 'deleted' }, true)
|
|
412
|
+
} else {
|
|
413
|
+
process.stdout.write(color(`Restored ${filePath} (deleted)\n`, 'green'))
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
if (opts.json !== 'true') process.stdout.write(`File ${filePath} doesn't exist\n`)
|
|
417
|
+
else print({ restored: filePath, action: 'noop' }, true)
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
// Restore file from source
|
|
421
|
+
const fp = path.join(dir, filePath)
|
|
422
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
423
|
+
await fs.promises.writeFile(fp, String(sourceContent), 'utf8')
|
|
424
|
+
if (opts.json === 'true') {
|
|
425
|
+
print({ restored: filePath, from: sourceCommit }, true)
|
|
426
|
+
} else {
|
|
427
|
+
process.stdout.write(color(`Restored ${filePath} from ${sourceCommit}\n`, 'green'))
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function cmdDiff(opts) {
|
|
433
|
+
const dir = path.resolve(opts.dir || '.')
|
|
434
|
+
const meta = readRemoteMeta(dir)
|
|
435
|
+
const cfg = loadConfig()
|
|
436
|
+
const server = getServer(opts, cfg) || meta.server
|
|
437
|
+
const token = getToken(opts, cfg) || meta.token
|
|
438
|
+
|
|
439
|
+
const filePath = opts.path
|
|
440
|
+
const commitId = opts.commit
|
|
441
|
+
|
|
442
|
+
if (commitId) {
|
|
443
|
+
// Show diff for specific commit
|
|
444
|
+
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
445
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
446
|
+
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
447
|
+
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
448
|
+
|
|
449
|
+
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)]))
|
|
450
|
+
for (const p of files) {
|
|
451
|
+
const oldContent = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
452
|
+
const newContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
453
|
+
if (oldContent !== newContent) {
|
|
454
|
+
if (opts.json === 'true') {
|
|
455
|
+
print({ path: p, old: oldContent, new: newContent }, true)
|
|
456
|
+
} else {
|
|
457
|
+
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
|
|
458
|
+
if (oldContent === null) {
|
|
459
|
+
process.stdout.write(color(`+++ b/${p}\n`, 'green'))
|
|
460
|
+
process.stdout.write(color(`+${newContent}\n`, 'green'))
|
|
461
|
+
} else if (newContent === null) {
|
|
462
|
+
process.stdout.write(color(`--- a/${p}\n`, 'red'))
|
|
463
|
+
process.stdout.write(color(`-${oldContent}\n`, 'red'))
|
|
464
|
+
} else {
|
|
465
|
+
const oldLines = oldContent.split(/\r?\n/)
|
|
466
|
+
const newLines = newContent.split(/\r?\n/)
|
|
467
|
+
const maxLen = Math.max(oldLines.length, newLines.length)
|
|
468
|
+
for (let i = 0; i < maxLen; i++) {
|
|
469
|
+
const oldLine = oldLines[i]
|
|
470
|
+
const newLine = newLines[i]
|
|
471
|
+
if (oldLine !== newLine) {
|
|
472
|
+
if (oldLine !== undefined) process.stdout.write(color(`-${oldLine}\n`, 'red'))
|
|
473
|
+
if (newLine !== undefined) process.stdout.write(color(`+${newLine}\n`, 'green'))
|
|
474
|
+
} else if (oldLine !== undefined) {
|
|
475
|
+
process.stdout.write(` ${oldLine}\n`)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Show diff for working directory
|
|
486
|
+
const remoteSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
487
|
+
const local = await collectLocal(dir)
|
|
488
|
+
const files = filePath ? [filePath] : Object.keys(new Set([...Object.keys(remoteSnap.files), ...Object.keys(local)]))
|
|
489
|
+
|
|
490
|
+
for (const p of files) {
|
|
491
|
+
const remoteContent = remoteSnap.files[p] !== undefined ? String(remoteSnap.files[p]) : null
|
|
492
|
+
const localContent = local[p]?.content || null
|
|
493
|
+
const remoteId = remoteSnap.files[p] !== undefined ? hashContent(Buffer.from(String(remoteSnap.files[p]))) : null
|
|
494
|
+
const localId = local[p]?.id
|
|
495
|
+
|
|
496
|
+
if (remoteId !== localId) {
|
|
497
|
+
if (opts.json === 'true') {
|
|
498
|
+
print({ path: p, remote: remoteContent, local: localContent }, true)
|
|
499
|
+
} else {
|
|
500
|
+
process.stdout.write(color(`diff --git a/${p} b/${p}\n`, 'dim'))
|
|
501
|
+
if (localContent === null) {
|
|
502
|
+
process.stdout.write(color(`--- a/${p}\n`, 'red'))
|
|
503
|
+
process.stdout.write(color(`-${remoteContent || ''}\n`, 'red'))
|
|
504
|
+
} else if (remoteContent === null) {
|
|
505
|
+
process.stdout.write(color(`+++ b/${p}\n`, 'green'))
|
|
506
|
+
process.stdout.write(color(`+${localContent}\n`, 'green'))
|
|
507
|
+
} else {
|
|
508
|
+
const remoteLines = String(remoteContent).split(/\r?\n/)
|
|
509
|
+
const localLines = String(localContent).split(/\r?\n/)
|
|
510
|
+
const maxLen = Math.max(remoteLines.length, localLines.length)
|
|
511
|
+
for (let i = 0; i < maxLen; i++) {
|
|
512
|
+
const remoteLine = remoteLines[i]
|
|
513
|
+
const localLine = localLines[i]
|
|
514
|
+
if (remoteLine !== localLine) {
|
|
515
|
+
if (remoteLine !== undefined) process.stdout.write(color(`-${remoteLine}\n`, 'red'))
|
|
516
|
+
if (localLine !== undefined) process.stdout.write(color(`+${localLine}\n`, 'green'))
|
|
517
|
+
} else if (remoteLine !== undefined) {
|
|
518
|
+
process.stdout.write(` ${remoteLine}\n`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function cmdRm(opts) {
|
|
528
|
+
const dir = path.resolve(opts.dir || '.')
|
|
529
|
+
const pathArg = opts.path
|
|
530
|
+
if (!pathArg) throw new Error('Missing --path')
|
|
531
|
+
const meta = readRemoteMeta(dir)
|
|
532
|
+
const cfg = loadConfig()
|
|
533
|
+
const server = getServer(opts, cfg) || meta.server
|
|
534
|
+
const token = getToken(opts, cfg) || meta.token
|
|
535
|
+
const u = new URL(`/api/repositories/${meta.repoId}/files`, server)
|
|
536
|
+
u.searchParams.set('branch', meta.branch)
|
|
537
|
+
u.searchParams.set('path', pathArg)
|
|
538
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
539
|
+
const res = await fetch(u.toString(), { method: 'DELETE', headers })
|
|
540
|
+
if (!res.ok) {
|
|
541
|
+
const t = await res.text()
|
|
542
|
+
throw new Error(t || 'delete failed')
|
|
543
|
+
}
|
|
544
|
+
const data = await res.json()
|
|
545
|
+
print(data, opts.json === 'true')
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function cmdCommit(opts) {
|
|
549
|
+
const dir = path.resolve(opts.dir || '.')
|
|
550
|
+
const message = opts.message || ''
|
|
551
|
+
if (!message) throw new Error('Missing --message')
|
|
552
|
+
|
|
553
|
+
// Check for unresolved conflicts
|
|
554
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
555
|
+
if (unresolvedConflicts.length > 0) {
|
|
556
|
+
if (opts.json === 'true') {
|
|
557
|
+
print({ error: 'Cannot commit with unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
558
|
+
} else {
|
|
559
|
+
process.stderr.write(color('Error: Cannot commit with unresolved conflicts\n', 'red'))
|
|
560
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
561
|
+
for (const p of unresolvedConflicts) {
|
|
562
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
563
|
+
}
|
|
564
|
+
process.stderr.write(color('\nResolve conflicts manually before committing.\n', 'yellow'))
|
|
565
|
+
}
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
570
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
571
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
572
|
+
try {
|
|
573
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
574
|
+
localMeta = JSON.parse(s)
|
|
575
|
+
} catch {}
|
|
576
|
+
const local = await collectLocal(dir)
|
|
577
|
+
const files = {}
|
|
578
|
+
for (const [p, v] of Object.entries(local)) files[p] = v.content
|
|
579
|
+
localMeta.pendingCommit = { message, files, createdAt: Date.now() }
|
|
580
|
+
// Clear conflicts if they were resolved
|
|
581
|
+
if (localMeta.conflicts) {
|
|
582
|
+
delete localMeta.conflicts
|
|
583
|
+
}
|
|
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
|
+
}
|
|
588
|
+
|
|
589
|
+
async function pullToDir(repo, branch, dir, server, token) {
|
|
590
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
591
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
592
|
+
url.searchParams.set('branch', branch)
|
|
593
|
+
const res = await fetch(url.toString(), { headers })
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
const text = await res.text()
|
|
596
|
+
throw new Error(`Snapshot fetch failed: ${res.status} ${res.statusText} - ${text}`)
|
|
597
|
+
}
|
|
598
|
+
const data = await res.json()
|
|
599
|
+
const files = data.files || {}
|
|
600
|
+
const root = path.resolve(dir)
|
|
601
|
+
for (const [p, content] of Object.entries(files)) {
|
|
602
|
+
const fullPath = path.join(root, p)
|
|
603
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
604
|
+
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
605
|
+
}
|
|
606
|
+
const keep = new Set(Object.keys(files))
|
|
607
|
+
const localMap = await collectLocal(root)
|
|
608
|
+
for (const rel of Object.keys(localMap)) {
|
|
609
|
+
if (!keep.has(rel)) {
|
|
610
|
+
const fp = path.join(root, rel)
|
|
611
|
+
try { await fs.promises.unlink(fp) } catch {}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const pruneEmptyDirs = async (start) => {
|
|
615
|
+
const entries = await fs.promises.readdir(start).catch(() => [])
|
|
616
|
+
for (const name of entries) {
|
|
617
|
+
if (name === '.git' || name === '.vcs-next') continue
|
|
618
|
+
const p = path.join(start, name)
|
|
619
|
+
const st = await fs.promises.stat(p).catch(() => null)
|
|
620
|
+
if (!st) continue
|
|
621
|
+
if (st.isDirectory()) {
|
|
622
|
+
await pruneEmptyDirs(p)
|
|
623
|
+
const left = await fs.promises.readdir(p).catch(() => [])
|
|
624
|
+
if (left.length === 0) { try { await fs.promises.rmdir(p) } catch {} }
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
await pruneEmptyDirs(root)
|
|
629
|
+
const metaDir = path.join(root, '.vcs-next')
|
|
630
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
631
|
+
const meta = { repoId: repo, branch, commitId: data.commitId || '', server, token: token || '' }
|
|
632
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(meta, null, 2))
|
|
633
|
+
const gitDir = path.join(root, '.git')
|
|
634
|
+
const refsHeadsDir = path.join(gitDir, 'refs', 'heads')
|
|
635
|
+
await fs.promises.mkdir(refsHeadsDir, { recursive: true })
|
|
636
|
+
const headContent = `ref: refs/heads/${branch}\n`
|
|
637
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), headContent, 'utf8')
|
|
638
|
+
const commitId = data.commitId || ''
|
|
639
|
+
await fs.promises.writeFile(path.join(refsHeadsDir, branch), commitId, 'utf8')
|
|
640
|
+
const localMeta = { baseCommitId: commitId, baseFiles: files, pendingCommit: null }
|
|
641
|
+
await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localMeta, null, 2))
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function cmdPull(opts) {
|
|
645
|
+
const dir = path.resolve(opts.dir || '.')
|
|
646
|
+
const meta = readRemoteMeta(dir)
|
|
647
|
+
const cfg = loadConfig()
|
|
648
|
+
const server = getServer(opts, cfg) || meta.server
|
|
649
|
+
const token = getToken(opts, cfg) || meta.token
|
|
650
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
651
|
+
print('Pull complete', opts.json === 'true')
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function fetchRemoteSnapshot(server, repo, branch, token) {
|
|
655
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
656
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
657
|
+
if (branch) url.searchParams.set('branch', branch)
|
|
658
|
+
const res = await fetch(url.toString(), { headers })
|
|
659
|
+
if (!res.ok) {
|
|
660
|
+
const text = await res.text()
|
|
661
|
+
throw new Error(text || 'snapshot failed')
|
|
662
|
+
}
|
|
663
|
+
const data = await res.json()
|
|
664
|
+
return { files: data.files || {}, commitId: data.commitId || '' }
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function fetchSnapshotByCommit(server, repo, commitId, token) {
|
|
668
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
669
|
+
const url = new URL(`/api/repositories/${repo}/snapshot`, server)
|
|
670
|
+
url.searchParams.set('commitId', commitId)
|
|
671
|
+
const res = await fetch(url.toString(), { headers })
|
|
672
|
+
if (!res.ok) {
|
|
673
|
+
const text = await res.text()
|
|
674
|
+
throw new Error(text || 'snapshot failed')
|
|
675
|
+
}
|
|
676
|
+
const data = await res.json()
|
|
677
|
+
return { files: data.files || {}, commitId: data.commitId || '' }
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function fetchCommitMeta(server, repo, commitId, token) {
|
|
681
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
682
|
+
const url = new URL(`/api/repositories/${repo}/commits/${commitId}`, server)
|
|
683
|
+
const res = await fetch(url.toString(), { headers })
|
|
684
|
+
if (!res.ok) {
|
|
685
|
+
const text = await res.text()
|
|
686
|
+
throw new Error(text || 'commit meta failed')
|
|
687
|
+
}
|
|
688
|
+
const data = await res.json()
|
|
689
|
+
return data
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function applyCherry(base, next, localContent) {
|
|
693
|
+
const b = base === undefined ? null : String(base)
|
|
694
|
+
const n = next === undefined ? null : String(next)
|
|
695
|
+
const l = localContent === undefined ? null : String(localContent)
|
|
696
|
+
const changedLocal = l !== b
|
|
697
|
+
const changedRemote = n !== b
|
|
698
|
+
if (changedLocal && changedRemote && l !== n) {
|
|
699
|
+
return { conflict: true }
|
|
700
|
+
}
|
|
701
|
+
if (changedLocal && !changedRemote) return { content: l }
|
|
702
|
+
if (!changedLocal && changedRemote) return { content: n }
|
|
703
|
+
return { content: b }
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function cmdCherryPick(opts) {
|
|
707
|
+
const dir = path.resolve(opts.dir || '.')
|
|
708
|
+
const meta = readRemoteMeta(dir)
|
|
709
|
+
const cfg = loadConfig()
|
|
710
|
+
const server = getServer(opts, cfg) || meta.server
|
|
711
|
+
const token = getToken(opts, cfg) || meta.token
|
|
712
|
+
const commitId = opts.commit
|
|
713
|
+
if (!commitId) throw new Error('Missing --commit')
|
|
714
|
+
const branchesInfo = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
715
|
+
let targetBranch = opts.branch || ''
|
|
716
|
+
if (!targetBranch) {
|
|
717
|
+
let current = meta.branch
|
|
718
|
+
try {
|
|
719
|
+
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
720
|
+
const m = head.match(/refs\/heads\/(.+)/)
|
|
721
|
+
if (m) current = m[1]
|
|
722
|
+
} catch {}
|
|
723
|
+
targetBranch = await tuiSelectBranch(branchesInfo.branches || [], current)
|
|
724
|
+
}
|
|
725
|
+
const exists = (branchesInfo.branches || []).some((b) => b.name === targetBranch)
|
|
726
|
+
if (!exists) throw new Error(`Invalid branch: ${targetBranch}`)
|
|
727
|
+
|
|
728
|
+
// Get the current state of the target branch (what we're cherry-picking onto)
|
|
729
|
+
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, targetBranch, token)
|
|
730
|
+
|
|
731
|
+
// Get the commit being cherry-picked and its parent
|
|
732
|
+
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
733
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
734
|
+
const targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
735
|
+
const baseSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
736
|
+
|
|
737
|
+
// Pull the target branch to ensure we're working with the latest state
|
|
738
|
+
await pullToDir(meta.repoId, targetBranch, dir, server, token)
|
|
739
|
+
|
|
740
|
+
// Re-collect local state after pull (now it matches the target branch)
|
|
741
|
+
const localAfterPull = await collectLocal(dir)
|
|
742
|
+
|
|
743
|
+
// Update local metadata to reflect the pulled state
|
|
744
|
+
const metaDirInit = path.join(dir, '.vcs-next')
|
|
745
|
+
await fs.promises.mkdir(metaDirInit, { recursive: true })
|
|
746
|
+
const localPathInit = path.join(metaDirInit, 'local.json')
|
|
747
|
+
let localMetaInit = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
748
|
+
try { const s = await fs.promises.readFile(localPathInit, 'utf8'); localMetaInit = JSON.parse(s) } catch {}
|
|
749
|
+
localMetaInit.baseCommitId = currentSnap.commitId
|
|
750
|
+
localMetaInit.baseFiles = currentSnap.files
|
|
751
|
+
await fs.promises.writeFile(localPathInit, JSON.stringify(localMetaInit, null, 2))
|
|
752
|
+
|
|
753
|
+
// Apply cherry-pick: compare base (parent of commit) vs target (the commit) vs current (target branch)
|
|
754
|
+
const conflicts = []
|
|
755
|
+
const changes = []
|
|
756
|
+
const mergedFiles = {}
|
|
757
|
+
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(targetSnap.files), ...Object.keys(currentSnap.files)])
|
|
758
|
+
|
|
759
|
+
for (const p of allPaths) {
|
|
760
|
+
const b = baseSnap.files[p] // base: parent of commit being cherry-picked
|
|
761
|
+
const n = targetSnap.files[p] // next: the commit being cherry-picked
|
|
762
|
+
const c = currentSnap.files[p] // current: current state of target branch (from remote)
|
|
763
|
+
|
|
764
|
+
// For cherry-pick, we need to apply the changes from the commit onto the current branch
|
|
765
|
+
// The logic: if the commit changed something from its parent, apply that change to current
|
|
766
|
+
const baseContent = b !== undefined ? String(b) : null
|
|
767
|
+
const commitContent = n !== undefined ? String(n) : null
|
|
768
|
+
const currentContent = c !== undefined ? String(c) : null
|
|
769
|
+
|
|
770
|
+
// Check if commit changed this file from its parent
|
|
771
|
+
const commitChanged = baseContent !== commitContent
|
|
772
|
+
|
|
773
|
+
if (commitChanged) {
|
|
774
|
+
// The commit modified this file - we want to apply that change
|
|
775
|
+
// Conflict detection:
|
|
776
|
+
// - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (just add it)
|
|
777
|
+
// - If file was modified in commit (base=content1, commit=content2) and current=content1: NO CONFLICT (apply change)
|
|
778
|
+
// - If file was modified in commit (base=content1, commit=content2) and current=content3 (different): CONFLICT
|
|
779
|
+
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
780
|
+
// - If file was deleted in commit (base=content, commit=null) and current=content2 (different): CONFLICT
|
|
781
|
+
|
|
782
|
+
const fileWasAdded = baseContent === null && commitContent !== null
|
|
783
|
+
const fileWasDeleted = baseContent !== null && commitContent === null
|
|
784
|
+
const fileWasModified = baseContent !== null && commitContent !== null && baseContent !== commitContent
|
|
785
|
+
|
|
786
|
+
// Check for conflicts: current branch changed the file differently than the commit
|
|
787
|
+
const currentChangedFromBase = currentContent !== baseContent
|
|
788
|
+
const currentDiffersFromCommit = currentContent !== commitContent
|
|
789
|
+
|
|
790
|
+
// Conflict detection:
|
|
791
|
+
// - If file was added in commit (base=null, commit=content) and current=null: NO CONFLICT (safe to add)
|
|
792
|
+
// - If file was added in commit (base=null, commit=content) and current=content2: CONFLICT (both added differently)
|
|
793
|
+
// - If file was modified in commit (base=content1, commit=content2) and current=content1: NO CONFLICT (apply change)
|
|
794
|
+
// - If file was modified in commit (base=content1, commit=content2) and current=content3: CONFLICT (both modified differently)
|
|
795
|
+
// - If file was deleted in commit (base=content, commit=null) and current=content: NO CONFLICT (delete it)
|
|
796
|
+
// - If file was deleted in commit (base=content, commit=null) and current=content2: CONFLICT (current modified it)
|
|
797
|
+
|
|
798
|
+
// Conflict if:
|
|
799
|
+
// 1. Current branch changed the file from base (current !== base)
|
|
800
|
+
// 2. AND current differs from what commit wants (current !== commit)
|
|
801
|
+
// 3. AND it's not the case where file was added in commit AND doesn't exist in current (safe case)
|
|
802
|
+
//
|
|
803
|
+
// Special case: If file was added in commit (base=null, commit=content) and doesn't exist in current (current=null),
|
|
804
|
+
// this is always safe - no conflict. The file is simply being added.
|
|
805
|
+
const safeAddCase = fileWasAdded && currentContent === null
|
|
806
|
+
const fileExistsInCurrent = c !== undefined // Check if file actually exists in current branch
|
|
807
|
+
|
|
808
|
+
// Only conflict if file exists in current AND was changed differently
|
|
809
|
+
if (fileExistsInCurrent && currentChangedFromBase && currentDiffersFromCommit && !safeAddCase) {
|
|
810
|
+
// Conflict: both changed the file differently
|
|
811
|
+
const line = firstDiffLine(currentContent || '', commitContent || '')
|
|
812
|
+
conflicts.push({ path: p, line, current: currentContent, incoming: commitContent })
|
|
813
|
+
// Don't set mergedFiles - will write conflict markers
|
|
814
|
+
} else {
|
|
815
|
+
// No conflict: apply the commit's version
|
|
816
|
+
if (commitContent !== null) {
|
|
817
|
+
mergedFiles[p] = commitContent
|
|
818
|
+
// Apply change if file is new or content differs
|
|
819
|
+
if (currentContent === null || currentContent !== commitContent) {
|
|
820
|
+
changes.push({ type: 'write', path: p, content: commitContent })
|
|
821
|
+
}
|
|
822
|
+
} else {
|
|
823
|
+
// File was deleted in the commit
|
|
824
|
+
if (currentContent !== null) {
|
|
825
|
+
changes.push({ type: 'delete', path: p })
|
|
826
|
+
// Don't add to mergedFiles (file is deleted)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
} else {
|
|
831
|
+
// Commit didn't change this file - keep current state
|
|
832
|
+
if (currentContent !== null) {
|
|
833
|
+
mergedFiles[p] = currentContent
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (conflicts.length > 0) {
|
|
839
|
+
// Write conflict markers to files
|
|
840
|
+
for (const conflict of conflicts) {
|
|
841
|
+
const fp = path.join(dir, conflict.path)
|
|
842
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
843
|
+
const conflictContent = writeConflictMarkers(
|
|
844
|
+
conflict.current,
|
|
845
|
+
conflict.incoming,
|
|
846
|
+
meta.branch,
|
|
847
|
+
`cherry-pick-${commitId.slice(0,7)}`
|
|
848
|
+
)
|
|
849
|
+
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Store conflict state in metadata
|
|
853
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
854
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
855
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
856
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
|
|
857
|
+
try {
|
|
858
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
859
|
+
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
860
|
+
} catch {}
|
|
861
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
862
|
+
|
|
863
|
+
if (opts.json === 'true') {
|
|
864
|
+
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: commit.message || '' }, true)
|
|
865
|
+
} else {
|
|
866
|
+
process.stdout.write(color(`\nCherry-pick conflict detected!\n`, 'red'))
|
|
867
|
+
process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
|
|
868
|
+
for (const c of conflicts) {
|
|
869
|
+
process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
|
|
870
|
+
}
|
|
871
|
+
process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
|
|
872
|
+
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
873
|
+
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
874
|
+
process.stdout.write(color(` =======\n`, 'dim'))
|
|
875
|
+
process.stdout.write(color(` >>>>>>> cherry-pick-${commitId.slice(0,7)}\n`, 'dim'))
|
|
876
|
+
}
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Apply the changes to the filesystem
|
|
881
|
+
for (const ch of changes) {
|
|
882
|
+
const fp = path.join(dir, ch.path)
|
|
883
|
+
if (ch.type === 'delete') {
|
|
884
|
+
try { await fs.promises.unlink(fp) } catch {}
|
|
885
|
+
} else if (ch.type === 'write') {
|
|
886
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
887
|
+
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Update local metadata with the pending commit
|
|
892
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
893
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
894
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
895
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
|
|
896
|
+
try {
|
|
897
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
898
|
+
localMeta = JSON.parse(s)
|
|
899
|
+
} catch {}
|
|
900
|
+
|
|
901
|
+
const cherryMsg = opts.message || `cherry-pick ${commitId.slice(0,7)}: ${commit.message || ''}`
|
|
902
|
+
|
|
903
|
+
// Collect final state after applying changes
|
|
904
|
+
const finalLocal = await collectLocal(dir)
|
|
905
|
+
const finalFiles = {}
|
|
906
|
+
for (const [p, v] of Object.entries(finalLocal)) {
|
|
907
|
+
finalFiles[p] = v.content
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Set pending commit with the actual files
|
|
911
|
+
localMeta.pendingCommit = { message: cherryMsg, files: finalFiles, createdAt: Date.now() }
|
|
912
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
913
|
+
|
|
914
|
+
// Ensure remote.json has the correct target branch before pushing
|
|
915
|
+
const remoteMetaPath = path.join(metaDir, 'remote.json')
|
|
916
|
+
let remoteMeta = { repoId: meta.repoId, branch: targetBranch, commitId: currentSnap.commitId, server, token: token || '' }
|
|
917
|
+
try {
|
|
918
|
+
const s = await fs.promises.readFile(remoteMetaPath, 'utf8')
|
|
919
|
+
remoteMeta = { ...JSON.parse(s), branch: targetBranch }
|
|
920
|
+
} catch {}
|
|
921
|
+
await fs.promises.writeFile(remoteMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
922
|
+
|
|
923
|
+
// Also update .git metadata
|
|
924
|
+
const gitDir = path.join(dir, '.git')
|
|
925
|
+
const gitMetaPath = path.join(gitDir, 'vcs-next.json')
|
|
926
|
+
await fs.promises.mkdir(gitDir, { recursive: true })
|
|
927
|
+
await fs.promises.writeFile(gitMetaPath, JSON.stringify(remoteMeta, null, 2))
|
|
928
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), `ref: refs/heads/${targetBranch}\n`, 'utf8')
|
|
929
|
+
|
|
930
|
+
if (opts.json === 'true') {
|
|
931
|
+
print({ commit: commitId, branch: targetBranch, status: 'applied', changes: changes.length }, true)
|
|
932
|
+
} else {
|
|
933
|
+
process.stdout.write(color(`Cherry-pick ${commitId.slice(0,7)} → ${targetBranch}: applied (${changes.length} changes)\n`, 'green'))
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (opts.noPush !== 'true') {
|
|
937
|
+
await cmdPush({ dir, json: opts.json, message: cherryMsg })
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function firstDiffLine(a, b) {
|
|
942
|
+
const la = String(a).split(/\r?\n/)
|
|
943
|
+
const lb = String(b).split(/\r?\n/)
|
|
944
|
+
const n = Math.max(la.length, lb.length)
|
|
945
|
+
for (let i = 0; i < n; i++) {
|
|
946
|
+
const va = la[i] || ''
|
|
947
|
+
const vb = lb[i] || ''
|
|
948
|
+
if (va !== vb) return i + 1
|
|
949
|
+
}
|
|
950
|
+
return 0
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function writeConflictMarkers(currentContent, incomingContent, currentLabel, incomingLabel) {
|
|
954
|
+
const current = String(currentContent || '')
|
|
955
|
+
const incoming = String(incomingContent || '')
|
|
956
|
+
const currentLines = current.split(/\r?\n/)
|
|
957
|
+
const incomingLines = incoming.split(/\r?\n/)
|
|
958
|
+
|
|
959
|
+
// Simple conflict marker format
|
|
960
|
+
const markers = [
|
|
961
|
+
`<<<<<<< ${currentLabel || 'HEAD'}`,
|
|
962
|
+
...currentLines,
|
|
963
|
+
'=======',
|
|
964
|
+
...incomingLines,
|
|
965
|
+
`>>>>>>> ${incomingLabel || 'incoming'}`
|
|
966
|
+
]
|
|
967
|
+
|
|
968
|
+
return markers.join('\n')
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
function hasConflictMarkers(content) {
|
|
972
|
+
const text = String(content)
|
|
973
|
+
return text.includes('<<<<<<<') && text.includes('=======') && text.includes('>>>>>>>')
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function checkForUnresolvedConflicts(dir) {
|
|
977
|
+
const local = await collectLocal(dir)
|
|
978
|
+
const conflicts = []
|
|
979
|
+
for (const [p, v] of Object.entries(local)) {
|
|
980
|
+
if (hasConflictMarkers(v.content)) {
|
|
981
|
+
conflicts.push(p)
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return conflicts
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
async function cmdPush(opts) {
|
|
988
|
+
const dir = path.resolve(opts.dir || '.')
|
|
989
|
+
const remoteMeta = readRemoteMeta(dir)
|
|
990
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
991
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
992
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
993
|
+
try {
|
|
994
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
995
|
+
localMeta = JSON.parse(s)
|
|
996
|
+
} catch {}
|
|
997
|
+
|
|
998
|
+
// Check for unresolved conflicts in files
|
|
999
|
+
const unresolvedConflicts = await checkForUnresolvedConflicts(dir)
|
|
1000
|
+
if (unresolvedConflicts.length > 0) {
|
|
1001
|
+
if (opts.json === 'true') {
|
|
1002
|
+
print({ error: 'Unresolved conflicts', conflicts: unresolvedConflicts }, true)
|
|
1003
|
+
} else {
|
|
1004
|
+
process.stderr.write(color('Error: Cannot push with unresolved conflicts\n', 'red'))
|
|
1005
|
+
process.stderr.write(color('Conflicts in files:\n', 'yellow'))
|
|
1006
|
+
for (const p of unresolvedConflicts) {
|
|
1007
|
+
process.stderr.write(color(` ${p}\n`, 'red'))
|
|
1008
|
+
}
|
|
1009
|
+
process.stderr.write(color('\nResolve conflicts manually, then try pushing again.\n', 'yellow'))
|
|
1010
|
+
}
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const cfg = loadConfig()
|
|
1015
|
+
const server = getServer(opts, cfg) || remoteMeta.server
|
|
1016
|
+
const token = getToken(opts, cfg) || remoteMeta.token
|
|
1017
|
+
const remote = await fetchRemoteSnapshot(server, remoteMeta.repoId, remoteMeta.branch, token)
|
|
1018
|
+
const base = localMeta.baseFiles || {}
|
|
1019
|
+
const local = await collectLocal(dir)
|
|
1020
|
+
const conflicts = []
|
|
1021
|
+
const merged = {}
|
|
1022
|
+
const paths = new Set([...Object.keys(base), ...Object.keys(remote.files), ...Object.keys(local).map(k => k)])
|
|
1023
|
+
for (const p of paths) {
|
|
1024
|
+
const b = p in base ? base[p] : null
|
|
1025
|
+
const r = p in remote.files ? remote.files[p] : null
|
|
1026
|
+
const l = p in local ? local[p].content : null
|
|
1027
|
+
const changedLocal = String(l) !== String(b)
|
|
1028
|
+
const changedRemote = String(r) !== String(b)
|
|
1029
|
+
if (changedLocal && changedRemote && String(l) !== String(r)) {
|
|
1030
|
+
const line = firstDiffLine(l || '', r || '')
|
|
1031
|
+
conflicts.push({ path: p, line })
|
|
1032
|
+
} else if (changedLocal && !changedRemote) {
|
|
1033
|
+
if (l !== null) merged[p] = l
|
|
1034
|
+
} else if (!changedLocal && changedRemote) {
|
|
1035
|
+
if (r !== null) merged[p] = r
|
|
1036
|
+
} else {
|
|
1037
|
+
if (b !== null) merged[p] = b
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
if (conflicts.length > 0) {
|
|
1041
|
+
if (opts.json === 'true') {
|
|
1042
|
+
print({ conflicts }, true)
|
|
1043
|
+
} else {
|
|
1044
|
+
process.stderr.write(color('Error: Cannot push with conflicts\n', 'red'))
|
|
1045
|
+
process.stderr.write(color('Conflicts detected:\n', 'yellow'))
|
|
1046
|
+
for (const c of conflicts) {
|
|
1047
|
+
process.stderr.write(color(` ${c.path}:${c.line}\n`, 'red'))
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
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
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function cmdMerge(opts) {
|
|
1063
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1064
|
+
const meta = readRemoteMeta(dir)
|
|
1065
|
+
const cfg = loadConfig()
|
|
1066
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1067
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1068
|
+
const sourceBranch = opts.branch || ''
|
|
1069
|
+
if (!sourceBranch) throw new Error('Missing --branch')
|
|
1070
|
+
|
|
1071
|
+
// Get current branch state
|
|
1072
|
+
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1073
|
+
|
|
1074
|
+
// Get source branch state
|
|
1075
|
+
const sourceSnap = await fetchRemoteSnapshot(server, meta.repoId, sourceBranch, token)
|
|
1076
|
+
|
|
1077
|
+
// Find common ancestor (merge base)
|
|
1078
|
+
// For simplicity, we'll use the current branch's base commit as the merge base
|
|
1079
|
+
// In a full implementation, we'd find the actual common ancestor
|
|
1080
|
+
const branchesInfo = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
1081
|
+
const currentBranchInfo = (branchesInfo.branches || []).find(b => b.name === meta.branch)
|
|
1082
|
+
const sourceBranchInfo = (branchesInfo.branches || []).find(b => b.name === sourceBranch)
|
|
1083
|
+
if (!currentBranchInfo || !sourceBranchInfo) throw new Error('Branch not found')
|
|
1084
|
+
|
|
1085
|
+
// Get base commit (for now, use current branch's commit as base)
|
|
1086
|
+
// In real Git, we'd find the merge base commit
|
|
1087
|
+
const baseSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1088
|
+
|
|
1089
|
+
// Pull current branch to ensure we're up to date
|
|
1090
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1091
|
+
const localAfterPull = await collectLocal(dir)
|
|
1092
|
+
|
|
1093
|
+
// Three-way merge: base, current (target), source
|
|
1094
|
+
const conflicts = []
|
|
1095
|
+
const changes = []
|
|
1096
|
+
const mergedFiles = {}
|
|
1097
|
+
const allPaths = new Set([...Object.keys(baseSnap.files), ...Object.keys(currentSnap.files), ...Object.keys(sourceSnap.files)])
|
|
1098
|
+
|
|
1099
|
+
for (const p of allPaths) {
|
|
1100
|
+
const base = baseSnap.files[p] !== undefined ? String(baseSnap.files[p]) : null
|
|
1101
|
+
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1102
|
+
const source = sourceSnap.files[p] !== undefined ? String(sourceSnap.files[p]) : null
|
|
1103
|
+
|
|
1104
|
+
const currentChanged = current !== base
|
|
1105
|
+
const sourceChanged = source !== base
|
|
1106
|
+
|
|
1107
|
+
if (currentChanged && sourceChanged && current !== source) {
|
|
1108
|
+
// Conflict: both branches changed the file differently
|
|
1109
|
+
const line = firstDiffLine(current || '', source || '')
|
|
1110
|
+
conflicts.push({ path: p, line, current, incoming: source })
|
|
1111
|
+
// Don't set mergedFiles - will write conflict markers
|
|
1112
|
+
} else if (sourceChanged && !currentChanged) {
|
|
1113
|
+
// Source changed, current didn't - take source
|
|
1114
|
+
if (source !== null) {
|
|
1115
|
+
mergedFiles[p] = source
|
|
1116
|
+
const localContent = localAfterPull[p]?.content || null
|
|
1117
|
+
if (localContent !== source) {
|
|
1118
|
+
changes.push({ type: 'write', path: p, content: source })
|
|
1119
|
+
}
|
|
1120
|
+
} else {
|
|
1121
|
+
// Source deleted the file
|
|
1122
|
+
if (current !== null) {
|
|
1123
|
+
changes.push({ type: 'delete', path: p })
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
} else if (currentChanged && !sourceChanged) {
|
|
1127
|
+
// Current changed, source didn't - keep current
|
|
1128
|
+
if (current !== null) {
|
|
1129
|
+
mergedFiles[p] = current
|
|
1130
|
+
}
|
|
1131
|
+
} else {
|
|
1132
|
+
// Neither changed or both same - keep current/base
|
|
1133
|
+
if (current !== null) {
|
|
1134
|
+
mergedFiles[p] = current
|
|
1135
|
+
} else if (base !== null) {
|
|
1136
|
+
mergedFiles[p] = base
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (conflicts.length > 0) {
|
|
1142
|
+
// Write conflict markers to files
|
|
1143
|
+
for (const conflict of conflicts) {
|
|
1144
|
+
const fp = path.join(dir, conflict.path)
|
|
1145
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1146
|
+
const conflictContent = writeConflictMarkers(
|
|
1147
|
+
conflict.current,
|
|
1148
|
+
conflict.incoming,
|
|
1149
|
+
meta.branch,
|
|
1150
|
+
sourceBranch
|
|
1151
|
+
)
|
|
1152
|
+
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// Store conflict state in metadata
|
|
1156
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1157
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1158
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1159
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
|
|
1160
|
+
try {
|
|
1161
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1162
|
+
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1163
|
+
} catch {}
|
|
1164
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1165
|
+
|
|
1166
|
+
const message = opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`
|
|
1167
|
+
if (opts.json === 'true') {
|
|
1168
|
+
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message }, true)
|
|
1169
|
+
} else {
|
|
1170
|
+
process.stdout.write(color(`\nMerge conflict detected!\n`, 'red'))
|
|
1171
|
+
process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
|
|
1172
|
+
for (const c of conflicts) {
|
|
1173
|
+
process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
|
|
1174
|
+
}
|
|
1175
|
+
process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
|
|
1176
|
+
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
1177
|
+
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
1178
|
+
process.stdout.write(color(` =======\n`, 'dim'))
|
|
1179
|
+
process.stdout.write(color(` >>>>>>> ${sourceBranch}\n`, 'dim'))
|
|
1180
|
+
}
|
|
1181
|
+
return
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// Apply changes to filesystem
|
|
1185
|
+
for (const ch of changes) {
|
|
1186
|
+
const fp = path.join(dir, ch.path)
|
|
1187
|
+
if (ch.type === 'delete') {
|
|
1188
|
+
try { await fs.promises.unlink(fp) } catch {}
|
|
1189
|
+
} else if (ch.type === 'write') {
|
|
1190
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1191
|
+
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Update local metadata
|
|
1196
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1197
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1198
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1199
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
|
|
1200
|
+
try {
|
|
1201
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1202
|
+
localMeta = JSON.parse(s)
|
|
1203
|
+
} catch {}
|
|
1204
|
+
|
|
1205
|
+
const mergeMsg = opts.message || (opts.squash ? `Merge ${sourceBranch} into ${meta.branch} (squash)` : `Merge ${sourceBranch} into ${meta.branch}`)
|
|
1206
|
+
|
|
1207
|
+
// Collect final state
|
|
1208
|
+
const finalLocal = await collectLocal(dir)
|
|
1209
|
+
const finalFiles = {}
|
|
1210
|
+
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1211
|
+
finalFiles[p] = v.content
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Set pending commit
|
|
1215
|
+
localMeta.pendingCommit = { message: mergeMsg, files: finalFiles, createdAt: Date.now() }
|
|
1216
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1217
|
+
|
|
1218
|
+
if (opts.json === 'true') {
|
|
1219
|
+
print({ merged: sourceBranch, into: meta.branch, status: 'applied', changes: changes.length }, true)
|
|
1220
|
+
} else {
|
|
1221
|
+
process.stdout.write(color(`Merge ${sourceBranch} → ${meta.branch}: applied (${changes.length} changes)\n`, 'green'))
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (opts.noPush !== 'true') {
|
|
1225
|
+
await cmdPush({ dir, json: opts.json, message: mergeMsg })
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function cmdStash(sub, opts) {
|
|
1230
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1231
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1232
|
+
const stashDir = path.join(metaDir, 'stash')
|
|
1233
|
+
await fs.promises.mkdir(stashDir, { recursive: true })
|
|
1234
|
+
|
|
1235
|
+
if (sub === 'list' || sub === undefined) {
|
|
1236
|
+
try {
|
|
1237
|
+
const files = await fs.promises.readdir(stashDir)
|
|
1238
|
+
const stashes = []
|
|
1239
|
+
for (const f of files) {
|
|
1240
|
+
if (f.endsWith('.json')) {
|
|
1241
|
+
const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
|
|
1242
|
+
const stash = JSON.parse(content)
|
|
1243
|
+
stashes.push({ id: f.replace('.json', ''), message: stash.message || '', createdAt: stash.createdAt || 0 })
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1247
|
+
if (opts.json === 'true') {
|
|
1248
|
+
print(stashes, true)
|
|
1249
|
+
} else {
|
|
1250
|
+
if (stashes.length === 0) {
|
|
1251
|
+
process.stdout.write('No stashes found.\n')
|
|
1252
|
+
} else {
|
|
1253
|
+
process.stdout.write(color('Stashes:\n', 'bold'))
|
|
1254
|
+
for (let i = 0; i < stashes.length; i++) {
|
|
1255
|
+
const s = stashes[i]
|
|
1256
|
+
const date = new Date(s.createdAt).toLocaleString()
|
|
1257
|
+
process.stdout.write(`stash@{${i}}: ${s.message || '(no message)'} (${date})\n`)
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
} catch {
|
|
1262
|
+
if (opts.json !== 'true') process.stdout.write('No stashes found.\n')
|
|
1263
|
+
else print([], true)
|
|
1264
|
+
}
|
|
1265
|
+
return
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
if (sub === 'save' || (sub === undefined && !opts.list)) {
|
|
1269
|
+
const message = opts.message || 'WIP'
|
|
1270
|
+
const local = await collectLocal(dir)
|
|
1271
|
+
const files = {}
|
|
1272
|
+
for (const [p, v] of Object.entries(local)) {
|
|
1273
|
+
files[p] = v.content
|
|
1274
|
+
}
|
|
1275
|
+
const stashId = Date.now().toString()
|
|
1276
|
+
const stash = { message, files, createdAt: Date.now() }
|
|
1277
|
+
await fs.promises.writeFile(path.join(stashDir, `${stashId}.json`), JSON.stringify(stash, null, 2))
|
|
1278
|
+
|
|
1279
|
+
// Restore to base state (discard local changes)
|
|
1280
|
+
const meta = readRemoteMeta(dir)
|
|
1281
|
+
const cfg = loadConfig()
|
|
1282
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1283
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1284
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1285
|
+
|
|
1286
|
+
if (opts.json === 'true') {
|
|
1287
|
+
print({ stashId, message }, true)
|
|
1288
|
+
} else {
|
|
1289
|
+
process.stdout.write(color(`Saved stash: ${message}\n`, 'green'))
|
|
1290
|
+
}
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (sub === 'pop' || sub === 'apply') {
|
|
1295
|
+
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1296
|
+
try {
|
|
1297
|
+
const files = await fs.promises.readdir(stashDir)
|
|
1298
|
+
const stashes = []
|
|
1299
|
+
for (const f of files) {
|
|
1300
|
+
if (f.endsWith('.json')) {
|
|
1301
|
+
const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
|
|
1302
|
+
const stash = JSON.parse(content)
|
|
1303
|
+
stashes.push({ id: f.replace('.json', ''), ...stash })
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1307
|
+
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1308
|
+
const stash = stashes[stashIndex]
|
|
1309
|
+
|
|
1310
|
+
// Apply stash files
|
|
1311
|
+
for (const [p, content] of Object.entries(stash.files || {})) {
|
|
1312
|
+
const fp = path.join(dir, p)
|
|
1313
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1314
|
+
await fs.promises.writeFile(fp, content, 'utf8')
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (sub === 'pop') {
|
|
1318
|
+
// Remove stash
|
|
1319
|
+
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
if (opts.json === 'true') {
|
|
1323
|
+
print({ applied: stash.id, message: stash.message }, true)
|
|
1324
|
+
} else {
|
|
1325
|
+
process.stdout.write(color(`${sub === 'pop' ? 'Popped' : 'Applied'} stash: ${stash.message || stash.id}\n`, 'green'))
|
|
1326
|
+
}
|
|
1327
|
+
} catch (err) {
|
|
1328
|
+
throw new Error(`Stash operation failed: ${err.message}`)
|
|
1329
|
+
}
|
|
1330
|
+
return
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
if (sub === 'drop') {
|
|
1334
|
+
const stashIndex = opts.index !== undefined ? parseInt(opts.index) : 0
|
|
1335
|
+
try {
|
|
1336
|
+
const files = await fs.promises.readdir(stashDir)
|
|
1337
|
+
const stashes = []
|
|
1338
|
+
for (const f of files) {
|
|
1339
|
+
if (f.endsWith('.json')) {
|
|
1340
|
+
const content = await fs.promises.readFile(path.join(stashDir, f), 'utf8')
|
|
1341
|
+
const stash = JSON.parse(content)
|
|
1342
|
+
stashes.push({ id: f.replace('.json', ''), ...stash })
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
stashes.sort((a, b) => b.createdAt - a.createdAt)
|
|
1346
|
+
if (stashIndex >= stashes.length) throw new Error(`Stash index ${stashIndex} not found`)
|
|
1347
|
+
const stash = stashes[stashIndex]
|
|
1348
|
+
await fs.promises.unlink(path.join(stashDir, `${stash.id}.json`))
|
|
1349
|
+
|
|
1350
|
+
if (opts.json === 'true') {
|
|
1351
|
+
print({ dropped: stash.id }, true)
|
|
1352
|
+
} else {
|
|
1353
|
+
process.stdout.write(color(`Dropped stash: ${stash.message || stash.id}\n`, 'green'))
|
|
1354
|
+
}
|
|
1355
|
+
} catch (err) {
|
|
1356
|
+
throw new Error(`Drop stash failed: ${err.message}`)
|
|
1357
|
+
}
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (sub === 'clear') {
|
|
1362
|
+
try {
|
|
1363
|
+
const files = await fs.promises.readdir(stashDir)
|
|
1364
|
+
for (const f of files) {
|
|
1365
|
+
if (f.endsWith('.json')) {
|
|
1366
|
+
await fs.promises.unlink(path.join(stashDir, f))
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
if (opts.json !== 'true') process.stdout.write(color('Cleared all stashes\n', 'green'))
|
|
1370
|
+
else print({ cleared: true }, true)
|
|
1371
|
+
} catch {
|
|
1372
|
+
if (opts.json !== 'true') process.stdout.write('No stashes to clear.\n')
|
|
1373
|
+
else print({ cleared: true }, true)
|
|
1374
|
+
}
|
|
1375
|
+
return
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
throw new Error('Unknown stash subcommand')
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function cmdBranch(sub, opts) {
|
|
1382
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1383
|
+
const meta = opts.repo ? { repoId: opts.repo, branch: '', server: getServer(opts, loadConfig()), token: getToken(opts, loadConfig()) } : readRemoteMeta(dir)
|
|
1384
|
+
const cfg = loadConfig()
|
|
1385
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1386
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1387
|
+
|
|
1388
|
+
if (sub === 'list' || sub === undefined) {
|
|
1389
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1390
|
+
const data = await request('GET', url, null, token)
|
|
1391
|
+
if (opts.json === 'true') {
|
|
1392
|
+
print(data, true)
|
|
1393
|
+
} else {
|
|
1394
|
+
let current = meta.branch
|
|
1395
|
+
try {
|
|
1396
|
+
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
1397
|
+
const m = head.match(/refs\/heads\/(.+)/)
|
|
1398
|
+
if (m) current = m[1]
|
|
1399
|
+
} catch {}
|
|
1400
|
+
process.stdout.write(color('Branches:\n', 'bold'))
|
|
1401
|
+
const list = (data.branches || []).map(b => ({ name: b.name, commitId: b.commitId || '' }))
|
|
1402
|
+
for (const b of list) {
|
|
1403
|
+
const isCur = b.name === current
|
|
1404
|
+
const mark = isCur ? color('*', 'green') : ' '
|
|
1405
|
+
const nameStr = isCur ? color(b.name, 'green') : color(b.name, 'cyan')
|
|
1406
|
+
const idStr = color((b.commitId || '').slice(0, 7), 'dim')
|
|
1407
|
+
process.stdout.write(` ${mark} ${nameStr} ${idStr}\n`)
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return
|
|
1411
|
+
}
|
|
1412
|
+
if (sub === 'create') {
|
|
1413
|
+
const name = opts.name
|
|
1414
|
+
if (!name) throw new Error('Missing --name')
|
|
1415
|
+
const baseBranch = opts.base || meta.branch
|
|
1416
|
+
const body = { name, baseBranch }
|
|
1417
|
+
const url = new URL(`/api/repositories/${meta.repoId}/branches`, server).toString()
|
|
1418
|
+
const data = await request('POST', url, body, token)
|
|
1419
|
+
print(data, opts.json === 'true')
|
|
1420
|
+
return
|
|
1421
|
+
}
|
|
1422
|
+
if (sub === 'delete') {
|
|
1423
|
+
const name = opts.name
|
|
1424
|
+
if (!name) throw new Error('Missing --name')
|
|
1425
|
+
const u = new URL(`/api/repositories/${meta.repoId}/branches`, server)
|
|
1426
|
+
u.searchParams.set('name', name)
|
|
1427
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1428
|
+
const res = await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1429
|
+
if (!res.ok) {
|
|
1430
|
+
const body = await res.text().catch(() => '')
|
|
1431
|
+
throw new Error(`Branch delete failed: ${res.status} ${res.statusText} ${body}`)
|
|
1432
|
+
}
|
|
1433
|
+
const data = await res.json()
|
|
1434
|
+
print(data, opts.json === 'true')
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
throw new Error('Unknown branch subcommand')
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
async function cmdSwitch(opts) {
|
|
1442
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1443
|
+
const meta = readRemoteMeta(dir)
|
|
1444
|
+
const branch = opts.branch
|
|
1445
|
+
if (!branch) throw new Error('Missing --branch')
|
|
1446
|
+
const cfg = loadConfig()
|
|
1447
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1448
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1449
|
+
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1450
|
+
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
async function checkoutCommit(meta, dir, commitId, server, token) {
|
|
1454
|
+
const snap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1455
|
+
const files = snap.files || {}
|
|
1456
|
+
const root = path.resolve(dir)
|
|
1457
|
+
for (const [p, content] of Object.entries(files)) {
|
|
1458
|
+
const fullPath = path.join(root, p)
|
|
1459
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
1460
|
+
await fs.promises.writeFile(fullPath, content, 'utf8')
|
|
1461
|
+
}
|
|
1462
|
+
const keep = new Set(Object.keys(files))
|
|
1463
|
+
const localMap = await collectLocal(root)
|
|
1464
|
+
for (const rel of Object.keys(localMap)) {
|
|
1465
|
+
if (!keep.has(rel)) {
|
|
1466
|
+
const fp = path.join(root, rel)
|
|
1467
|
+
try { await fs.promises.unlink(fp) } catch {}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
const metaDir = path.join(root, '.vcs-next')
|
|
1471
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1472
|
+
const remoteMeta = { repoId: meta.repoId, branch: meta.branch, commitId: snap.commitId || commitId, server, token: token || '' }
|
|
1473
|
+
await fs.promises.writeFile(path.join(metaDir, 'remote.json'), JSON.stringify(remoteMeta, null, 2))
|
|
1474
|
+
const gitDir = path.join(root, '.git')
|
|
1475
|
+
await fs.promises.mkdir(gitDir, { recursive: true })
|
|
1476
|
+
await fs.promises.writeFile(path.join(gitDir, 'HEAD'), commitId + '\n', 'utf8')
|
|
1477
|
+
const localState = { baseCommitId: snap.commitId || commitId, baseFiles: files, pendingCommit: null }
|
|
1478
|
+
await fs.promises.writeFile(path.join(metaDir, 'local.json'), JSON.stringify(localState, null, 2))
|
|
1479
|
+
print({ repoId: meta.repoId, commit: commitId, dir }, token ? (false) : (opts && opts.json === 'true'))
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function cmdCheckout(opts) {
|
|
1483
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1484
|
+
const meta = readRemoteMeta(dir)
|
|
1485
|
+
const cfg = loadConfig()
|
|
1486
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1487
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1488
|
+
const branch = opts.branch
|
|
1489
|
+
const commitId = opts.commit
|
|
1490
|
+
if (commitId) {
|
|
1491
|
+
await checkoutCommit(meta, dir, commitId, server, token)
|
|
1492
|
+
return
|
|
1493
|
+
}
|
|
1494
|
+
if (!branch) throw new Error('Missing --branch or --commit')
|
|
1495
|
+
await pullToDir(meta.repoId, branch, dir, server, token)
|
|
1496
|
+
print({ repoId: meta.repoId, branch, dir }, opts.json === 'true')
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
async function cmdTag(sub, opts) {
|
|
1500
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1501
|
+
const meta = readRemoteMeta(dir)
|
|
1502
|
+
const cfg = loadConfig()
|
|
1503
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1504
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1505
|
+
if (sub === 'list') {
|
|
1506
|
+
const url = new URL(`/api/repositories/${meta.repoId}/tags`, server).toString()
|
|
1507
|
+
const data = await request('GET', url, null, token)
|
|
1508
|
+
print(data, opts.json === 'true')
|
|
1509
|
+
return
|
|
1510
|
+
}
|
|
1511
|
+
if (sub === 'create') {
|
|
1512
|
+
const name = opts.name
|
|
1513
|
+
if (!name) throw new Error('Missing --name')
|
|
1514
|
+
const body = { name, branchName: opts.branch || meta.branch }
|
|
1515
|
+
const url = new URL(`/api/repositories/${meta.repoId}/tags`, server).toString()
|
|
1516
|
+
const data = await request('POST', url, body, token)
|
|
1517
|
+
print(data, opts.json === 'true')
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
if (sub === 'delete') {
|
|
1521
|
+
const name = opts.name
|
|
1522
|
+
if (!name) throw new Error('Missing --name')
|
|
1523
|
+
const u = new URL(`/api/repositories/${meta.repoId}/tags`, server)
|
|
1524
|
+
u.searchParams.set('name', name)
|
|
1525
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
|
1526
|
+
const res = await fetch(u.toString(), { method: 'DELETE', headers })
|
|
1527
|
+
if (!res.ok) throw new Error('delete failed')
|
|
1528
|
+
const data = await res.json()
|
|
1529
|
+
print(data, opts.json === 'true')
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
throw new Error('Unknown tag subcommand')
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function cmdPr(sub, opts) {
|
|
1536
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1537
|
+
const meta = readRemoteMeta(dir)
|
|
1538
|
+
const cfg = loadConfig()
|
|
1539
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1540
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1541
|
+
if (sub === 'list') {
|
|
1542
|
+
const url = new URL(`/api/repositories/${meta.repoId}/pull-requests`, server).toString()
|
|
1543
|
+
const data = await request('GET', url, null, token)
|
|
1544
|
+
print(data, opts.json === 'true')
|
|
1545
|
+
return
|
|
1546
|
+
}
|
|
1547
|
+
if (sub === 'create') {
|
|
1548
|
+
const title = opts.title
|
|
1549
|
+
const sourceBranch = opts.source || meta.branch
|
|
1550
|
+
const targetBranch = opts.target || meta.branch
|
|
1551
|
+
if (!title) throw new Error('Missing --title')
|
|
1552
|
+
const body = { title, description: opts.description || '', sourceBranch, targetBranch }
|
|
1553
|
+
const url = new URL(`/api/repositories/${meta.repoId}/pull-requests`, server).toString()
|
|
1554
|
+
const data = await request('POST', url, body, token)
|
|
1555
|
+
print(data, opts.json === 'true')
|
|
1556
|
+
return
|
|
1557
|
+
}
|
|
1558
|
+
if (sub === 'merge') {
|
|
1559
|
+
const prId = opts.id
|
|
1560
|
+
if (!prId) throw new Error('Missing --id')
|
|
1561
|
+
const url = new URL(`/api/repositories/${meta.repoId}/pull-requests/${prId}/merge`, server).toString()
|
|
1562
|
+
const data = await request('POST', url, {}, token)
|
|
1563
|
+
print(data, opts.json === 'true')
|
|
1564
|
+
return
|
|
1565
|
+
}
|
|
1566
|
+
throw new Error('Unknown pr subcommand')
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async function cmdCurrent(opts) {
|
|
1570
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1571
|
+
const meta = readRemoteMeta(dir)
|
|
1572
|
+
print({ repoId: meta.repoId, branch: meta.branch, server: meta.server, dir }, opts.json === 'true')
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
async function cmdHead(opts) {
|
|
1576
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1577
|
+
const meta = readRemoteMeta(dir)
|
|
1578
|
+
let commitId = ''
|
|
1579
|
+
try {
|
|
1580
|
+
const head = fs.readFileSync(path.join(dir, '.git', 'HEAD'), 'utf8')
|
|
1581
|
+
const m = head.match(/refs\/heads\/(.+)/)
|
|
1582
|
+
if (m) {
|
|
1583
|
+
const ref = path.join(dir, '.git', 'refs', 'heads', m[1])
|
|
1584
|
+
commitId = (await fs.promises.readFile(ref, 'utf8')).trim()
|
|
1585
|
+
} else {
|
|
1586
|
+
commitId = head.trim()
|
|
1587
|
+
}
|
|
1588
|
+
} catch {}
|
|
1589
|
+
if (!commitId) {
|
|
1590
|
+
const cfg = loadConfig()
|
|
1591
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1592
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1593
|
+
const info = await request('GET', new URL(`/api/repositories/${meta.repoId}/branches`, server).toString(), null, token)
|
|
1594
|
+
const found = (info.branches || []).find(b => b.name === meta.branch)
|
|
1595
|
+
commitId = found ? (found.commitId || '') : ''
|
|
1596
|
+
}
|
|
1597
|
+
const out = { branch: meta.branch, commitId }
|
|
1598
|
+
print(opts.json === 'true' ? out : (commitId || ''), opts.json === 'true')
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
async function cmdShow(opts) {
|
|
1602
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1603
|
+
const meta = readRemoteMeta(dir)
|
|
1604
|
+
const cfg = loadConfig()
|
|
1605
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1606
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1607
|
+
const commitId = opts.commit
|
|
1608
|
+
if (!commitId) throw new Error('Missing --commit')
|
|
1609
|
+
|
|
1610
|
+
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1611
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1612
|
+
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1613
|
+
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1614
|
+
|
|
1615
|
+
if (opts.json === 'true') {
|
|
1616
|
+
print({ commit: commitId, message: commit.message, author: commit.author, parents: commit.parents, files: Object.keys(commitSnap.files) }, true)
|
|
1617
|
+
} else {
|
|
1618
|
+
process.stdout.write(color(`commit ${commitId}\n`, 'yellow'))
|
|
1619
|
+
if (commit.author) {
|
|
1620
|
+
process.stdout.write(`Author: ${commit.author.name || ''} <${commit.author.email || ''}>\n`)
|
|
1621
|
+
}
|
|
1622
|
+
if (commit.committer) {
|
|
1623
|
+
process.stdout.write(`Date: ${commit.committer.date ? new Date(commit.committer.date).toLocaleString() : ''}\n`)
|
|
1624
|
+
}
|
|
1625
|
+
process.stdout.write(`\n${commit.message || ''}\n\n`)
|
|
1626
|
+
|
|
1627
|
+
// Show file changes
|
|
1628
|
+
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files)])
|
|
1629
|
+
const changed = []
|
|
1630
|
+
for (const p of allPaths) {
|
|
1631
|
+
const old = parentSnap.files[p]
|
|
1632
|
+
const new_ = commitSnap.files[p]
|
|
1633
|
+
if (old !== new_) {
|
|
1634
|
+
if (old === undefined) changed.push({ path: p, type: 'added' })
|
|
1635
|
+
else if (new_ === undefined) changed.push({ path: p, type: 'deleted' })
|
|
1636
|
+
else changed.push({ path: p, type: 'modified' })
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (changed.length > 0) {
|
|
1640
|
+
process.stdout.write(color(`\nFiles changed (${changed.length}):\n`, 'bold'))
|
|
1641
|
+
for (const ch of changed) {
|
|
1642
|
+
const colorCode = ch.type === 'added' ? 'green' : ch.type === 'deleted' ? 'red' : 'yellow'
|
|
1643
|
+
process.stdout.write(color(`${ch.type === 'added' ? '+' : ch.type === 'deleted' ? '-' : 'M'} ${ch.path}\n`, colorCode))
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
async function cmdRevert(opts) {
|
|
1650
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1651
|
+
const meta = readRemoteMeta(dir)
|
|
1652
|
+
const cfg = loadConfig()
|
|
1653
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1654
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1655
|
+
const commitId = opts.commit
|
|
1656
|
+
if (!commitId) throw new Error('Missing --commit')
|
|
1657
|
+
|
|
1658
|
+
// Get the commit to revert
|
|
1659
|
+
const commit = await fetchCommitMeta(server, meta.repoId, commitId, token)
|
|
1660
|
+
const commitSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1661
|
+
const parentId = (Array.isArray(commit.parents) && commit.parents[0]) || ''
|
|
1662
|
+
const parentSnap = parentId ? await fetchSnapshotByCommit(server, meta.repoId, parentId, token) : { files: {}, commitId: '' }
|
|
1663
|
+
|
|
1664
|
+
// Get current state
|
|
1665
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1666
|
+
const currentSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1667
|
+
const localAfterPull = await collectLocal(dir)
|
|
1668
|
+
|
|
1669
|
+
// Revert: apply inverse of commit changes
|
|
1670
|
+
const conflicts = []
|
|
1671
|
+
const changes = []
|
|
1672
|
+
const allPaths = new Set([...Object.keys(parentSnap.files), ...Object.keys(commitSnap.files), ...Object.keys(currentSnap.files)])
|
|
1673
|
+
|
|
1674
|
+
for (const p of allPaths) {
|
|
1675
|
+
const base = parentSnap.files[p] !== undefined ? String(parentSnap.files[p]) : null
|
|
1676
|
+
const commitContent = commitSnap.files[p] !== undefined ? String(commitSnap.files[p]) : null
|
|
1677
|
+
const current = currentSnap.files[p] !== undefined ? String(currentSnap.files[p]) : null
|
|
1678
|
+
const local = localAfterPull[p]?.content || null
|
|
1679
|
+
|
|
1680
|
+
// Revert: we want to go from commit state back to parent state
|
|
1681
|
+
// But check if current branch has changed this file
|
|
1682
|
+
const commitChanged = base !== commitContent
|
|
1683
|
+
const currentChanged = current !== base
|
|
1684
|
+
|
|
1685
|
+
if (commitChanged) {
|
|
1686
|
+
// Commit changed this file - we want to revert it
|
|
1687
|
+
if (currentChanged && current !== base) {
|
|
1688
|
+
// Conflict: current branch also changed this file
|
|
1689
|
+
const line = firstDiffLine(String(local || ''), String(base || ''))
|
|
1690
|
+
conflicts.push({ path: p, line, current: local, incoming: base })
|
|
1691
|
+
} else {
|
|
1692
|
+
// No conflict: revert to base
|
|
1693
|
+
if (base !== null) {
|
|
1694
|
+
if (local !== base) {
|
|
1695
|
+
changes.push({ type: 'write', path: p, content: base })
|
|
1696
|
+
}
|
|
1697
|
+
} else {
|
|
1698
|
+
// Base was null, commit added file - delete it
|
|
1699
|
+
if (local !== null) {
|
|
1700
|
+
changes.push({ type: 'delete', path: p })
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
if (conflicts.length > 0) {
|
|
1708
|
+
// Write conflict markers to files
|
|
1709
|
+
for (const conflict of conflicts) {
|
|
1710
|
+
const fp = path.join(dir, conflict.path)
|
|
1711
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1712
|
+
const conflictContent = writeConflictMarkers(
|
|
1713
|
+
conflict.current,
|
|
1714
|
+
conflict.incoming,
|
|
1715
|
+
meta.branch,
|
|
1716
|
+
`revert-${commitId.slice(0,7)}`
|
|
1717
|
+
)
|
|
1718
|
+
await fs.promises.writeFile(fp, conflictContent, 'utf8')
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
// Store conflict state in metadata
|
|
1722
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1723
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1724
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1725
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null, conflicts: conflicts.map(c => c.path) }
|
|
1726
|
+
try {
|
|
1727
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1728
|
+
localMeta = { ...JSON.parse(s), conflicts: conflicts.map(c => c.path) }
|
|
1729
|
+
} catch {}
|
|
1730
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1731
|
+
|
|
1732
|
+
if (opts.json === 'true') {
|
|
1733
|
+
print({ conflicts: conflicts.map(c => ({ path: c.path, line: c.line })), message: `Revert ${commitId}` }, true)
|
|
1734
|
+
} else {
|
|
1735
|
+
process.stdout.write(color(`\nRevert conflict detected!\n`, 'red'))
|
|
1736
|
+
process.stdout.write(color(`Conflicts in ${conflicts.length} file(s):\n`, 'yellow'))
|
|
1737
|
+
for (const c of conflicts) {
|
|
1738
|
+
process.stdout.write(color(` ${c.path}:${c.line}\n`, 'red'))
|
|
1739
|
+
}
|
|
1740
|
+
process.stdout.write(color(`\nResolve conflicts manually, then commit and push.\n`, 'yellow'))
|
|
1741
|
+
process.stdout.write(color(`Files with conflicts have been marked with conflict markers:\n`, 'dim'))
|
|
1742
|
+
process.stdout.write(color(` <<<<<<< ${meta.branch}\n`, 'dim'))
|
|
1743
|
+
process.stdout.write(color(` =======\n`, 'dim'))
|
|
1744
|
+
process.stdout.write(color(` >>>>>>> revert-${commitId.slice(0,7)}\n`, 'dim'))
|
|
1745
|
+
}
|
|
1746
|
+
return
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
// Apply changes
|
|
1750
|
+
for (const ch of changes) {
|
|
1751
|
+
const fp = path.join(dir, ch.path)
|
|
1752
|
+
if (ch.type === 'delete') {
|
|
1753
|
+
try { await fs.promises.unlink(fp) } catch {}
|
|
1754
|
+
} else if (ch.type === 'write') {
|
|
1755
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1756
|
+
await fs.promises.writeFile(fp, ch.content, 'utf8')
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// Update metadata
|
|
1761
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1762
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1763
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1764
|
+
let localMeta = { baseCommitId: currentSnap.commitId, baseFiles: currentSnap.files, pendingCommit: null }
|
|
1765
|
+
try {
|
|
1766
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1767
|
+
localMeta = JSON.parse(s)
|
|
1768
|
+
} catch {}
|
|
1769
|
+
|
|
1770
|
+
const revertMsg = opts.message || `Revert "${commit.message || commitId}"`
|
|
1771
|
+
const finalLocal = await collectLocal(dir)
|
|
1772
|
+
const finalFiles = {}
|
|
1773
|
+
for (const [p, v] of Object.entries(finalLocal)) {
|
|
1774
|
+
finalFiles[p] = v.content
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
localMeta.pendingCommit = { message: revertMsg, files: finalFiles, createdAt: Date.now() }
|
|
1778
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1779
|
+
|
|
1780
|
+
if (opts.json === 'true') {
|
|
1781
|
+
print({ reverted: commitId, changes: changes.length }, true)
|
|
1782
|
+
} else {
|
|
1783
|
+
process.stdout.write(color(`Reverted ${commitId.slice(0,7)}: ${changes.length} changes\n`, 'green'))
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
if (opts.noPush !== 'true') {
|
|
1787
|
+
await cmdPush({ dir, json: opts.json, message: revertMsg })
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
async function cmdReset(opts) {
|
|
1792
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1793
|
+
const meta = readRemoteMeta(dir)
|
|
1794
|
+
const cfg = loadConfig()
|
|
1795
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1796
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1797
|
+
|
|
1798
|
+
const commitId = opts.commit || 'HEAD'
|
|
1799
|
+
const mode = opts.mode || 'mixed' // soft, mixed, hard
|
|
1800
|
+
const filePath = opts.path
|
|
1801
|
+
|
|
1802
|
+
if (filePath) {
|
|
1803
|
+
// Reset specific file (unstage)
|
|
1804
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1805
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1806
|
+
let localMeta = { baseCommitId: '', baseFiles: {}, pendingCommit: null }
|
|
1807
|
+
try {
|
|
1808
|
+
const s = await fs.promises.readFile(localPath, 'utf8')
|
|
1809
|
+
localMeta = JSON.parse(s)
|
|
1810
|
+
} catch {}
|
|
1811
|
+
|
|
1812
|
+
// Restore file from base
|
|
1813
|
+
const baseContent = localMeta.baseFiles[filePath]
|
|
1814
|
+
if (baseContent !== undefined) {
|
|
1815
|
+
const fp = path.join(dir, filePath)
|
|
1816
|
+
await fs.promises.mkdir(path.dirname(fp), { recursive: true })
|
|
1817
|
+
await fs.promises.writeFile(fp, String(baseContent), 'utf8')
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
if (opts.json === 'true') {
|
|
1821
|
+
print({ reset: filePath }, true)
|
|
1822
|
+
} else {
|
|
1823
|
+
process.stdout.write(color(`Reset ${filePath}\n`, 'green'))
|
|
1824
|
+
}
|
|
1825
|
+
return
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// Reset to commit
|
|
1829
|
+
let targetSnap
|
|
1830
|
+
if (commitId === 'HEAD') {
|
|
1831
|
+
targetSnap = await fetchRemoteSnapshot(server, meta.repoId, meta.branch, token)
|
|
1832
|
+
} else {
|
|
1833
|
+
targetSnap = await fetchSnapshotByCommit(server, meta.repoId, commitId, token)
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
if (mode === 'hard') {
|
|
1837
|
+
// Hard reset: discard all changes, reset to commit
|
|
1838
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1839
|
+
// Update to target commit if different
|
|
1840
|
+
if (commitId !== 'HEAD') {
|
|
1841
|
+
const files = targetSnap.files || {}
|
|
1842
|
+
const root = path.resolve(dir)
|
|
1843
|
+
for (const [p, content] of Object.entries(files)) {
|
|
1844
|
+
const fullPath = path.join(root, p)
|
|
1845
|
+
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true })
|
|
1846
|
+
await fs.promises.writeFile(fullPath, String(content), 'utf8')
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
} else if (mode === 'soft') {
|
|
1850
|
+
// Soft reset: keep changes staged
|
|
1851
|
+
// Just update base reference
|
|
1852
|
+
} else {
|
|
1853
|
+
// Mixed reset: keep changes unstaged (default)
|
|
1854
|
+
await pullToDir(meta.repoId, meta.branch, dir, server, token)
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// Update metadata
|
|
1858
|
+
const metaDir = path.join(dir, '.vcs-next')
|
|
1859
|
+
const localPath = path.join(metaDir, 'local.json')
|
|
1860
|
+
await fs.promises.mkdir(metaDir, { recursive: true })
|
|
1861
|
+
const localMeta = { baseCommitId: targetSnap.commitId, baseFiles: targetSnap.files, pendingCommit: null }
|
|
1862
|
+
await fs.promises.writeFile(localPath, JSON.stringify(localMeta, null, 2))
|
|
1863
|
+
|
|
1864
|
+
if (opts.json === 'true') {
|
|
1865
|
+
print({ reset: commitId, mode }, true)
|
|
1866
|
+
} else {
|
|
1867
|
+
process.stdout.write(color(`Reset to ${commitId} (${mode})\n`, 'green'))
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
async function cmdMv(opts) {
|
|
1872
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1873
|
+
const from = opts.from
|
|
1874
|
+
const to = opts.to
|
|
1875
|
+
if (!from || !to) throw new Error('Missing --from and --to')
|
|
1876
|
+
|
|
1877
|
+
const fromPath = path.join(dir, from)
|
|
1878
|
+
const toPath = path.join(dir, to)
|
|
1879
|
+
|
|
1880
|
+
// Check if source exists
|
|
1881
|
+
const stat = await fs.promises.stat(fromPath).catch(() => null)
|
|
1882
|
+
if (!stat) throw new Error(`Source file not found: ${from}`)
|
|
1883
|
+
|
|
1884
|
+
// Move file
|
|
1885
|
+
await fs.promises.mkdir(path.dirname(toPath), { recursive: true })
|
|
1886
|
+
await fs.promises.rename(fromPath, toPath)
|
|
1887
|
+
|
|
1888
|
+
// If it's a tracked file, we need to update it in the next commit
|
|
1889
|
+
// For now, just move it - the next commit will track the change
|
|
1890
|
+
if (opts.json === 'true') {
|
|
1891
|
+
print({ moved: { from, to } }, true)
|
|
1892
|
+
} else {
|
|
1893
|
+
process.stdout.write(color(`Moved ${from} → ${to}\n`, 'green'))
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async function cmdAdd(opts) {
|
|
1898
|
+
const dir = path.resolve(opts.dir || '.')
|
|
1899
|
+
|
|
1900
|
+
// Handle --all flag
|
|
1901
|
+
if (opts.all === 'true' || opts.all === true) {
|
|
1902
|
+
const meta = readRemoteMeta(dir)
|
|
1903
|
+
const cfg = loadConfig()
|
|
1904
|
+
const server = getServer(opts, cfg) || meta.server
|
|
1905
|
+
const token = getToken(opts, cfg) || meta.token
|
|
1906
|
+
const remote = await fetchRemoteFilesMap(server, meta.repoId, meta.branch, token)
|
|
1907
|
+
const local = await collectLocal(dir)
|
|
1908
|
+
|
|
1909
|
+
// Stage all changes (add new, modified, delete removed)
|
|
1910
|
+
const allPaths = new Set([...Object.keys(remote.map), ...Object.keys(local)])
|
|
1911
|
+
let stagedCount = 0
|
|
1912
|
+
for (const p of allPaths) {
|
|
1913
|
+
const remoteId = remote.map[p]
|
|
1914
|
+
const localId = local[p]?.id
|
|
1915
|
+
if (remoteId !== localId) {
|
|
1916
|
+
stagedCount++
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
if (opts.json === 'true') {
|
|
1921
|
+
print({ staged: stagedCount }, true)
|
|
1922
|
+
} else {
|
|
1923
|
+
process.stdout.write(color(`Staged ${stagedCount} changes\n`, 'green'))
|
|
1924
|
+
}
|
|
1925
|
+
return
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
const p = opts.path
|
|
1929
|
+
if (!p) throw new Error('Missing --path')
|
|
1930
|
+
const abs = path.join(dir, p)
|
|
1931
|
+
const exists = await fs.promises.stat(abs).catch(() => null)
|
|
1932
|
+
if (opts.from) {
|
|
1933
|
+
const src = path.resolve(opts.from)
|
|
1934
|
+
const srcStat = await fs.promises.stat(src)
|
|
1935
|
+
if (srcStat.isDirectory()) {
|
|
1936
|
+
await copyDir(src, abs)
|
|
1937
|
+
if (opts['commit-message']) {
|
|
1938
|
+
await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
|
|
1939
|
+
return
|
|
1940
|
+
}
|
|
1941
|
+
print({ addedDir: p, dir }, opts.json === 'true')
|
|
1942
|
+
return
|
|
1943
|
+
} else {
|
|
1944
|
+
const targetStat = exists
|
|
1945
|
+
const target = targetStat && targetStat.isDirectory() ? path.join(abs, path.basename(src)) : abs
|
|
1946
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true })
|
|
1947
|
+
await fs.promises.copyFile(src, target)
|
|
1948
|
+
if (opts['commit-message']) {
|
|
1949
|
+
await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
|
|
1950
|
+
return
|
|
1951
|
+
}
|
|
1952
|
+
print({ added: path.relative(dir, target), dir }, opts.json === 'true')
|
|
1953
|
+
return
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
if (p === '.' || (exists && exists.isDirectory())) {
|
|
1957
|
+
if (!exists) await fs.promises.mkdir(abs, { recursive: true })
|
|
1958
|
+
if (opts['commit-message']) {
|
|
1959
|
+
await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
|
|
1960
|
+
return
|
|
1961
|
+
}
|
|
1962
|
+
print({ addedDir: p, dir }, opts.json === 'true')
|
|
1963
|
+
return
|
|
1964
|
+
}
|
|
1965
|
+
await fs.promises.mkdir(path.dirname(abs), { recursive: true })
|
|
1966
|
+
const content = opts.content || ''
|
|
1967
|
+
await fs.promises.writeFile(abs, content, 'utf8')
|
|
1968
|
+
if (opts['commit-message']) {
|
|
1969
|
+
await cmdCommit({ dir, message: opts['commit-message'], json: opts.json })
|
|
1970
|
+
return
|
|
1971
|
+
}
|
|
1972
|
+
print({ added: p, dir }, opts.json === 'true')
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
function help() {
|
|
1976
|
+
const h = [
|
|
1977
|
+
'Usage: resulgit <group> <command> [options]',
|
|
1978
|
+
'',
|
|
1979
|
+
'Groups:',
|
|
1980
|
+
' auth set-token --token <token>',
|
|
1981
|
+
' auth set-server --server <url>',
|
|
1982
|
+
' auth login --email <email> --password <password> [--server <url>]',
|
|
1983
|
+
' auth register --username <name> --email <email> --password <password> [--displayName <text>] [--server <url>]',
|
|
1984
|
+
' repo list [--json]',
|
|
1985
|
+
' repo create --name <name> [--description <text>] [--visibility <private|public>] [--init]',
|
|
1986
|
+
' repo log --repo <id> [--branch <name>] [--json]',
|
|
1987
|
+
' repo head --repo <id> [--branch <name>] [--json]',
|
|
1988
|
+
' repo select [--workspace] (interactive select and clone/open)',
|
|
1989
|
+
' branch list|create|delete [--dir <path>] [--name <branch>] [--base <branch>]',
|
|
1990
|
+
' switch --branch <name> [--dir <path>]',
|
|
1991
|
+
' current [--dir <path>] (show active repo/branch)',
|
|
1992
|
+
' add <file> [--content <text>] [--from <src>] [--all] [--dir <path>] [--commit-message <text>]',
|
|
1993
|
+
' tag list|create|delete [--dir <path>] [--name <tag>] [--branch <name>]',
|
|
1994
|
+
' status [--dir <path>] [--json]',
|
|
1995
|
+
' diff [--dir <path>] [--path <file>] [--commit <id>] [--json]',
|
|
1996
|
+
' commit --message <text> [--dir <path>] [--json]',
|
|
1997
|
+
' push [--dir <path>] [--json]',
|
|
1998
|
+
' head [--dir <path>] [--json]',
|
|
1999
|
+
' rm --path <file> [--dir <path>] [--json]',
|
|
2000
|
+
' pull [--dir <path>]',
|
|
2001
|
+
' merge --branch <name> [--squash] [--no-push] [--dir <path>]',
|
|
2002
|
+
' stash [save|list|pop|apply|drop|clear] [--message <msg>] [--index <n>] [--dir <path>]',
|
|
2003
|
+
' restore --path <file> [--source <commit>] [--dir <path>]',
|
|
2004
|
+
' revert --commit <id> [--no-push] [--dir <path>]',
|
|
2005
|
+
' reset [--commit <id>] [--mode <soft|mixed|hard>] [--path <file>] [--dir <path>]',
|
|
2006
|
+
' show --commit <id> [--dir <path>] [--json]',
|
|
2007
|
+
' mv --from <old> --to <new> [--dir <path>]',
|
|
2008
|
+
'',
|
|
2009
|
+
'Conflict Resolution:',
|
|
2010
|
+
' When conflicts occur (merge/cherry-pick/revert), conflict markers are written to files:',
|
|
2011
|
+
' <<<<<<< HEAD (current changes)',
|
|
2012
|
+
' =======',
|
|
2013
|
+
' >>>>>>> incoming (incoming changes)',
|
|
2014
|
+
' Resolve conflicts manually, then commit and push. Push is blocked until conflicts are resolved.',
|
|
2015
|
+
' pr list|create|merge [--dir <path>] [--title <t>] [--id <id>] [--source <branch>] [--target <branch>]',
|
|
2016
|
+
' clone --repo <id> --branch <name> [--dest <dir>] [--server <url>] [--token <token>]',
|
|
2017
|
+
' workspace set-root --path <dir>',
|
|
2018
|
+
' cherry-pick --commit <id> [--branch <name>] [--no-push] [--dir <path>]',
|
|
2019
|
+
' checkout <branch>|--branch <name> | --commit <id> [--dir <path>]',
|
|
2020
|
+
'',
|
|
2021
|
+
'Global options:',
|
|
2022
|
+
' --server <url> Override default server',
|
|
2023
|
+
' --token <tok> Override stored token',
|
|
2024
|
+
' --json JSON output'
|
|
2025
|
+
].join('\n')
|
|
2026
|
+
process.stdout.write(h + '\n')
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
async function main() {
|
|
2030
|
+
const { cmd, opts } = parseArgs(process.argv)
|
|
2031
|
+
const cfg = loadConfig()
|
|
2032
|
+
if (cmd.length === 0 || (cmd.length === 1 && (cmd[0] === 'help' || cmd[0] === '--help'))) {
|
|
2033
|
+
help()
|
|
2034
|
+
return
|
|
2035
|
+
}
|
|
2036
|
+
if (cmd[0] === 'auth') {
|
|
2037
|
+
await cmdAuth(cmd[1], opts)
|
|
2038
|
+
return
|
|
2039
|
+
}
|
|
2040
|
+
if (cmd[0] === 'repo') {
|
|
2041
|
+
await cmdRepo(cmd[1], opts, cfg)
|
|
2042
|
+
return
|
|
2043
|
+
}
|
|
2044
|
+
if (cmd[0] === 'clone') {
|
|
2045
|
+
await cmdClone(opts, cfg)
|
|
2046
|
+
return
|
|
2047
|
+
}
|
|
2048
|
+
if (cmd[0] === 'status') {
|
|
2049
|
+
await cmdStatus(opts)
|
|
2050
|
+
return
|
|
2051
|
+
}
|
|
2052
|
+
if (cmd[0] === 'diff') {
|
|
2053
|
+
await cmdDiff(opts)
|
|
2054
|
+
return
|
|
2055
|
+
}
|
|
2056
|
+
if (cmd[0] === 'commit') {
|
|
2057
|
+
await cmdCommit(opts)
|
|
2058
|
+
return
|
|
2059
|
+
}
|
|
2060
|
+
if (cmd[0] === 'push') {
|
|
2061
|
+
await cmdPush(opts)
|
|
2062
|
+
return
|
|
2063
|
+
}
|
|
2064
|
+
if (cmd[0] === 'rm') {
|
|
2065
|
+
await cmdRm(opts)
|
|
2066
|
+
return
|
|
2067
|
+
}
|
|
2068
|
+
if (cmd[0] === 'pull') {
|
|
2069
|
+
await cmdPull(opts)
|
|
2070
|
+
return
|
|
2071
|
+
}
|
|
2072
|
+
if (cmd[0] === 'branch') {
|
|
2073
|
+
await cmdBranch(cmd[1], opts)
|
|
2074
|
+
return
|
|
2075
|
+
}
|
|
2076
|
+
if (cmd[0] === 'switch') {
|
|
2077
|
+
await cmdSwitch(opts)
|
|
2078
|
+
return
|
|
2079
|
+
}
|
|
2080
|
+
if (cmd[0] === 'tag') {
|
|
2081
|
+
await cmdTag(cmd[1], opts)
|
|
2082
|
+
return
|
|
2083
|
+
}
|
|
2084
|
+
if (cmd[0] === 'pr') {
|
|
2085
|
+
await cmdPr(cmd[1], opts)
|
|
2086
|
+
return
|
|
2087
|
+
}
|
|
2088
|
+
if (cmd[0] === 'merge') {
|
|
2089
|
+
await cmdMerge(opts)
|
|
2090
|
+
return
|
|
2091
|
+
}
|
|
2092
|
+
if (cmd[0] === 'stash') {
|
|
2093
|
+
await cmdStash(cmd[1], opts)
|
|
2094
|
+
return
|
|
2095
|
+
}
|
|
2096
|
+
if (cmd[0] === 'restore') {
|
|
2097
|
+
await cmdRestore(opts)
|
|
2098
|
+
return
|
|
2099
|
+
}
|
|
2100
|
+
if (cmd[0] === 'revert') {
|
|
2101
|
+
await cmdRevert(opts)
|
|
2102
|
+
return
|
|
2103
|
+
}
|
|
2104
|
+
if (cmd[0] === 'reset') {
|
|
2105
|
+
await cmdReset(opts)
|
|
2106
|
+
return
|
|
2107
|
+
}
|
|
2108
|
+
if (cmd[0] === 'show') {
|
|
2109
|
+
await cmdShow(opts)
|
|
2110
|
+
return
|
|
2111
|
+
}
|
|
2112
|
+
if (cmd[0] === 'mv') {
|
|
2113
|
+
await cmdMv(opts)
|
|
2114
|
+
return
|
|
2115
|
+
}
|
|
2116
|
+
if (cmd[0] === 'cherry-pick') {
|
|
2117
|
+
await cmdCherryPick(opts)
|
|
2118
|
+
return
|
|
2119
|
+
}
|
|
2120
|
+
if (cmd[0] === 'checkout') {
|
|
2121
|
+
if (!opts.branch && !opts.commit && cmd[1]) opts.branch = cmd[1]
|
|
2122
|
+
await cmdCheckout(opts)
|
|
2123
|
+
return
|
|
2124
|
+
}
|
|
2125
|
+
if (cmd[0] === 'head') {
|
|
2126
|
+
await cmdHead(opts)
|
|
2127
|
+
return
|
|
2128
|
+
}
|
|
2129
|
+
if (cmd[0] === 'workspace') {
|
|
2130
|
+
if (cmd[1] === 'set-root') {
|
|
2131
|
+
const p = opts.path
|
|
2132
|
+
if (!p) throw new Error('Missing --path')
|
|
2133
|
+
const cfg2 = saveConfig({ workspaceRoot: path.resolve(p) })
|
|
2134
|
+
print({ workspaceRoot: cfg2.workspaceRoot }, opts.json === 'true')
|
|
2135
|
+
return
|
|
2136
|
+
}
|
|
2137
|
+
throw new Error('Unknown workspace subcommand')
|
|
2138
|
+
}
|
|
2139
|
+
if (cmd[0] === 'current' || cmd[0] === 'which') {
|
|
2140
|
+
await cmdCurrent(opts)
|
|
2141
|
+
return
|
|
2142
|
+
}
|
|
2143
|
+
if (cmd[0] === 'add') {
|
|
2144
|
+
if (!opts.path && cmd[1]) opts.path = cmd[1]
|
|
2145
|
+
await cmdAdd(opts)
|
|
2146
|
+
return
|
|
2147
|
+
}
|
|
2148
|
+
throw new Error('Unknown command')
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function enableMouse() {
|
|
2152
|
+
process.stdout.write('\x1b[?1000h')
|
|
2153
|
+
process.stdout.write('\x1b[?1006h')
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function disableMouse() {
|
|
2157
|
+
process.stdout.write('\x1b[?1000l')
|
|
2158
|
+
process.stdout.write('\x1b[?1006l')
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
async function tuiSelectRepo(repos, cfg, opts) {
|
|
2162
|
+
const wsRoot = (opts.workspace ? process.cwd() : (cfg.workspaceRoot || path.join(os.homedir(), 'resulgit-workspace')))
|
|
2163
|
+
await fs.promises.mkdir(wsRoot, { recursive: true })
|
|
2164
|
+
const items = repos.map(r => ({ id: String(r.id || r._id || ''), name: r.name || '', defaultBranch: r.defaultBranch || 'main' }))
|
|
2165
|
+
if (items.length === 0) {
|
|
2166
|
+
print('No repositories available', false)
|
|
2167
|
+
return
|
|
2168
|
+
}
|
|
2169
|
+
const lines = process.stdout.rows || 24
|
|
2170
|
+
let idx = 0
|
|
2171
|
+
const render = () => {
|
|
2172
|
+
process.stdout.write('\x1b[2J\x1b[H')
|
|
2173
|
+
process.stdout.write('Select a repository (↑/↓, Enter to open, q to quit)\n')
|
|
2174
|
+
process.stdout.write(`Workspace: ${wsRoot}\n\n`)
|
|
2175
|
+
const max = Math.max(0, lines - 5)
|
|
2176
|
+
const start = Math.max(0, Math.min(idx - Math.floor(max / 2), items.length - max))
|
|
2177
|
+
const end = Math.min(items.length, start + max)
|
|
2178
|
+
for (let i = start; i < end; i++) {
|
|
2179
|
+
const mark = i === idx ? '> ' : ' '
|
|
2180
|
+
process.stdout.write(`${mark}${items[i].name} (${items[i].id})\n`)
|
|
2181
|
+
}
|
|
2182
|
+
process.stdout.write('\nMouse: click an item to select.\n')
|
|
2183
|
+
}
|
|
2184
|
+
const cleanup = () => {
|
|
2185
|
+
disableMouse()
|
|
2186
|
+
process.stdin.setRawMode(false)
|
|
2187
|
+
process.stdin.pause()
|
|
2188
|
+
process.stdout.write('\x1b[?25h')
|
|
2189
|
+
}
|
|
2190
|
+
process.stdout.write('\x1b[?25l')
|
|
2191
|
+
enableMouse()
|
|
2192
|
+
render()
|
|
2193
|
+
process.stdin.setRawMode(true)
|
|
2194
|
+
process.stdin.resume()
|
|
2195
|
+
process.stdin.on('data', async (buf) => {
|
|
2196
|
+
const s = buf.toString('utf8')
|
|
2197
|
+
if (s === '\u0003') { // Ctrl-C
|
|
2198
|
+
cleanup()
|
|
2199
|
+
return
|
|
2200
|
+
}
|
|
2201
|
+
if (s === '\u001b[A') { // Up
|
|
2202
|
+
idx = (idx + items.length - 1) % items.length
|
|
2203
|
+
render()
|
|
2204
|
+
return
|
|
2205
|
+
}
|
|
2206
|
+
if (s === '\u001b[B') { // Down
|
|
2207
|
+
idx = (idx + 1) % items.length
|
|
2208
|
+
render()
|
|
2209
|
+
return
|
|
2210
|
+
}
|
|
2211
|
+
if (s === '\r') { // Enter
|
|
2212
|
+
cleanup()
|
|
2213
|
+
await openRepo(items[idx], wsRoot, cfg, opts)
|
|
2214
|
+
return
|
|
2215
|
+
}
|
|
2216
|
+
if (s === 'q') {
|
|
2217
|
+
cleanup()
|
|
2218
|
+
return
|
|
2219
|
+
}
|
|
2220
|
+
if (s.startsWith('\u001b[')) {
|
|
2221
|
+
const m = s.match(/\u001b\[<(?<btn>\d+);(?<x>\d+);(?<y>\d+)(?<type>[mM])/)
|
|
2222
|
+
if (m && m.groups) {
|
|
2223
|
+
const y = parseInt(m.groups.y, 10)
|
|
2224
|
+
const headerLines = 3
|
|
2225
|
+
const itemIdx = y - headerLines - 1
|
|
2226
|
+
if (itemIdx >= 0 && itemIdx < items.length) {
|
|
2227
|
+
idx = itemIdx
|
|
2228
|
+
render()
|
|
2229
|
+
if (m.groups.type === 'M') { // press
|
|
2230
|
+
cleanup()
|
|
2231
|
+
await openRepo(items[idx], wsRoot, cfg, opts)
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
})
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
async function openRepo(item, wsRoot, cfg, opts) {
|
|
2240
|
+
const server = getServer(opts, cfg)
|
|
2241
|
+
const token = getToken(opts, cfg)
|
|
2242
|
+
const dest = path.join(wsRoot, item.name || item.id)
|
|
2243
|
+
await pullToDir(item.id, item.defaultBranch || 'main', dest, server, token)
|
|
2244
|
+
print({ opened: item.name, id: item.id, path: dest }, opts.json === 'true')
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
async function copyDir(src, dest) {
|
|
2248
|
+
await fs.promises.mkdir(dest, { recursive: true })
|
|
2249
|
+
const entries = await fs.promises.readdir(src, { withFileTypes: true })
|
|
2250
|
+
for (const e of entries) {
|
|
2251
|
+
if (e.name === '.git' || e.name === '.vcs-next') continue
|
|
2252
|
+
const s = path.join(src, e.name)
|
|
2253
|
+
const d = path.join(dest, e.name)
|
|
2254
|
+
if (e.isDirectory()) {
|
|
2255
|
+
await copyDir(s, d)
|
|
2256
|
+
} else if (e.isFile()) {
|
|
2257
|
+
await fs.promises.copyFile(s, d)
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
async function tuiSelectBranch(branches, current) {
|
|
2263
|
+
const items = (branches || []).map((b) => ({ name: b.name || '', commitId: b.commitId || '' })).filter((b) => b.name)
|
|
2264
|
+
if (items.length === 0) throw new Error('No branches available')
|
|
2265
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2266
|
+
const found = items.find((i) => i.name === current)
|
|
2267
|
+
return (found ? found.name : items[0].name)
|
|
2268
|
+
}
|
|
2269
|
+
let idx = Math.max(0, items.findIndex((i) => i.name === current))
|
|
2270
|
+
const render = () => {
|
|
2271
|
+
process.stdout.write('\x1b[2J\x1b[H')
|
|
2272
|
+
process.stdout.write(color('Select target branch for cherry-pick\n\n', 'bold'))
|
|
2273
|
+
for (let i = 0; i < items.length; i++) {
|
|
2274
|
+
const isCur = items[i].name === current
|
|
2275
|
+
const mark = i === idx ? color('> ', 'yellow') : ' '
|
|
2276
|
+
const star = isCur ? color('* ', 'green') : ' '
|
|
2277
|
+
const nameStr = isCur ? color(items[i].name, 'green') : color(items[i].name, 'cyan')
|
|
2278
|
+
const idStr = color((items[i].commitId || '').slice(0, 7), 'dim')
|
|
2279
|
+
process.stdout.write(`${mark}${star}${nameStr} ${idStr}\n`)
|
|
2280
|
+
}
|
|
2281
|
+
process.stdout.write('\nUse ↑/↓ and Enter. Press q to cancel.\n')
|
|
2282
|
+
}
|
|
2283
|
+
const cleanup = (listener) => {
|
|
2284
|
+
try { process.stdin.removeListener('data', listener) } catch {}
|
|
2285
|
+
process.stdin.setRawMode(false)
|
|
2286
|
+
process.stdin.pause()
|
|
2287
|
+
process.stdout.write('\x1b[?25h')
|
|
2288
|
+
}
|
|
2289
|
+
process.stdout.write('\x1b[?25l')
|
|
2290
|
+
render()
|
|
2291
|
+
process.stdin.setRawMode(true)
|
|
2292
|
+
process.stdin.resume()
|
|
2293
|
+
return await new Promise((resolve, reject) => {
|
|
2294
|
+
const onData = (buf) => {
|
|
2295
|
+
const s = buf.toString('utf8')
|
|
2296
|
+
if (s === '\u0003') { cleanup(onData); reject(new Error('Cancelled')); return }
|
|
2297
|
+
if (s === '\u001b[A') { idx = (idx + items.length - 1) % items.length; render(); return }
|
|
2298
|
+
if (s === '\u001b[B') { idx = (idx + 1) % items.length; render(); return }
|
|
2299
|
+
if (s === 'q') { cleanup(onData); reject(new Error('Cancelled')); return }
|
|
2300
|
+
if (s === '\r') { const name = items[idx].name; cleanup(onData); resolve(name); return }
|
|
2301
|
+
}
|
|
2302
|
+
process.stdin.on('data', onData)
|
|
2303
|
+
})
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
main().catch((err) => {
|
|
2307
|
+
process.stderr.write((err && err.message) ? err.message + '\n' : String(err) + '\n')
|
|
2308
|
+
process.exit(1)
|
|
2309
|
+
})
|