dsh-ide-git 0.22.0 → 0.24.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 +3 -2
- package/README_EN.md +4 -3
- package/dsh.plugin.json +2 -2
- package/package.json +4 -3
- package/src/client.js +333 -119
- package/src/host/commit-message.js +354 -0
- package/src/host/config.js +71 -0
- package/src/host/core.js +121 -0
- package/src/host/git.js +423 -0
- package/src/host/http.js +237 -0
- package/src/host/read.js +348 -0
- package/src/host/repos.js +222 -0
- package/src/host/undo.js +207 -0
- package/src/host/write.js +551 -0
- package/src/index.js +11 -2320
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
/** dsh-ide-git host: mutating methods. Every destructive one checks confirm. */
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
|
|
9
|
+
PanelError, badRequest, cwdOf, requireAbsolute, requireRef, requireMessage,
|
|
10
|
+
|
|
11
|
+
optionalRevs, optionalPaths, requireRelativePath,
|
|
12
|
+
|
|
13
|
+
} from './core.js'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
|
|
17
|
+
git, runGit, gitFailure, repoRootOf, currentBranchOf, worktreesOf, sameDirectory,
|
|
18
|
+
|
|
19
|
+
FIELD_SEP, RECORD_SEP, NETWORK_TIMEOUT_MS,
|
|
20
|
+
|
|
21
|
+
} from './git.js'
|
|
22
|
+
|
|
23
|
+
import { PROTECTED_BRANCHES, pushUndo, snapshotForUndo, snapshotState } from './undo.js'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async function stage(payload) {
|
|
28
|
+
const cwd = cwdOf(payload)
|
|
29
|
+
const root = await repoRootOf(cwd, payload)
|
|
30
|
+
const paths = optionalPaths(payload, 'paths')
|
|
31
|
+
await git(root, ['add', '-A', '--', ...paths])
|
|
32
|
+
return { staged: paths.length }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function unstage(payload) {
|
|
36
|
+
const cwd = cwdOf(payload)
|
|
37
|
+
const root = await repoRootOf(cwd, payload)
|
|
38
|
+
const paths = optionalPaths(payload, 'paths')
|
|
39
|
+
const result = await runGit(root, ['reset', '-q', 'HEAD', '--', ...paths])
|
|
40
|
+
if (result.code !== 0) await git(root, ['rm', '--cached', '-r', '-q', '--', ...paths])
|
|
41
|
+
return { unstaged: paths.length }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function discard(payload) {
|
|
45
|
+
const cwd = cwdOf(payload)
|
|
46
|
+
const root = await repoRootOf(cwd, payload)
|
|
47
|
+
// '.' would discard EVERYTHING in the repository in one go; the panel always
|
|
48
|
+
// discards explicit paths, so the root itself is not a valid target here.
|
|
49
|
+
const paths = optionalPaths(payload, 'paths').filter((entry) => entry !== '.' && entry !== './')
|
|
50
|
+
if (paths.length === 0) throw badRequest('paths is required')
|
|
51
|
+
const untracked = payload.untracked === true
|
|
52
|
+
if (untracked && payload.confirm !== true) throw badRequest('discarding untracked files requires confirm: true')
|
|
53
|
+
// Last chance to keep the bytes: the checkout/clean below is what destroys them.
|
|
54
|
+
const files = snapshotForUndo(root, paths)
|
|
55
|
+
if (untracked) await git(root, ['clean', '-f', '-d', '--', ...paths])
|
|
56
|
+
else await git(root, ['checkout', '--', ...paths])
|
|
57
|
+
// A snapshot the host cannot reproduce faithfully means no handle at all — the
|
|
58
|
+
// panel says so instead of showing an undo that would fail later.
|
|
59
|
+
if (files === null) return { discarded: paths.length, untracked: untracked, undoBlocked: true }
|
|
60
|
+
const label = paths.length === 1 ? paths[0] : paths.length + ' paths'
|
|
61
|
+
const undo = pushUndo(root, { kind: 'discard', label: label, files: files, after: snapshotState(root, files) })
|
|
62
|
+
return { discarded: paths.length, untracked: untracked, undo: undo }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function commit(payload) {
|
|
66
|
+
const cwd = cwdOf(payload)
|
|
67
|
+
const root = await repoRootOf(cwd, payload)
|
|
68
|
+
const message = requireMessage(payload.message)
|
|
69
|
+
const paths = (payload.paths === undefined || payload.paths === null) ? [] : optionalPaths(payload, 'paths')
|
|
70
|
+
if (paths.length > 0) await git(root, ['add', '-A', '--', ...paths])
|
|
71
|
+
const args = ['commit', '-m', message]
|
|
72
|
+
if (payload.amend === true) args.push('--amend')
|
|
73
|
+
if (payload.signoff === true) args.push('--signoff')
|
|
74
|
+
const out = await git(root, args)
|
|
75
|
+
const result = { output: out.trim() }
|
|
76
|
+
if (payload.push === true) {
|
|
77
|
+
// Commit-and-push is one gesture in the panel, so the commit is published in
|
|
78
|
+
// the same request. A failed push must not hide the commit that did land.
|
|
79
|
+
if (payload.confirm !== true) throw badRequest('pushing requires confirm: true')
|
|
80
|
+
const pushed = await runGit(root, ['push'])
|
|
81
|
+
result.pushed = pushed.code === 0
|
|
82
|
+
if (pushed.code !== 0) result.pushError = (pushed.stderr.trim() || pushed.stdout.trim() || 'push failed')
|
|
83
|
+
}
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function checkout(payload) {
|
|
88
|
+
const cwd = cwdOf(payload)
|
|
89
|
+
const root = await repoRootOf(cwd, payload)
|
|
90
|
+
if (payload.create === true) {
|
|
91
|
+
const name = requireRef(payload.branch, 'branch')
|
|
92
|
+
const start = typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '' ? requireRef(payload.startPoint, 'startPoint') : undefined
|
|
93
|
+
const args = ['checkout', '-b', name]
|
|
94
|
+
/* track (issue #18): "create a local branch from a remote one" must bind
|
|
95
|
+
the upstream in the same breath — checkout -b alone never does. */
|
|
96
|
+
if (payload.track === true && start !== undefined) args.push('--track')
|
|
97
|
+
if (start !== undefined) args.push(start)
|
|
98
|
+
await git(root, args)
|
|
99
|
+
return { branch: name, created: true }
|
|
100
|
+
}
|
|
101
|
+
const target = requireRef(payload.branch, 'branch')
|
|
102
|
+
await git(root, ['checkout', target])
|
|
103
|
+
return { branch: target, created: false }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function branchCreate(payload) {
|
|
107
|
+
const cwd = cwdOf(payload)
|
|
108
|
+
const root = await repoRootOf(cwd, payload)
|
|
109
|
+
const name = requireRef(payload.name, 'name')
|
|
110
|
+
const args = ['branch', name]
|
|
111
|
+
if (typeof payload.startPoint === 'string' && payload.startPoint.trim() !== '') args.push(requireRef(payload.startPoint, 'startPoint'))
|
|
112
|
+
await git(root, args)
|
|
113
|
+
return { branch: name }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* ---------- worktrees (v0.22, alpha.2) ----------
|
|
117
|
+
A worktree is a second checkout of the same repository. These three verbs
|
|
118
|
+
are the whole lifecycle the panel offers: create a checkout (on a new or an
|
|
119
|
+
existing branch), remove one, and prune the admin records of directories
|
|
120
|
+
that are already gone.
|
|
121
|
+
|
|
122
|
+
Removal is deliberately strict. The current and primary checkouts are
|
|
123
|
+
refused outright (removing the tree the panel is reading is never what a
|
|
124
|
+
click meant), a locked worktree must be unlocked first, and a dirty one
|
|
125
|
+
needs a second request with force:true. The dirty check runs INSIDE the
|
|
126
|
+
target, not the repository root: git status at the root says nothing
|
|
127
|
+
about the linked checkout's files. */
|
|
128
|
+
async function worktreeAdd(payload) {
|
|
129
|
+
const cwd = cwdOf(payload)
|
|
130
|
+
const root = await repoRootOf(cwd, payload)
|
|
131
|
+
const target = requireAbsolute(payload.path, 'path')
|
|
132
|
+
if (sameDirectory(target, root)) throw badRequest('path must differ from the current worktree')
|
|
133
|
+
if (existsSync(target)) throw new PanelError('worktree-exists', 'path already exists: ' + target, 409)
|
|
134
|
+
const branch = requireRef(payload.branch, 'branch')
|
|
135
|
+
const checked = await runGit(root, ['check-ref-format', '--branch', branch])
|
|
136
|
+
if (checked.code !== 0) throw badRequest('invalid branch name: ' + branch)
|
|
137
|
+
const createBranch = payload.createBranch !== false
|
|
138
|
+
const args = ['worktree', 'add']
|
|
139
|
+
if (createBranch === true) {
|
|
140
|
+
args.push('-b', branch, target)
|
|
141
|
+
if (typeof payload.base === 'string' && payload.base.trim() !== '') args.push(requireRef(payload.base, 'base'))
|
|
142
|
+
} else {
|
|
143
|
+
args.push(target, branch)
|
|
144
|
+
}
|
|
145
|
+
await git(root, args)
|
|
146
|
+
return { path: target, branch: branch, created: createBranch }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function worktreeRemove(payload) {
|
|
150
|
+
const cwd = cwdOf(payload)
|
|
151
|
+
const root = await repoRootOf(cwd, payload)
|
|
152
|
+
if (payload.confirm !== true) throw badRequest('removing a worktree requires confirm: true')
|
|
153
|
+
const target = requireAbsolute(payload.path, 'path')
|
|
154
|
+
const trees = await worktreesOf(root)
|
|
155
|
+
const tree = trees.find((entry) => sameDirectory(entry.path, target))
|
|
156
|
+
if (tree === undefined) throw new PanelError('worktree-missing', 'not a worktree of this repository: ' + target, 409)
|
|
157
|
+
if (tree.current === true) throw new PanelError('protected-worktree', 'refusing to remove the worktree this panel is showing', 409)
|
|
158
|
+
if (tree.primary === true) throw new PanelError('protected-worktree', 'refusing to remove the primary worktree', 409)
|
|
159
|
+
if (tree.locked === true) throw new PanelError('worktree-locked', 'worktree is locked; unlock it before removing: ' + target, 409)
|
|
160
|
+
if (tree.prunable === true || existsSync(target) === false) throw new PanelError('worktree-missing', 'worktree directory is gone; prune the record instead: ' + target, 409)
|
|
161
|
+
const dirty = await runGit(target, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])
|
|
162
|
+
if (dirty.code !== 0) throw new PanelError('worktree-unreadable', 'could not inspect the worktree: ' + target, 409)
|
|
163
|
+
const hasChanges = dirty.stdout !== ''
|
|
164
|
+
if (hasChanges === true && payload.force !== true) throw new PanelError('worktree-dirty', 'worktree has uncommitted changes; force removal deletes them: ' + target, 409)
|
|
165
|
+
const args = ['worktree', 'remove']
|
|
166
|
+
if (payload.force === true) args.push('--force')
|
|
167
|
+
args.push(target)
|
|
168
|
+
await git(root, args)
|
|
169
|
+
return { path: target, forced: payload.force === true, dirty: hasChanges }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function worktreePrune(payload) {
|
|
173
|
+
const cwd = cwdOf(payload)
|
|
174
|
+
const root = await repoRootOf(cwd, payload)
|
|
175
|
+
if (payload.confirm !== true) throw badRequest('pruning worktrees requires confirm: true')
|
|
176
|
+
const output = await git(root, ['worktree', 'prune', '-v'])
|
|
177
|
+
return { output: output.trim() }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* Upstream management (issue #18): rebind or drop a branch's tracking remote.
|
|
181
|
+
git refuses --set-upstream-to with a ref it cannot resolve, so a typo dies
|
|
182
|
+
here instead of silently writing branch.<name>.merge. */
|
|
183
|
+
async function branchUpstream(payload) {
|
|
184
|
+
const cwd = cwdOf(payload)
|
|
185
|
+
const root = await repoRootOf(cwd, payload)
|
|
186
|
+
const branch = requireRef(payload.branch, 'branch')
|
|
187
|
+
if (payload.unset === true) {
|
|
188
|
+
/* --unset-upstream errors on a branch that has none; the client only
|
|
189
|
+
offers the verb when the row shows an upstream, and git's own message
|
|
190
|
+
is the right one to surface if they raced. */
|
|
191
|
+
await git(root, ['branch', '--unset-upstream', branch])
|
|
192
|
+
return { branch, upstream: null }
|
|
193
|
+
}
|
|
194
|
+
const upstream = requireRef(payload.upstream, 'upstream')
|
|
195
|
+
await git(root, ['branch', '--set-upstream-to=' + upstream, branch])
|
|
196
|
+
return { branch, upstream }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function branchRename(payload) {
|
|
200
|
+
const cwd = cwdOf(payload)
|
|
201
|
+
const root = await repoRootOf(cwd, payload)
|
|
202
|
+
const from = requireRef(payload.from, 'from')
|
|
203
|
+
const to = requireRef(payload.to, 'to')
|
|
204
|
+
await git(root, ['branch', '-m', from, to])
|
|
205
|
+
return { from, to }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function branchDelete(payload) {
|
|
209
|
+
const cwd = cwdOf(payload)
|
|
210
|
+
const root = await repoRootOf(cwd, payload)
|
|
211
|
+
const force = payload.force === true
|
|
212
|
+
if (force && payload.confirm !== true) throw badRequest('force delete requires confirm: true')
|
|
213
|
+
/* Batch form (multi-select): `names` wins when present; the single `name`
|
|
214
|
+
keeps working exactly as before. Every name goes through the same ref
|
|
215
|
+
guard — an array never bypasses validation. */
|
|
216
|
+
const names = Array.isArray(payload.names)
|
|
217
|
+
? payload.names.map((entry) => requireRef(entry, 'name'))
|
|
218
|
+
: [requireRef(payload.name, 'name')]
|
|
219
|
+
const current = await currentBranchOf(root)
|
|
220
|
+
for (const name of names) {
|
|
221
|
+
if (name === current) throw new PanelError('protected-branch', 'refusing to delete the checked-out branch "' + name + '"', 409)
|
|
222
|
+
if (PROTECTED_BRANCHES.has(name) && payload.confirm !== true) throw badRequest('deleting "' + name + '" requires confirm: true')
|
|
223
|
+
}
|
|
224
|
+
const branches = []
|
|
225
|
+
for (const name of names) {
|
|
226
|
+
const head = (await git(root, ['rev-parse', '--verify', 'refs/heads/' + name])).trim()
|
|
227
|
+
branches.push({ name: name, hash: head })
|
|
228
|
+
}
|
|
229
|
+
// -d (the default) refuses to drop an unmerged branch: git is the guard here.
|
|
230
|
+
await git(root, ['branch', force ? '-D' : '-d', ...names])
|
|
231
|
+
const label = names.length === 1 ? names[0] : names.length + ' branches'
|
|
232
|
+
const undo = pushUndo(root, {
|
|
233
|
+
kind: 'branch-delete', label: label,
|
|
234
|
+
branches: branches,
|
|
235
|
+
// Legacy single-branch fields stay so an in-flight old handle keeps working.
|
|
236
|
+
name: names[0], hash: branches[0].hash,
|
|
237
|
+
})
|
|
238
|
+
return { branch: names[0], hash: branches[0].hash, names: names, branches: branches, force: force, undo: undo }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function merge(payload) {
|
|
242
|
+
const cwd = cwdOf(payload)
|
|
243
|
+
const root = await repoRootOf(cwd, payload)
|
|
244
|
+
const branch = requireRef(payload.branch, 'branch')
|
|
245
|
+
const args = ['merge', '--no-edit']
|
|
246
|
+
if (payload.ffOnly === true) args.push('--ff-only')
|
|
247
|
+
if (payload.noFf === true) args.push('--no-ff')
|
|
248
|
+
if (payload.squash === true) args.push('--squash')
|
|
249
|
+
args.push(branch)
|
|
250
|
+
const result = await runGit(root, args)
|
|
251
|
+
const combined = (result.stdout + '\n' + result.stderr).trim()
|
|
252
|
+
const conflicted = /CONFLICT|Automatic merge failed/.test(combined)
|
|
253
|
+
if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'merge failed' : combined, 409)
|
|
254
|
+
return { branch, conflicted, output: combined }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function rebase(payload) {
|
|
258
|
+
const cwd = cwdOf(payload)
|
|
259
|
+
const root = await repoRootOf(cwd, payload)
|
|
260
|
+
const onto = requireRef(payload.onto, 'onto')
|
|
261
|
+
const result = await runGit(root, ['rebase', onto])
|
|
262
|
+
const combined = (result.stdout + '\n' + result.stderr).trim()
|
|
263
|
+
const conflicted = /CONFLICT|could not apply/.test(combined)
|
|
264
|
+
if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'rebase failed' : combined, 409)
|
|
265
|
+
return { onto, conflicted, output: combined }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/* VS Code's "Abort Rebase" (SCM ▸ Commit ▸ Abort Rebase). Only meaningful
|
|
269
|
+
while a rebase is in progress — `operationOf` already surfaces that state,
|
|
270
|
+
so the menu greys the entry out; the route still refuses honestly when
|
|
271
|
+
there is nothing to abort. */
|
|
272
|
+
async function rebaseAbort(payload) {
|
|
273
|
+
const cwd = cwdOf(payload)
|
|
274
|
+
const root = await repoRootOf(cwd, payload)
|
|
275
|
+
const result = await runGit(root, ['rebase', '--abort'])
|
|
276
|
+
if (result.code !== 0) {
|
|
277
|
+
const detail = (result.stdout + '\n' + result.stderr).trim()
|
|
278
|
+
throw new PanelError('git-failed', detail === '' ? 'no rebase to abort' : detail, 409)
|
|
279
|
+
}
|
|
280
|
+
return { output: (result.stdout + '\n' + result.stderr).trim() }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/* VS Code's SCM ▸ Remote ▸ Add Remote.... The name is a ref, the URL the same
|
|
284
|
+
grammar `clone` already accepts. */
|
|
285
|
+
async function remoteAdd(payload) {
|
|
286
|
+
const cwd = cwdOf(payload)
|
|
287
|
+
const root = await repoRootOf(cwd, payload)
|
|
288
|
+
const name = requireRef(payload.name, 'name')
|
|
289
|
+
const url = requireUrl(payload.url, 'url')
|
|
290
|
+
await git(root, ['remote', 'add', name, url])
|
|
291
|
+
return { name, url }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function remoteRemove(payload) {
|
|
295
|
+
const cwd = cwdOf(payload)
|
|
296
|
+
const root = await repoRootOf(cwd, payload)
|
|
297
|
+
const name = requireRef(payload.name, 'name')
|
|
298
|
+
if (typeof payload.confirm !== 'boolean' || payload.confirm !== true) throw badRequest('removing a remote requires confirm: true')
|
|
299
|
+
await git(root, ['remote', 'remove', name])
|
|
300
|
+
return { name }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/* SCM ▸ Branch ▸ Delete Remote Branch...: `git push <remote> --delete <branch>`.
|
|
304
|
+
Genuinely destructive on the OTHER side, so it keeps the typed-confirm
|
|
305
|
+
posture of a local protected-branch delete: confirm is mandatory. */
|
|
306
|
+
async function branchDeleteRemote(payload) {
|
|
307
|
+
const cwd = cwdOf(payload)
|
|
308
|
+
const root = await repoRootOf(cwd, payload)
|
|
309
|
+
const remote = requireRef(payload.remote, 'remote')
|
|
310
|
+
/* Batch form (multi-select): `branches` wins; the single `branch` keeps
|
|
311
|
+
working. `git push --delete` accepts several names in one go. */
|
|
312
|
+
const branches = Array.isArray(payload.branches)
|
|
313
|
+
? payload.branches.map((entry) => requireRef(entry, 'branch'))
|
|
314
|
+
: [requireRef(payload.branch, 'branch')]
|
|
315
|
+
if (payload.confirm !== true) throw badRequest('deleting a remote branch requires confirm: true')
|
|
316
|
+
const out = await git(root, ['push', remote, '--delete', ...branches], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
317
|
+
return { remote: remote, branch: branches[0], branches: branches, output: out.trim() }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* SCM ▸ Tags ▸ Delete Remote Tag... / Push Tags. */
|
|
321
|
+
async function tagDeleteRemote(payload) {
|
|
322
|
+
const cwd = cwdOf(payload)
|
|
323
|
+
const root = await repoRootOf(cwd, payload)
|
|
324
|
+
const remote = requireRef(payload.remote, 'remote')
|
|
325
|
+
const tag = requireRef(payload.name, 'name')
|
|
326
|
+
if (payload.confirm !== true) throw badRequest('deleting a remote tag requires confirm: true')
|
|
327
|
+
const out = await git(root, ['push', remote, '--delete', tag], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
328
|
+
return { remote, tag, output: out.trim() }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function pushTags(payload) {
|
|
332
|
+
const cwd = cwdOf(payload)
|
|
333
|
+
const root = await repoRootOf(cwd, payload)
|
|
334
|
+
if (payload.confirm !== true) throw badRequest('pushing tags requires confirm: true')
|
|
335
|
+
const remote = typeof payload.remote === 'string' && payload.remote.trim() !== '' ? requireRef(payload.remote, 'remote') : 'origin'
|
|
336
|
+
const out = await git(root, ['push', remote, '--tags'], { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
337
|
+
return { output: out.trim() }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/* SCM ▸ Stash ▸ Drop All Stashes...: one `git stash clear`. No undo handle is
|
|
341
|
+
possible (the stash reflog is gone with the clear), hence the mandatory
|
|
342
|
+
confirm on top of the dialog. */
|
|
343
|
+
async function stashClear(payload) {
|
|
344
|
+
const cwd = cwdOf(payload)
|
|
345
|
+
const root = await repoRootOf(cwd, payload)
|
|
346
|
+
if (payload.confirm !== true) throw badRequest('dropping all stashes requires confirm: true')
|
|
347
|
+
await git(root, ['stash', 'clear'])
|
|
348
|
+
return { cleared: true }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function cherryPick(payload) {
|
|
352
|
+
const cwd = cwdOf(payload)
|
|
353
|
+
const root = await repoRootOf(cwd, payload)
|
|
354
|
+
/* Batch form (multi-select): `hashes` are applied in the order given — the
|
|
355
|
+
client sends them oldest-first so the sequence replays topologically. The
|
|
356
|
+
single `hash` keeps working. */
|
|
357
|
+
const hashes = Array.isArray(payload.hashes)
|
|
358
|
+
? payload.hashes.map((entry) => requireRef(entry, 'hash'))
|
|
359
|
+
: [requireRef(payload.hash, 'hash')]
|
|
360
|
+
const result = await runGit(root, ['cherry-pick', ...hashes])
|
|
361
|
+
const combined = (result.stdout + '\n' + result.stderr).trim()
|
|
362
|
+
const conflicted = /CONFLICT/.test(combined)
|
|
363
|
+
if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'cherry-pick failed' : combined, 409)
|
|
364
|
+
return { hash: hashes[0], hashes: hashes, conflicted: conflicted, output: combined }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function revert(payload) {
|
|
368
|
+
const cwd = cwdOf(payload)
|
|
369
|
+
const root = await repoRootOf(cwd, payload)
|
|
370
|
+
const hash = requireRef(payload.hash, 'hash')
|
|
371
|
+
const result = await runGit(root, ['revert', '--no-edit', hash])
|
|
372
|
+
const combined = (result.stdout + '\n' + result.stderr).trim()
|
|
373
|
+
const conflicted = /CONFLICT/.test(combined)
|
|
374
|
+
if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'revert failed' : combined, 409)
|
|
375
|
+
return { hash, conflicted, output: combined }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function reset(payload) {
|
|
379
|
+
const cwd = cwdOf(payload)
|
|
380
|
+
const root = await repoRootOf(cwd, payload)
|
|
381
|
+
const hash = requireRef(payload.hash, 'hash')
|
|
382
|
+
const mode = payload.mode === 'soft' || payload.mode === 'hard' ? payload.mode : 'mixed'
|
|
383
|
+
if (mode === 'hard' && payload.confirm !== true) throw badRequest('hard reset requires confirm: true')
|
|
384
|
+
await git(root, ['reset', '--' + mode, hash])
|
|
385
|
+
return { hash, mode }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function fetch(payload) {
|
|
389
|
+
const cwd = cwdOf(payload)
|
|
390
|
+
const root = await repoRootOf(cwd, payload)
|
|
391
|
+
const args = ['fetch']
|
|
392
|
+
if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
|
|
393
|
+
// A refspec (`main:main`) fast-forwards a NON-checked-out branch in place —
|
|
394
|
+
// this is how "pull a branch that is not HEAD" works without switching to
|
|
395
|
+
// it (git pull would merge into HEAD instead). git refuses a non-ff update
|
|
396
|
+
// here, which is exactly the safety we want.
|
|
397
|
+
if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'refspec'))
|
|
398
|
+
if (payload.all === true) args.push('--all')
|
|
399
|
+
// prune/--tags serve the repo-wide fetch; a refspec fetch is deliberately
|
|
400
|
+
// narrow and must not drag every tag over the wire.
|
|
401
|
+
if (payload.branch === undefined && payload.prune !== false) args.push('--prune')
|
|
402
|
+
if (payload.branch === undefined) args.push('--tags')
|
|
403
|
+
const out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
404
|
+
return { output: (out + '\n').trim() }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function pull(payload) {
|
|
408
|
+
const cwd = cwdOf(payload)
|
|
409
|
+
const root = await repoRootOf(cwd, payload)
|
|
410
|
+
/* Merge is the default (v0.24.0): --ff-only is safe but leaves a diverged
|
|
411
|
+
branch permanently unpullable, which is the exact wall users hit. ff-only
|
|
412
|
+
stays available when asked for explicitly. */
|
|
413
|
+
const mode = payload.mode === 'ff-only' || payload.mode === 'rebase' || payload.mode === 'merge' ? payload.mode : 'merge'
|
|
414
|
+
const args = ['pull']
|
|
415
|
+
if (mode === 'ff-only') args.push('--ff-only')
|
|
416
|
+
if (mode === 'rebase') args.push('--rebase')
|
|
417
|
+
if (mode === 'merge') args.push('--no-rebase')
|
|
418
|
+
if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
|
|
419
|
+
if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
|
|
420
|
+
let out
|
|
421
|
+
try {
|
|
422
|
+
out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
423
|
+
} catch (error) {
|
|
424
|
+
throw gitFailure('pull', error)
|
|
425
|
+
}
|
|
426
|
+
return { mode, output: out.trim() }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function push(payload) {
|
|
430
|
+
const cwd = cwdOf(payload)
|
|
431
|
+
const root = await repoRootOf(cwd, payload)
|
|
432
|
+
if (payload.confirm !== true) throw badRequest('push requires confirm: true')
|
|
433
|
+
const args = ['push']
|
|
434
|
+
if (payload.setUpstream === true) args.push('--set-upstream')
|
|
435
|
+
if (payload.force === true) args.push('--force-with-lease')
|
|
436
|
+
if (typeof payload.remote === 'string' && payload.remote.trim() !== '') args.push(requireRef(payload.remote, 'remote'))
|
|
437
|
+
if (typeof payload.branch === 'string' && payload.branch.trim() !== '') args.push(requireRef(payload.branch, 'branch'))
|
|
438
|
+
let out
|
|
439
|
+
try {
|
|
440
|
+
out = await git(root, args, { timeoutMs: NETWORK_TIMEOUT_MS })
|
|
441
|
+
} catch (error) {
|
|
442
|
+
throw gitFailure('push', error)
|
|
443
|
+
}
|
|
444
|
+
return { output: (out + '\n').trim() }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function stashList(payload) {
|
|
448
|
+
const cwd = cwdOf(payload)
|
|
449
|
+
const root = await repoRootOf(cwd, payload)
|
|
450
|
+
const out = await git(root, ['stash', 'list', '--pretty=format:%gd' + FIELD_SEP + '%gs' + FIELD_SEP + '%aI' + RECORD_SEP])
|
|
451
|
+
const stashes = []
|
|
452
|
+
for (const record of out.split(RECORD_SEP)) {
|
|
453
|
+
const trimmed = record.replace(/^\n+/, '')
|
|
454
|
+
if (trimmed.trim() === '') continue
|
|
455
|
+
const parts = trimmed.split(FIELD_SEP)
|
|
456
|
+
stashes.push({ ref: parts[0], subject: parts[1] === undefined ? '' : parts[1], date: parts[2] === undefined ? '' : parts[2] })
|
|
457
|
+
}
|
|
458
|
+
return { stashes }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function stashPush(payload) {
|
|
462
|
+
const cwd = cwdOf(payload)
|
|
463
|
+
const root = await repoRootOf(cwd, payload)
|
|
464
|
+
const args = ['stash', 'push']
|
|
465
|
+
if (payload.includeUntracked === true) args.push('--include-untracked')
|
|
466
|
+
if (payload.staged === true) args.push('--staged')
|
|
467
|
+
if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-m', payload.message)
|
|
468
|
+
const out = await git(root, args)
|
|
469
|
+
return { output: out.trim() }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function stashApply(payload) {
|
|
473
|
+
const cwd = cwdOf(payload)
|
|
474
|
+
const root = await repoRootOf(cwd, payload)
|
|
475
|
+
const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
|
|
476
|
+
const args = ['stash', payload.pop === true ? 'pop' : 'apply', ref]
|
|
477
|
+
const result = await runGit(root, args)
|
|
478
|
+
const combined = (result.stdout + '\n' + result.stderr).trim()
|
|
479
|
+
const conflicted = /CONFLICT/.test(combined)
|
|
480
|
+
if (result.code !== 0 && !conflicted) throw new PanelError('git-failed', combined === '' ? 'stash apply failed' : combined, 409)
|
|
481
|
+
return { ref, conflicted, output: combined }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function stashDrop(payload) {
|
|
485
|
+
const cwd = cwdOf(payload)
|
|
486
|
+
const root = await repoRootOf(cwd, payload)
|
|
487
|
+
const ref = typeof payload.ref === 'string' && payload.ref.trim() !== '' ? requireRef(payload.ref, 'ref') : 'stash@{0}'
|
|
488
|
+
if (payload.confirm !== true) throw badRequest('dropping a stash requires confirm: true')
|
|
489
|
+
const sha = (await git(root, ['rev-parse', '--verify', ref])).trim()
|
|
490
|
+
await git(root, ['stash', 'drop', ref])
|
|
491
|
+
const undo = pushUndo(root, { kind: 'stash-drop', label: ref, hash: sha })
|
|
492
|
+
return { ref, hash: sha, undo: undo }
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
async function tagCreate(payload) {
|
|
496
|
+
const cwd = cwdOf(payload)
|
|
497
|
+
const root = await repoRootOf(cwd, payload)
|
|
498
|
+
const tag = requireRef(payload.name, 'name')
|
|
499
|
+
const args = ['tag']
|
|
500
|
+
if (typeof payload.message === 'string' && payload.message.trim() !== '') args.push('-a', tag, '-m', payload.message)
|
|
501
|
+
else args.push(tag)
|
|
502
|
+
if (typeof payload.hash === 'string' && payload.hash.trim() !== '') args.push(requireRef(payload.hash, 'hash'))
|
|
503
|
+
await git(root, args)
|
|
504
|
+
return { tag }
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function tagDelete(payload) {
|
|
508
|
+
const cwd = cwdOf(payload)
|
|
509
|
+
const root = await repoRootOf(cwd, payload)
|
|
510
|
+
/* Batch form (multi-select): `names` wins; the single `name` keeps working. */
|
|
511
|
+
const names = Array.isArray(payload.names)
|
|
512
|
+
? payload.names.map((entry) => requireRef(entry, 'name'))
|
|
513
|
+
: [requireRef(payload.name, 'name')]
|
|
514
|
+
await git(root, ['tag', '-d', ...names])
|
|
515
|
+
return { tag: names[0], names: names }
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/* VS Code's SCM title "Clone..." — clone a URL into an absolute local path.
|
|
519
|
+
The PARENT of the target must exist; git creates the last component itself
|
|
520
|
+
and refuses a non-empty one, which is the safety net for typos. It writes a
|
|
521
|
+
brand-new tree (never touches an existing repository), but it does write the
|
|
522
|
+
filesystem, so it rides the write queue like everything else that mutates. */
|
|
523
|
+
const CLONE_TIMEOUT_MS = 10 * 60 * 1000
|
|
524
|
+
function requireUrl(value, label) {
|
|
525
|
+
if (typeof value !== 'string' || value.trim() === '') throw badRequest(label + ' is required')
|
|
526
|
+
const text = value.trim()
|
|
527
|
+
if (text.startsWith('-') === true || /[\s\u0000-\u001f]/.test(text) === true || text.length > 2048) {
|
|
528
|
+
throw badRequest(label + ' is not a valid repository URL')
|
|
529
|
+
}
|
|
530
|
+
return text
|
|
531
|
+
}
|
|
532
|
+
async function clone(payload) {
|
|
533
|
+
const url = requireUrl(payload.url, 'url')
|
|
534
|
+
const dir = requireAbsolute(payload.dir, 'dir')
|
|
535
|
+
await git(path.dirname(dir), ['clone', url, dir], { timeoutMs: CLONE_TIMEOUT_MS })
|
|
536
|
+
return { dir }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export {
|
|
540
|
+
|
|
541
|
+
stage, unstage, discard, commit, checkout, branchCreate, worktreeAdd, worktreeRemove,
|
|
542
|
+
|
|
543
|
+
worktreePrune, branchUpstream, branchRename, branchDelete, merge, rebase, rebaseAbort,
|
|
544
|
+
|
|
545
|
+
remoteAdd, remoteRemove, branchDeleteRemote, tagDeleteRemote, pushTags, stashClear,
|
|
546
|
+
|
|
547
|
+
cherryPick, revert, reset, fetch, pull, push, stashList, stashPush, stashApply,
|
|
548
|
+
|
|
549
|
+
stashDrop, tagCreate, tagDelete, clone,
|
|
550
|
+
|
|
551
|
+
}
|