@bridge4dev/runner 0.13.1 → 0.22.1
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/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +402 -4
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +7 -0
- package/dist/self-update.js +28 -1
- package/dist/service-unit.d.ts +48 -1
- package/dist/service-unit.js +109 -4
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/gitops.js
CHANGED
|
@@ -8,6 +8,12 @@ const execFileAsync = promisify(execFile);
|
|
|
8
8
|
const GIT_TIMEOUT_MS = 30_000;
|
|
9
9
|
const MERGE_TIMEOUT_MS = 60_000;
|
|
10
10
|
const DIFF_CAP_BYTES = 200_000;
|
|
11
|
+
/**
|
|
12
|
+
* git's own name for «nothing», and the only thing a repository without a
|
|
13
|
+
* single commit can be diffed against. Constant in every git repository ever
|
|
14
|
+
* made — it is the SHA-1 of the empty tree object.
|
|
15
|
+
*/
|
|
16
|
+
const EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
11
17
|
/**
|
|
12
18
|
* Global git switches every call in this module needs.
|
|
13
19
|
*
|
|
@@ -31,12 +37,85 @@ async function git(cwd, ...args) {
|
|
|
31
37
|
});
|
|
32
38
|
return stdout.replace(/\n$/, '');
|
|
33
39
|
}
|
|
34
|
-
/**
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Pathspec magic, as far as it can still exist.
|
|
42
|
+
*
|
|
43
|
+
* `--literal-pathspecs` is on for every call in this file, which turns `*`,
|
|
44
|
+
* `?`, `[…]` and the `:(glob)` prefix into ordinary characters of a file name.
|
|
45
|
+
* That is why only the LEADING colon is refused here: it is the one form that
|
|
46
|
+
* could still be read as magic if the flag were ever dropped, and no path in a
|
|
47
|
+
* `git status` listing begins with one.
|
|
48
|
+
*
|
|
49
|
+
* The wider check this replaces (QA-110 M2) refused every name containing a
|
|
50
|
+
* bracket — which meant a Next.js dynamic route (`app/[slug]/page.tsx`, and
|
|
51
|
+
* there is one in this very repository) could not be staged, discarded or even
|
|
52
|
+
* diffed. A rule that blocks a legal file name is not a security control; it is
|
|
53
|
+
* a feature that does not work on a Tuesday.
|
|
54
|
+
*/
|
|
55
|
+
const PATHSPEC_MAGIC = /^:/;
|
|
56
|
+
/**
|
|
57
|
+
* The stricter rule, kept for HISTORY reads only.
|
|
58
|
+
*
|
|
59
|
+
* Relaxing the working-tree check (above) is safe because those paths are
|
|
60
|
+
* checked against a `git status` listing that named them, and because
|
|
61
|
+
* `--literal-pathspecs` makes the characters ordinary. A history read has
|
|
62
|
+
* neither guard: it hands a path straight to `git show <sha> -- <path>` for a
|
|
63
|
+
* commit the caller chose. There a wildcard has no legitimate use, nobody is
|
|
64
|
+
* staging a Next.js route out of a 2019 commit, and the cost of being wrong is
|
|
65
|
+
* the whole commit printed into a browser (QA-105 MAJOR-1).
|
|
66
|
+
*/
|
|
67
|
+
const HISTORY_PATHSPEC_MAGIC = /[*?[\]]|^:/;
|
|
36
68
|
function shortError(error) {
|
|
37
69
|
const message = error instanceof Error ? error.message : String(error);
|
|
38
70
|
return maskString(message).slice(0, 500);
|
|
39
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Put the workspace checkout back exactly as we found it.
|
|
74
|
+
*
|
|
75
|
+
* `reset --merge` is the polite form and needs a MERGE_HEAD, which
|
|
76
|
+
* `merge-recursive` never writes — so it can decline and leave the failed merge
|
|
77
|
+
* staged. The hard reset behind it is safe only because every caller verifies
|
|
78
|
+
* `status --porcelain` is empty before touching anything: there is nothing of
|
|
79
|
+
* the user's to lose, only our own half-finished merge.
|
|
80
|
+
*/
|
|
81
|
+
async function restoreCleanWorkspace(workspacePath, untrackedBefore) {
|
|
82
|
+
await git(workspacePath, 'reset', '--merge').catch(() => undefined);
|
|
83
|
+
const dirty = await git(workspacePath, 'status', '--porcelain').catch(() => '');
|
|
84
|
+
if (!dirty)
|
|
85
|
+
return;
|
|
86
|
+
// Tracked files go back to HEAD. `reset --hard` only rewrites what git
|
|
87
|
+
// tracks, so nobody's untracked work is at risk from this line.
|
|
88
|
+
await git(workspacePath, 'reset', '--hard', 'HEAD').catch(() => undefined);
|
|
89
|
+
// And then ONLY what this merge itself created.
|
|
90
|
+
//
|
|
91
|
+
// This used to be `git clean -fd`, justified by «every caller verified the
|
|
92
|
+
// tree was clean first, so there is nothing of the user's to lose». That
|
|
93
|
+
// reasoning is a snapshot pretending to be a lock. The project folder is
|
|
94
|
+
// shared — a person and their terminal agents write into it continuously —
|
|
95
|
+
// so between the check and the cleanup new untracked files appear, and
|
|
96
|
+
// `clean -fd` deleted them. On 2026-07-28 it destroyed a session's planning
|
|
97
|
+
// documents on a live project: hundreds of lines that existed nowhere else.
|
|
98
|
+
//
|
|
99
|
+
// A merge can leave untracked files behind (a file the branch adds, dropped
|
|
100
|
+
// in before the conflict), so something has to go. The list captured before
|
|
101
|
+
// the merge is what makes «ours» answerable: delete a path only if it was
|
|
102
|
+
// not there when we started.
|
|
103
|
+
const after = await git(workspacePath, 'status', '--porcelain').catch(() => '');
|
|
104
|
+
for (const path of porcelainPaths(after)) {
|
|
105
|
+
if (untrackedBefore.has(path))
|
|
106
|
+
continue;
|
|
107
|
+
await git(workspacePath, 'clean', '-fd', '--', path).catch(() => undefined);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Paths git does not track right now — the set a failed merge must not touch. */
|
|
111
|
+
async function untrackedPaths(workspacePath) {
|
|
112
|
+
const raw = await git(workspacePath, 'status', '--porcelain').catch(() => '');
|
|
113
|
+
return new Set(porcelainPaths(raw));
|
|
114
|
+
}
|
|
115
|
+
/** `git merge-recursive` reports a conflict by exiting 1, quietly. */
|
|
116
|
+
function exitedWithConflict(error) {
|
|
117
|
+
return error?.code === 1;
|
|
118
|
+
}
|
|
40
119
|
/**
|
|
41
120
|
* True only for a genuine content conflict. `git merge --squash` also exits
|
|
42
121
|
* non-zero for a dirty index, a refused overwrite or a failing hook — those
|
|
@@ -48,12 +127,125 @@ function isMergeConflict(error) {
|
|
|
48
127
|
return /CONFLICT \(|Automatic merge failed|fix conflicts/i.test(output);
|
|
49
128
|
}
|
|
50
129
|
/**
|
|
51
|
-
* The
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
130
|
+
* The seven pairs that mean «git stopped, a human has to choose».
|
|
131
|
+
*
|
|
132
|
+
* Spelled out rather than guessed at with «either column is U», because `DD`
|
|
133
|
+
* and `AA` are conflicts without a single U in them, and treating those as an
|
|
134
|
+
* ordinary staged change would offer a Discard button that git refuses.
|
|
135
|
+
*/
|
|
136
|
+
const CONFLICT_PAIRS = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
|
|
137
|
+
export function isConflictedEntry(entry) {
|
|
138
|
+
return CONFLICT_PAIRS.has(`${entry.index}${entry.worktree}`);
|
|
139
|
+
}
|
|
140
|
+
export function isStagedEntry(entry) {
|
|
141
|
+
return !isConflictedEntry(entry) && entry.index !== ' ' && entry.index !== '?';
|
|
142
|
+
}
|
|
143
|
+
export function isUnstagedEntry(entry) {
|
|
144
|
+
return !isConflictedEntry(entry) && entry.worktree !== ' ';
|
|
145
|
+
}
|
|
146
|
+
/** How many blocking paths travel to the panel; the count is always exact. */
|
|
147
|
+
const WORKSPACE_DIRTY_SAMPLE = 20;
|
|
148
|
+
/**
|
|
149
|
+
* Read the porcelain lines of the project folder into paths.
|
|
150
|
+
*
|
|
151
|
+
* Shared by the status call and by every guard, so «is it dirty» has exactly
|
|
152
|
+
* one answer in this file. Rename entries (`R old -> new`) contribute the new
|
|
153
|
+
* path, matching how the worktree file list already reads them.
|
|
154
|
+
*/
|
|
155
|
+
function porcelainPaths(porcelain) {
|
|
156
|
+
return porcelain
|
|
157
|
+
.split('\n')
|
|
158
|
+
.filter(Boolean)
|
|
159
|
+
.map((line) => {
|
|
160
|
+
const raw = line.slice(3);
|
|
161
|
+
return raw.includes(' -> ') ? (raw.split(' -> ')[1] ?? raw) : raw;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** How many working-tree entries travel to the panel; the count is exact. */
|
|
165
|
+
const MAX_WORKING_ENTRIES = 1_000;
|
|
166
|
+
/**
|
|
167
|
+
* `git status --porcelain=v1 -z`, parsed.
|
|
168
|
+
*
|
|
169
|
+
* NUL-separated and not line-separated on purpose: a file name may legally
|
|
170
|
+
* contain a newline, and with `core.quotePath=false` — which we set globally so
|
|
171
|
+
* Cyrillic paths stay readable — git no longer escapes it. Splitting that output
|
|
172
|
+
* on `\n` invents two entries out of one file, and the second one is a path that
|
|
173
|
+
* exists nowhere. `-z` is the only format that cannot be misread.
|
|
174
|
+
*
|
|
175
|
+
* A rename or a copy carries its ORIGINAL path as its own NUL-terminated token
|
|
176
|
+
* right after the entry, so the loop consumes two tokens for those.
|
|
177
|
+
*/
|
|
178
|
+
export function parsePorcelainZ(raw) {
|
|
179
|
+
const tokens = raw.split('\0');
|
|
180
|
+
const entries = [];
|
|
181
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
182
|
+
const token = tokens[i];
|
|
183
|
+
// "XY path" — anything shorter is the trailing empty token, not an entry.
|
|
184
|
+
if (!token || token.length < 4)
|
|
185
|
+
continue;
|
|
186
|
+
const index = token.charAt(0);
|
|
187
|
+
const worktree = token.charAt(1);
|
|
188
|
+
const filePath = token.slice(3);
|
|
189
|
+
let origPath;
|
|
190
|
+
if (index === 'R' || index === 'C' || worktree === 'R' || worktree === 'C') {
|
|
191
|
+
i += 1;
|
|
192
|
+
origPath = tokens[i] || undefined;
|
|
193
|
+
}
|
|
194
|
+
if (!filePath)
|
|
195
|
+
continue;
|
|
196
|
+
entries.push({ path: filePath, index, worktree, ...(origPath ? { origPath } : {}) });
|
|
197
|
+
}
|
|
198
|
+
return entries;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The top of the checkout `cwd` belongs to.
|
|
202
|
+
*
|
|
203
|
+
* Every path in this file's source-control half is repo-root-relative, because
|
|
204
|
+
* that is what `git status --porcelain` prints and therefore what the panel
|
|
205
|
+
* sends back. Handing those to a `git add` that runs one directory deeper would
|
|
206
|
+
* stage the wrong files — or nothing at all — so all of them stand at the root.
|
|
207
|
+
*/
|
|
208
|
+
async function repoRoot(cwd) {
|
|
209
|
+
const top = await git(cwd, 'rev-parse', '--show-toplevel').catch(() => null);
|
|
210
|
+
return top || cwd;
|
|
211
|
+
}
|
|
212
|
+
/** Is a merge (or a pull that became one) waiting for a human right now? */
|
|
213
|
+
async function hasMergeInProgress(root) {
|
|
214
|
+
const gitDir = await git(root, 'rev-parse', '--absolute-git-dir').catch(() => null);
|
|
215
|
+
if (!gitDir)
|
|
216
|
+
return false;
|
|
217
|
+
return fs.existsSync(path.join(gitDir, 'MERGE_HEAD'));
|
|
218
|
+
}
|
|
219
|
+
/** The paths git is waiting on, by name. */
|
|
220
|
+
async function conflictedPaths(root) {
|
|
221
|
+
const raw = await git(root, 'diff', '--name-only', '--diff-filter=U', '-z').catch(() => '');
|
|
222
|
+
return raw.split('\0').filter(Boolean).slice(0, MAX_WORKING_ENTRIES);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The base the session forked from — and will be applied onto.
|
|
226
|
+
*
|
|
227
|
+
* Session 13 made this a RECORDED decision. Before, it was recomputed on every
|
|
228
|
+
* call as «whatever branch the project folder happens to be on», so checking a
|
|
229
|
+
* different branch out on the server silently moved the target of «Apply» for
|
|
230
|
+
* every session of that project. When the API supplies the base it was pinned
|
|
231
|
+
* with, that wins; the old guess stays as the fallback for sessions created
|
|
232
|
+
* before the column existed.
|
|
233
|
+
*
|
|
234
|
+
* A pinned base that no longer exists is reported rather than silently swapped:
|
|
235
|
+
* the caller has to be able to say «the branch you forked from is gone».
|
|
55
236
|
*/
|
|
56
|
-
async function resolveBase(workspacePath, sessionBranch) {
|
|
237
|
+
async function resolveBase(workspacePath, sessionBranch, pinnedBase) {
|
|
238
|
+
const safePinned = pinnedBase ? sanitizeBranch(pinnedBase) : null;
|
|
239
|
+
if (safePinned && safePinned !== sessionBranch) {
|
|
240
|
+
if (await branchExists(workspacePath, safePinned)) {
|
|
241
|
+
return { baseBranch: safePinned, baseRef: safePinned, pinned: true, baseMissing: false };
|
|
242
|
+
}
|
|
243
|
+
const guessed = await guessBase(workspacePath, sessionBranch);
|
|
244
|
+
return { ...guessed, pinned: false, baseMissing: true };
|
|
245
|
+
}
|
|
246
|
+
return { ...(await guessBase(workspacePath, sessionBranch)), pinned: false, baseMissing: false };
|
|
247
|
+
}
|
|
248
|
+
async function guessBase(workspacePath, sessionBranch) {
|
|
57
249
|
const current = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
58
250
|
if (current !== sessionBranch && current !== 'HEAD') {
|
|
59
251
|
return { baseBranch: current, baseRef: current };
|
|
@@ -67,20 +259,130 @@ async function resolveBase(workspacePath, sessionBranch) {
|
|
|
67
259
|
}
|
|
68
260
|
throw new Error(`Cannot determine the base branch (workspace is on ${current})`);
|
|
69
261
|
}
|
|
262
|
+
/** How far apart two refs are, in one call: `[behind, ahead]`. */
|
|
263
|
+
async function aheadBehind(cwd, baseRef, branchRef) {
|
|
264
|
+
const raw = await git(cwd, 'rev-list', '--left-right', '--count', `${baseRef}...${branchRef}`).catch(() => null);
|
|
265
|
+
if (raw === null)
|
|
266
|
+
return null;
|
|
267
|
+
const [behind, ahead] = raw.split(/\s+/).map((value) => Number(value));
|
|
268
|
+
if (!Number.isFinite(ahead) || !Number.isFinite(behind))
|
|
269
|
+
return null;
|
|
270
|
+
return { ahead: ahead ?? 0, behind: behind ?? 0 };
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Commits on `branchRef` that are not reachable from `sinceSha` — and, when
|
|
274
|
+
* `excludeRef` is given, not reachable from the base either.
|
|
275
|
+
*
|
|
276
|
+
* Both options exist because of «Update from base» (QA-107). It merges the base
|
|
277
|
+
* INTO the session branch, so every commit the base contributed is newer than
|
|
278
|
+
* the last apply's `sinceSha`: without `excludeRef` the panel announced «12
|
|
279
|
+
* commits not in main» about main's own commits, and without `noMerges` it
|
|
280
|
+
* announced «1 commit» about the merge commit that brought them — bookkeeping,
|
|
281
|
+
* not work.
|
|
282
|
+
*/
|
|
283
|
+
async function countSince(cwd, sinceSha, branchRef, options = {}) {
|
|
284
|
+
if (!isCommitSha(sinceSha))
|
|
285
|
+
return null;
|
|
286
|
+
const raw = await git(cwd, 'rev-list', '--count', ...(options.noMerges ? ['--no-merges'] : []), `${sinceSha}..${branchRef}`, ...(options.excludeRef ? ['--not', options.excludeRef] : [])).catch(() => null);
|
|
287
|
+
if (raw === null)
|
|
288
|
+
return null;
|
|
289
|
+
const count = Number(raw);
|
|
290
|
+
return Number.isFinite(count) ? count : null;
|
|
291
|
+
}
|
|
70
292
|
/** Merge-base of the session worktree against the workspace's base branch. */
|
|
71
293
|
async function mergeBase(worktreePath, baseRef) {
|
|
72
294
|
return git(worktreePath, 'merge-base', 'HEAD', baseRef);
|
|
73
295
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
296
|
+
/**
|
|
297
|
+
* Statuses currently being computed, keyed by the question being asked (#114).
|
|
298
|
+
*
|
|
299
|
+
* `git_status` is not one command — it fans out to roughly fifteen git
|
|
300
|
+
* processes, two of them full working-tree scans. While the agent is building,
|
|
301
|
+
* that can take longer than the dashboard's polling period, and every poll that
|
|
302
|
+
* arrives meanwhile used to start the whole fan-out AGAIN on a machine that was
|
|
303
|
+
* already the reason the first one was slow. The pile-up was self-sustaining:
|
|
304
|
+
* more polls made it slower, and slower made more polls overlap.
|
|
305
|
+
*
|
|
306
|
+
* So overlapping callers SHARE the answer that is already on its way. No cache
|
|
307
|
+
* and no TTL — the entry lives exactly as long as the in-flight promise, so a
|
|
308
|
+
* caller can never be handed a status that was true a moment ago rather than
|
|
309
|
+
* now.
|
|
310
|
+
*/
|
|
311
|
+
const statusInFlight = new Map();
|
|
312
|
+
/**
|
|
313
|
+
* How many times each working tree has been WRITTEN to by this runner.
|
|
314
|
+
*
|
|
315
|
+
* A shared promise answers about the tree as it was when the scan STARTED, not
|
|
316
|
+
* when it was joined — so a read issued after a write could join a scan that
|
|
317
|
+
* began before it and report the pre-write world (QA-111 M2). Every Source
|
|
318
|
+
* Control action invalidates and refetches immediately, which is exactly that
|
|
319
|
+
* shape: press `+`, and the file does not move to Staged until the next poll.
|
|
320
|
+
*
|
|
321
|
+
* So a write bumps the generation, and the generation is part of the key. Reads
|
|
322
|
+
* issued after it cannot share a scan started before it. Bumped AFTER the write
|
|
323
|
+
* finishes, deliberately: a read that arrives mid-write has nothing newer to be
|
|
324
|
+
* given, and starting a second scan against a tree being modified only spends
|
|
325
|
+
* the machine we are trying to spare.
|
|
326
|
+
*/
|
|
327
|
+
const treeGeneration = new Map();
|
|
328
|
+
/** Record that this working tree has changed under us. */
|
|
329
|
+
function bumpTreeGeneration(worktreePath) {
|
|
330
|
+
treeGeneration.set(worktreePath, (treeGeneration.get(worktreePath) ?? 0) + 1);
|
|
331
|
+
}
|
|
332
|
+
export function gitStatus(input) {
|
|
333
|
+
// Everything that changes the ANSWER, plus the generation of each tree read.
|
|
334
|
+
//
|
|
335
|
+
// `appliedBranchSha` / `pushedSha` stay in the key even though they only feed
|
|
336
|
+
// two derived counts (`newCommits`, `aheadOfPushed`) computed at the very
|
|
337
|
+
// end. Dropping them would let callers that pass different journal facts
|
|
338
|
+
// share one result — and one of them would be handed the other's numbers.
|
|
339
|
+
// The cost is that `applyCommitMessage` and `withVerifiedTrailer`, which ask
|
|
340
|
+
// without them, do not share with the panel's poll (QA-111 m5): a real but
|
|
341
|
+
// occasional extra fan-out, taken knowingly over a wrong count.
|
|
342
|
+
const key = JSON.stringify([
|
|
343
|
+
input.worktreePath,
|
|
344
|
+
input.workspacePath,
|
|
345
|
+
input.sessionBranch,
|
|
346
|
+
input.baseBranch ?? '',
|
|
347
|
+
input.appliedBranchSha ?? '',
|
|
348
|
+
input.pushedSha ?? '',
|
|
349
|
+
input.direct === true,
|
|
350
|
+
treeGeneration.get(input.worktreePath) ?? 0,
|
|
351
|
+
treeGeneration.get(input.workspacePath) ?? 0,
|
|
352
|
+
]);
|
|
353
|
+
const running = statusInFlight.get(key);
|
|
354
|
+
if (running)
|
|
355
|
+
return running;
|
|
356
|
+
const started = computeGitStatus(input).finally(() => {
|
|
357
|
+
// Only ever delete our own entry: a slow status settling after a newer one
|
|
358
|
+
// took its place must not evict the newer one.
|
|
359
|
+
if (statusInFlight.get(key) === started)
|
|
360
|
+
statusInFlight.delete(key);
|
|
361
|
+
});
|
|
362
|
+
statusInFlight.set(key, started);
|
|
363
|
+
return started;
|
|
364
|
+
}
|
|
365
|
+
async function computeGitStatus(input) {
|
|
366
|
+
const { worktreePath, workspacePath, sessionBranch } = input;
|
|
367
|
+
const resolved = input.direct
|
|
368
|
+
? { baseBranch: sessionBranch, baseRef: 'HEAD', pinned: false, baseMissing: false }
|
|
369
|
+
: await resolveBase(workspacePath, sessionBranch, input.baseBranch);
|
|
370
|
+
const { baseBranch, baseRef, pinned, baseMissing } = resolved;
|
|
371
|
+
const base = input.direct
|
|
372
|
+
? // A repository with no commits yet has no HEAD to diff against. The empty
|
|
373
|
+
// tree is what git itself compares a first commit to, and it keeps the
|
|
374
|
+
// whole call working instead of failing the panel on a fresh `git init`.
|
|
375
|
+
await git(worktreePath, 'rev-parse', 'HEAD').catch(() => EMPTY_TREE_SHA)
|
|
376
|
+
: await mergeBase(worktreePath, baseRef);
|
|
77
377
|
// One diff of the working tree against the merge-base covers both committed
|
|
78
378
|
// and uncommitted changes — exactly what «Применить» would bring to main.
|
|
379
|
+
// Each piece degrades on its own: an unborn HEAD makes the commit count
|
|
380
|
+
// impossible without making the file list impossible.
|
|
79
381
|
const [numstatRaw, nameStatusRaw, porcelainRaw, commitCountRaw] = await Promise.all([
|
|
80
|
-
git(worktreePath, 'diff', '--numstat', '-M', base),
|
|
81
|
-
git(worktreePath, 'diff', '--name-status', '-M', base),
|
|
382
|
+
git(worktreePath, 'diff', '--numstat', '-M', base).catch(() => ''),
|
|
383
|
+
git(worktreePath, 'diff', '--name-status', '-M', base).catch(() => ''),
|
|
82
384
|
git(worktreePath, 'status', '--porcelain'),
|
|
83
|
-
git(worktreePath, 'rev-list', '--count', `${base}..HEAD`),
|
|
385
|
+
git(worktreePath, 'rev-list', '--count', `${base}..HEAD`).catch(() => '0'),
|
|
84
386
|
]);
|
|
85
387
|
const numstat = new Map();
|
|
86
388
|
for (const line of numstatRaw.split('\n').filter(Boolean)) {
|
|
@@ -126,14 +428,108 @@ export async function gitStatus(worktreePath, workspacePath, sessionBranch) {
|
|
|
126
428
|
}
|
|
127
429
|
}
|
|
128
430
|
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
431
|
+
// In DIRECT mode the session works IN the project folder, so the two paths
|
|
432
|
+
// are the same directory and half the questions below are the same question
|
|
433
|
+
// asked twice — including a full working-tree scan, which is the expensive
|
|
434
|
+
// one. Reusing the answer roughly halves the cost of a status on exactly the
|
|
435
|
+
// sessions where it was slow enough to time out (#114).
|
|
436
|
+
const sameTree = worktreePath === workspacePath;
|
|
437
|
+
// Everything below is read-only bookkeeping for the Git panel. Each piece
|
|
438
|
+
// degrades to null on its own rather than failing the whole status: a repo
|
|
439
|
+
// with a missing base still has to render its file list.
|
|
440
|
+
const [branchSha, baseSha, worktreeBranch, distance, workspacePorcelain, workingRaw, upstreamRaw, remotesRaw, mergeInProgress, workspaceMergeInProgressRaw,] = await Promise.all([
|
|
441
|
+
git(worktreePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
442
|
+
git(workspacePath, 'rev-parse', baseRef).catch(() => null),
|
|
443
|
+
git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
|
|
444
|
+
input.direct ? Promise.resolve(null) : aheadBehind(worktreePath, baseRef, 'HEAD'),
|
|
445
|
+
// The apply guard, asked in advance — and already answered above whenever
|
|
446
|
+
// the project folder IS the working tree.
|
|
447
|
+
sameTree
|
|
448
|
+
? Promise.resolve(porcelainRaw)
|
|
449
|
+
: git(workspacePath, 'status', '--porcelain').catch(() => null),
|
|
450
|
+
// Session 16: the Source Control view. `-uall` so a new folder lists its
|
|
451
|
+
// files rather than collapsing to `dir/` — the panel stages one path at a
|
|
452
|
+
// time and a directory entry is not a path anybody can act on.
|
|
453
|
+
git(worktreePath, 'status', '--porcelain=v1', '-z', '--untracked-files=all')
|
|
454
|
+
// A repository with tens of thousands of new files blows the 8MB buffer.
|
|
455
|
+
// The collapsed form still answers «is there anything here», which is
|
|
456
|
+
// better than a panel that cannot render at all.
|
|
457
|
+
.catch(() => git(worktreePath, 'status', '--porcelain=v1', '-z').catch(() => '')),
|
|
458
|
+
git(worktreePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
|
|
459
|
+
git(worktreePath, 'remote').catch(() => ''),
|
|
460
|
+
hasMergeInProgress(worktreePath),
|
|
461
|
+
// The project folder's own stopped merge. Different question from the one
|
|
462
|
+
// above whenever they are different folders — and the answer changes the
|
|
463
|
+
// advice a BRANCH session gets: you cannot «just commit» a conflicted tree
|
|
464
|
+
// (QA-110 m7). When they are the same folder it is the same answer.
|
|
465
|
+
sameTree ? Promise.resolve(false) : hasMergeInProgress(workspacePath),
|
|
466
|
+
]);
|
|
467
|
+
const workspaceMergeInProgress = sameTree ? mergeInProgress : workspaceMergeInProgressRaw;
|
|
468
|
+
const workspaceDirtyPaths = porcelainPaths(workspacePorcelain ?? '');
|
|
469
|
+
// In DIRECT mode the branch is whatever the folder is on RIGHT NOW, not the
|
|
470
|
+
// name recorded when the session started. Somebody checking out another
|
|
471
|
+
// branch mid-session is a thing that happens — it is their folder — and the
|
|
472
|
+
// panel has to name the branch its Commit button would actually write to.
|
|
473
|
+
const branch = input.direct ? (worktreeBranch ?? sessionBranch) : sessionBranch;
|
|
474
|
+
const working = parsePorcelainZ(workingRaw);
|
|
475
|
+
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
476
|
+
// Only worth a round trip when there IS an upstream — and it is read against
|
|
477
|
+
// the last fetch, so `behind` is «what the last Pull would have brought»,
|
|
478
|
+
// not «what the remote has right now». Nothing here talks to the network.
|
|
479
|
+
const upstreamDistance = upstream ? await aheadBehind(worktreePath, upstream, 'HEAD') : null;
|
|
480
|
+
// `null` means «we cannot know», and that is the honest answer whenever the
|
|
481
|
+
// caller did not tell us where the last apply stopped — a session applied
|
|
482
|
+
// before session 13, or applied through a runner too old to report the tip.
|
|
483
|
+
// Falling back to `ahead` was worse than saying nothing: a squash merge never
|
|
484
|
+
// makes the branch an ancestor of the base, so that number never drops to
|
|
485
|
+
// zero and the panel announced «4 commits not in main» about work that was
|
|
486
|
+
// already there (QA-107).
|
|
487
|
+
const newCommits = input.appliedBranchSha
|
|
488
|
+
? await countSince(worktreePath, input.appliedBranchSha, 'HEAD', {
|
|
489
|
+
excludeRef: baseRef,
|
|
490
|
+
noMerges: true,
|
|
491
|
+
})
|
|
492
|
+
: null;
|
|
493
|
+
// Push sends everything, merges included — so this one counts them.
|
|
494
|
+
const aheadOfPushed = input.pushedSha
|
|
495
|
+
? await countSince(worktreePath, input.pushedSha, 'HEAD')
|
|
496
|
+
: null;
|
|
129
497
|
return {
|
|
130
|
-
branch
|
|
131
|
-
baseBranch,
|
|
498
|
+
branch,
|
|
499
|
+
baseBranch: input.direct ? branch : baseBranch,
|
|
132
500
|
files,
|
|
133
501
|
additions: files.reduce((sum, f) => sum + (f.additions ?? 0), 0),
|
|
134
502
|
deletions: files.reduce((sum, f) => sum + (f.deletions ?? 0), 0),
|
|
135
503
|
agentCommits: Number(commitCountRaw) || 0,
|
|
136
504
|
uncommittedFiles: uncommittedPaths.size,
|
|
505
|
+
branchSha,
|
|
506
|
+
baseSha,
|
|
507
|
+
forkSha: base,
|
|
508
|
+
ahead: distance?.ahead ?? null,
|
|
509
|
+
behind: distance?.behind ?? null,
|
|
510
|
+
newCommits,
|
|
511
|
+
aheadOfPushed,
|
|
512
|
+
worktreeBranch,
|
|
513
|
+
// Drift is only meaningful against a base we were TOLD to expect. Without a
|
|
514
|
+
// pinned base the folder's branch IS the base by definition, so nothing can
|
|
515
|
+
// have drifted (pre-session-13 sessions keep the old behaviour exactly).
|
|
516
|
+
drifted: pinned && worktreeBranch !== null && worktreeBranch !== baseBranch,
|
|
517
|
+
baseMissing,
|
|
518
|
+
workspaceDirty: workspacePorcelain === null ? null : workspaceDirtyPaths.length > 0,
|
|
519
|
+
workspaceMergeInProgress,
|
|
520
|
+
workspaceDirtyFiles: workspaceDirtyPaths.slice(0, WORKSPACE_DIRTY_SAMPLE),
|
|
521
|
+
workspaceDirtyCount: workspaceDirtyPaths.length,
|
|
522
|
+
working: working.slice(0, MAX_WORKING_ENTRIES),
|
|
523
|
+
workingCount: working.length,
|
|
524
|
+
workingTruncated: working.length > MAX_WORKING_ENTRIES,
|
|
525
|
+
stagedCount: working.filter(isStagedEntry).length,
|
|
526
|
+
unstagedCount: working.filter(isUnstagedEntry).length,
|
|
527
|
+
conflictedCount: working.filter(isConflictedEntry).length,
|
|
528
|
+
mergeInProgress,
|
|
529
|
+
upstream,
|
|
530
|
+
aheadUpstream: upstreamDistance?.ahead ?? null,
|
|
531
|
+
behindUpstream: upstreamDistance?.behind ?? null,
|
|
532
|
+
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
137
533
|
};
|
|
138
534
|
}
|
|
139
535
|
/**
|
|
@@ -239,16 +635,32 @@ function capDiff(diff) {
|
|
|
239
635
|
const truncated = masked.length > DIFF_CAP_BYTES;
|
|
240
636
|
return { diff: truncated ? masked.slice(0, DIFF_CAP_BYTES) : masked, truncated };
|
|
241
637
|
}
|
|
242
|
-
export async function gitDiff(worktreePath, workspacePath, sessionBranch, filePath) {
|
|
638
|
+
export async function gitDiff(worktreePath, workspacePath, sessionBranch, filePath, baseBranch, mode = 'base') {
|
|
243
639
|
const relPath = safeDiffPath(worktreePath, filePath);
|
|
244
|
-
|
|
640
|
+
if (mode === 'staged') {
|
|
641
|
+
return capDiff(await git(worktreePath, 'diff', '--cached', '-M', '--', relPath));
|
|
642
|
+
}
|
|
643
|
+
if (mode === 'worktree') {
|
|
644
|
+
const unstaged = await git(worktreePath, 'diff', '-M', '--', relPath);
|
|
645
|
+
if (unstaged)
|
|
646
|
+
return capDiff(unstaged);
|
|
647
|
+
return untrackedDiff(worktreePath, relPath);
|
|
648
|
+
}
|
|
649
|
+
const { baseRef } = await resolveBase(workspacePath, sessionBranch, baseBranch);
|
|
245
650
|
const base = await mergeBase(worktreePath, baseRef);
|
|
246
651
|
const diff = await git(worktreePath, 'diff', '-M', base, '--', relPath);
|
|
247
652
|
if (diff)
|
|
248
653
|
return capDiff(diff);
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
654
|
+
return untrackedDiff(worktreePath, relPath);
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* The content of a file git does not track yet, shown as an all-additions diff.
|
|
658
|
+
*
|
|
659
|
+
* Gated on the status letter and nothing else: this path prints file CONTENT
|
|
660
|
+
* verbatim, so it is allowed only for genuinely UNTRACKED files (`??`) — never
|
|
661
|
+
* for an unchanged tracked file and never for a gitignored one (QA-98 F1).
|
|
662
|
+
*/
|
|
663
|
+
async function untrackedDiff(worktreePath, relPath) {
|
|
252
664
|
const porcelain = await git(worktreePath, 'status', '--porcelain', '--', relPath);
|
|
253
665
|
if (!porcelain.startsWith('??'))
|
|
254
666
|
return { diff: '', truncated: false };
|
|
@@ -257,55 +669,586 @@ export async function gitDiff(worktreePath, workspacePath, sessionBranch, filePa
|
|
|
257
669
|
.catch((error) => error?.code === 1 && typeof error.stdout === 'string' ? error.stdout : '');
|
|
258
670
|
return capDiff(fallback);
|
|
259
671
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
672
|
+
/**
|
|
673
|
+
* The whole diff of the branch against its base — masked, never header-checked.
|
|
674
|
+
*
|
|
675
|
+
* Deliberately separate from `gitDiff`: that one REFUSES a diff containing a
|
|
676
|
+
* protected file, which is right for a panel showing one file at a time and
|
|
677
|
+
* wrong here, where the answer would be «no commit message at all, because the
|
|
678
|
+
* branch happens to touch `.env.example`». The caller strips protected files
|
|
679
|
+
* instead of losing the diff, and says how many it dropped.
|
|
680
|
+
*/
|
|
681
|
+
export async function gitBranchDiff(input) {
|
|
682
|
+
const { baseRef } = await resolveBase(input.workspacePath, input.sessionBranch, input.baseBranch);
|
|
683
|
+
const base = await mergeBase(input.worktreePath, baseRef);
|
|
684
|
+
const raw = await tooLargeAware(git(input.worktreePath, 'diff', '-M', base)).catch(() => '');
|
|
685
|
+
const masked = maskString(raw);
|
|
686
|
+
const cap = input.maxBytes ?? DIFF_CAP_BYTES;
|
|
687
|
+
return masked.length > cap
|
|
688
|
+
? { diff: masked.slice(0, cap), truncated: true }
|
|
689
|
+
: { diff: masked, truncated: false };
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Commit — either the index, or everything, and the caller says which.
|
|
693
|
+
*
|
|
694
|
+
* `all: true` is the historical behaviour and still the default for the agent's
|
|
695
|
+
* own auto-commit: `git add -A` then commit, because an agent has no index to
|
|
696
|
+
* curate. `all: false` is what the Source Control panel sends, and it is the
|
|
697
|
+
* whole point of session 16 — a person who staged three files out of nine gets
|
|
698
|
+
* a commit with three files in it, exactly as `git commit` has always worked.
|
|
699
|
+
*
|
|
700
|
+
* A merge that stopped on a conflict commits with no `-m` message of ours?
|
|
701
|
+
* No: the message is still the user's, and `git commit` finishing a merge is
|
|
702
|
+
* how a merge is finished. The only thing we check is that nothing is still
|
|
703
|
+
* conflicted, because git would refuse anyway and its refusal is unreadable.
|
|
704
|
+
*/
|
|
705
|
+
async function gitCommitImpl(worktreePath, message, options = {}) {
|
|
706
|
+
const all = options.all !== false;
|
|
707
|
+
const root = await repoRoot(worktreePath);
|
|
708
|
+
const porcelain = await git(root, 'status', '--porcelain=v1', '-z', '--untracked-files=all');
|
|
709
|
+
const entries = parsePorcelainZ(porcelain);
|
|
710
|
+
// A merge still needs its commit even when the resolution left the tree
|
|
711
|
+
// identical to HEAD — every conflict resolved «take ours». Without this the
|
|
712
|
+
// panel would answer «nothing to commit» to the one press that finishes the
|
|
713
|
+
// merge, and `MERGE_HEAD` would sit there forever.
|
|
714
|
+
const finishingMerge = await hasMergeInProgress(root);
|
|
715
|
+
if (entries.length === 0 && !finishingMerge) {
|
|
263
716
|
return { committed: false, reason: 'nothing-to-commit' };
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
717
|
+
}
|
|
718
|
+
const conflicted = entries.filter(isConflictedEntry);
|
|
719
|
+
if (conflicted.length > 0) {
|
|
720
|
+
return {
|
|
721
|
+
committed: false,
|
|
722
|
+
reason: `unresolved-conflicts: ${conflicted
|
|
723
|
+
.slice(0, 5)
|
|
724
|
+
.map((entry) => entry.path)
|
|
725
|
+
.join(', ')}`,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
if (all) {
|
|
729
|
+
await git(root, 'add', '-A');
|
|
730
|
+
}
|
|
731
|
+
else if (!entries.some(isStagedEntry) && !finishingMerge) {
|
|
732
|
+
// Nothing staged and we were told not to stage: `git commit` would either
|
|
733
|
+
// fail or, mid-merge, commit an empty change. Say which of the two states
|
|
734
|
+
// this is instead.
|
|
735
|
+
return { committed: false, reason: 'nothing-staged' };
|
|
736
|
+
}
|
|
737
|
+
await git(root, 'commit', '-m', message);
|
|
738
|
+
return { committed: true, sha: await git(root, 'rev-parse', 'HEAD') };
|
|
739
|
+
}
|
|
740
|
+
// ─── Source control (session 16: git is git) ─────────────────────────
|
|
741
|
+
//
|
|
742
|
+
// Stage, unstage, discard, pull — the four verbs the Git panel was missing, and
|
|
743
|
+
// the reason «Set aside»/stash existed at all. A stash was our way of saying
|
|
744
|
+
// «your uncommitted work is in the way and you have no shell here»; with these
|
|
745
|
+
// the work is no longer in anybody's way, because the person can just commit it.
|
|
746
|
+
//
|
|
747
|
+
// Every one of them stands at the repository ROOT and takes paths exactly as
|
|
748
|
+
// `git status --porcelain` printed them, which is exactly what the panel sends
|
|
749
|
+
// back. Every one of them passes those paths after a `--`, with
|
|
750
|
+
// `--literal-pathspecs` on, having first refused anything absolute, anything
|
|
751
|
+
// containing `..`, anything with pathspec magic in it and anything that resolves
|
|
752
|
+
// — symlinks followed — outside the checkout.
|
|
753
|
+
/** A ceiling on one request. The panel selects files, not file systems. */
|
|
754
|
+
const MAX_PATHSPECS = 500;
|
|
755
|
+
/** argv has a length limit; git is called in batches under it. */
|
|
756
|
+
const PATHSPEC_BATCH = 100;
|
|
757
|
+
/**
|
|
758
|
+
* The deepest ancestor of `target` that actually exists, fully resolved.
|
|
759
|
+
*
|
|
760
|
+
* A path being discarded may not exist any more (that is often WHY it is being
|
|
761
|
+
* discarded), so `realpathSync` on it throws — and a check that throws is a
|
|
762
|
+
* check that got skipped. Walking up to something real still catches the case
|
|
763
|
+
* that matters: a symlinked directory pointing out of the repository.
|
|
764
|
+
*/
|
|
765
|
+
function realAncestor(target) {
|
|
766
|
+
let current = target;
|
|
767
|
+
for (let depth = 0; depth < 64; depth += 1) {
|
|
768
|
+
try {
|
|
769
|
+
return fs.realpathSync(current);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
const parent = path.dirname(current);
|
|
773
|
+
if (parent === current)
|
|
774
|
+
break;
|
|
775
|
+
current = parent;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return target;
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Paths from the browser, turned into pathspecs git may be handed.
|
|
782
|
+
*
|
|
783
|
+
* Deliberately NOT filtered by the secret denylist. That list exists to stop a
|
|
784
|
+
* `.env` being READ into a diff and shipped to a browser; staging or discarding
|
|
785
|
+
* one reads nothing and reveals nothing, and refusing it would mean a file the
|
|
786
|
+
* user's own `git status` shows has a row in the panel with every button dead.
|
|
787
|
+
* What git ignores stays ignored — it never appears in the status this list is
|
|
788
|
+
* built from, so nothing here can reach it.
|
|
789
|
+
*/
|
|
790
|
+
function safeRepoPaths(root, raw) {
|
|
791
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
792
|
+
throw new Error('No paths were given');
|
|
793
|
+
}
|
|
794
|
+
if (raw.length > MAX_PATHSPECS) {
|
|
795
|
+
throw new Error(`Too many paths in one request (max ${MAX_PATHSPECS})`);
|
|
796
|
+
}
|
|
797
|
+
const realRoot = realAncestor(root);
|
|
798
|
+
const out = [];
|
|
799
|
+
for (const value of raw) {
|
|
800
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 1_000) {
|
|
801
|
+
throw new Error('Every path must be a non-empty string');
|
|
802
|
+
}
|
|
803
|
+
if (path.isAbsolute(value) || value.split(/[\\/]/).includes('..')) {
|
|
804
|
+
throw new Error(`Path must be relative to the repository: ${value}`);
|
|
805
|
+
}
|
|
806
|
+
if (PATHSPEC_MAGIC.test(value)) {
|
|
807
|
+
throw new Error(`Path must name a file, not a pattern: ${value}`);
|
|
808
|
+
}
|
|
809
|
+
const resolved = path.resolve(root, value);
|
|
810
|
+
const rel = path.relative(root, resolved);
|
|
811
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
812
|
+
throw new Error(`Path escapes the repository: ${value}`);
|
|
813
|
+
}
|
|
814
|
+
if (rel.split(path.sep).includes('.git')) {
|
|
815
|
+
throw new Error('Paths inside .git are not files anybody stages');
|
|
816
|
+
}
|
|
817
|
+
const realRel = path.relative(realRoot, realAncestor(resolved));
|
|
818
|
+
if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
|
|
819
|
+
throw new Error(`Path escapes the repository: ${value}`);
|
|
820
|
+
}
|
|
821
|
+
out.push(rel.split(path.sep).join('/'));
|
|
822
|
+
}
|
|
823
|
+
return out;
|
|
824
|
+
}
|
|
825
|
+
/** Run one git command per batch of pathspecs, so argv never overflows. */
|
|
826
|
+
async function gitPerBatch(root, args, paths) {
|
|
827
|
+
for (let i = 0; i < paths.length; i += PATHSPEC_BATCH) {
|
|
828
|
+
await git(root, ...args, '--', ...paths.slice(i, i + PATHSPEC_BATCH));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** Counts after the fact, read from git rather than predicted. */
|
|
832
|
+
async function stageCounts(root) {
|
|
833
|
+
const entries = parsePorcelainZ(await git(root, 'status', '--porcelain=v1', '-z', '--untracked-files=all').catch(() => ''));
|
|
834
|
+
return {
|
|
835
|
+
staged: entries.filter(isStagedEntry).length,
|
|
836
|
+
unstaged: entries.filter(isUnstagedEntry).length,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* `git add` — the `+` next to a file, and the `+` next to the group header.
|
|
841
|
+
*
|
|
842
|
+
* `-A` rather than a bare `add` so a DELETED file is staged as a deletion: `git
|
|
843
|
+
* add file.txt` on a path that no longer exists is an error in older git and a
|
|
844
|
+
* no-op in the reader's head, and «I staged the deletion and it did not stage»
|
|
845
|
+
* is the kind of quiet wrong answer this whole session is about removing.
|
|
846
|
+
*/
|
|
847
|
+
async function gitStageImpl(input) {
|
|
848
|
+
const root = await repoRoot(input.worktreePath);
|
|
849
|
+
if (input.all) {
|
|
850
|
+
await git(root, 'add', '-A', '--', '.');
|
|
851
|
+
return { paths: 0, ...(await stageCounts(root)) };
|
|
852
|
+
}
|
|
853
|
+
const paths = safeRepoPaths(root, input.paths);
|
|
854
|
+
await gitPerBatch(root, ['add', '-A'], paths);
|
|
855
|
+
return { paths: paths.length, ...(await stageCounts(root)) };
|
|
267
856
|
}
|
|
268
|
-
|
|
857
|
+
/**
|
|
858
|
+
* `git restore --staged` — the `−` that takes a file back out of the index.
|
|
859
|
+
*
|
|
860
|
+
* Two fallbacks, both real: `git restore` needs git 2.23, and a repository
|
|
861
|
+
* whose first commit has not happened yet has no HEAD to restore from, so the
|
|
862
|
+
* index entry has to be removed instead of rewritten.
|
|
863
|
+
*/
|
|
864
|
+
async function gitUnstageImpl(input) {
|
|
865
|
+
const root = await repoRoot(input.worktreePath);
|
|
866
|
+
const paths = input.all ? ['.'] : safeRepoPaths(root, input.paths);
|
|
867
|
+
const unborn = await git(root, 'rev-parse', '--verify', '--quiet', 'HEAD')
|
|
868
|
+
.then(() => false)
|
|
869
|
+
.catch(() => true);
|
|
870
|
+
if (unborn) {
|
|
871
|
+
await gitPerBatch(root, ['rm', '--cached', '-r', '--force', '--ignore-unmatch', '-q'], paths);
|
|
872
|
+
}
|
|
873
|
+
else {
|
|
874
|
+
try {
|
|
875
|
+
await gitPerBatch(root, ['restore', '--staged'], paths);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
await gitPerBatch(root, ['reset', '-q', 'HEAD'], paths);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { paths: input.all ? 0 : paths.length, ...(await stageCounts(root)) };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* `git checkout -- <file>` and `rm <untracked>` — the ↺ next to a row.
|
|
885
|
+
*
|
|
886
|
+
* The one destructive button in the panel, so it is built to destroy only what
|
|
887
|
+
* it can name. It reads the status FIRST and acts per entry:
|
|
888
|
+
*
|
|
889
|
+
* - tracked → `git restore --worktree`, which puts the file back to what the
|
|
890
|
+
* INDEX holds, not to HEAD. That is what VS Code's «Discard Changes» does,
|
|
891
|
+
* and it is the difference between losing today's edit and losing the
|
|
892
|
+
* carefully staged version of it as well.
|
|
893
|
+
* - untracked → the file is removed. Only ever a path git reported as `??`,
|
|
894
|
+
* which is why a `.gitignore`d file can never reach this line: ignored files
|
|
895
|
+
* are not in the status at all. There is no `-x` anywhere in this file.
|
|
896
|
+
* - conflicted → skipped and reported. `git restore` refuses these, and doing
|
|
897
|
+
* it the forceful way would silently pick a side of somebody's merge.
|
|
898
|
+
*
|
|
899
|
+
* This replaces the `git clean -fd` that destroyed a live project's planning
|
|
900
|
+
* documents on 2026-07-28: `clean` deletes what it FINDS, this deletes what it
|
|
901
|
+
* was told, and the two are only the same in a folder nobody else is using.
|
|
902
|
+
*/
|
|
903
|
+
async function gitDiscardImpl(input) {
|
|
904
|
+
const root = await repoRoot(input.worktreePath);
|
|
905
|
+
const raw = await git(root, 'status', '--porcelain=v1', '-z', '--untracked-files=all').catch((error) => {
|
|
906
|
+
// Deliberately fatal rather than defaulted to «nothing»: an empty status
|
|
907
|
+
// here would make «discard all» a no-op reported as success, and the next
|
|
908
|
+
// press would be somebody pressing it harder.
|
|
909
|
+
throw new Error(`Could not read the working tree: ${shortError(error)}`);
|
|
910
|
+
});
|
|
911
|
+
const entries = parsePorcelainZ(raw);
|
|
912
|
+
const byPath = new Map(entries.map((entry) => [entry.path, entry]));
|
|
913
|
+
// Explicit paths are validated up front and a bad one is FATAL: the caller
|
|
914
|
+
// asked for something illegal and must hear so. `all` is different — the list
|
|
915
|
+
// is git's own, and one entry we decline to touch (a symlink pointing out of
|
|
916
|
+
// the tree) must not cost the other forty their discard (QA-110 m6).
|
|
917
|
+
const wanted = input.all
|
|
918
|
+
? entries.filter(isUnstagedEntry).map((entry) => entry.path)
|
|
919
|
+
: safeRepoPaths(root, input.paths);
|
|
920
|
+
const skipped = [];
|
|
921
|
+
const restore = [];
|
|
922
|
+
const remove = [];
|
|
923
|
+
for (const target of wanted) {
|
|
924
|
+
const entry = byPath.get(target);
|
|
925
|
+
// Not in the status: already clean, or a path that only ever existed in the
|
|
926
|
+
// browser's stale copy of it. Either way there is nothing to discard, and
|
|
927
|
+
// deleting an unlisted path on request is exactly the mistake being fixed.
|
|
928
|
+
if (!entry)
|
|
929
|
+
continue;
|
|
930
|
+
if (isConflictedEntry(entry)) {
|
|
931
|
+
skipped.push(target);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
if (entry.index === '?')
|
|
935
|
+
remove.push(target);
|
|
936
|
+
else if (entry.worktree !== ' ')
|
|
937
|
+
restore.push(target);
|
|
938
|
+
}
|
|
939
|
+
// Everything that is going to be deleted is resolved and checked HERE, before
|
|
940
|
+
// a single file is touched (QA-110 M3). The validation used to run inside the
|
|
941
|
+
// delete loop, so a bad path in position four aborted the request after
|
|
942
|
+
// positions one to three had already been restored and unlinked — a partial
|
|
943
|
+
// destruction reported to the browser as one flat «could not discard».
|
|
944
|
+
const targets = [];
|
|
945
|
+
for (const target of remove) {
|
|
946
|
+
let safe;
|
|
947
|
+
try {
|
|
948
|
+
[safe] = safeRepoPaths(root, [target]);
|
|
949
|
+
}
|
|
950
|
+
catch (error) {
|
|
951
|
+
// Only reachable for `all`, where the paths came from git rather than
|
|
952
|
+
// from the caller — the explicit branch already threw above.
|
|
953
|
+
if (!input.all)
|
|
954
|
+
throw error;
|
|
955
|
+
skipped.push(target);
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (!safe)
|
|
959
|
+
continue;
|
|
960
|
+
targets.push({ path: target, resolved: path.resolve(root, safe) });
|
|
961
|
+
}
|
|
962
|
+
// And a nested repository is left alone, the way `git clean` leaves one alone
|
|
963
|
+
// (QA-110 B1). `git status -uall` reports a checkout inside the tree as ONE
|
|
964
|
+
// untracked directory, so a recursive delete would take its `.git` and every
|
|
965
|
+
// commit in it — work that exists nowhere else and that nobody asked about.
|
|
966
|
+
const nested = targets.filter((t) => fs.existsSync(path.join(t.resolved, '.git')));
|
|
967
|
+
for (const t of nested)
|
|
968
|
+
skipped.push(t.path);
|
|
969
|
+
const deletable = targets.filter((t) => !nested.includes(t));
|
|
970
|
+
if (restore.length > 0) {
|
|
971
|
+
try {
|
|
972
|
+
await gitPerBatch(root, ['restore', '--worktree'], restore);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
await gitPerBatch(root, ['checkout', '--'], restore);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
let deleted = 0;
|
|
979
|
+
for (const target of deletable) {
|
|
980
|
+
try {
|
|
981
|
+
fs.rmSync(target.resolved, { recursive: true, force: true });
|
|
982
|
+
deleted += 1;
|
|
983
|
+
}
|
|
984
|
+
catch {
|
|
985
|
+
skipped.push(target.path);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return { restored: restore.length, deleted, skipped };
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* `git pull` — fetch, then fast-forward, then merge if it has to.
|
|
992
|
+
*
|
|
993
|
+
* Deliberately NOT `--ff-only` forever: a branch with local commits and an
|
|
994
|
+
* upstream that moved is the normal state of any shared branch, and refusing it
|
|
995
|
+
* would mean the button works until the first day it matters. So it does what
|
|
996
|
+
* `git pull` does, including leaving a conflict in the tree — which the panel
|
|
997
|
+
* then draws as «Merge Changes», the way an editor does, instead of hiding the
|
|
998
|
+
* state and inventing a rollback nobody asked for.
|
|
999
|
+
*
|
|
1000
|
+
* Credentials are the user's own and are never seen here; the environment is
|
|
1001
|
+
* hardened the same way `gitPush` hardens it, so a missing one FAILS rather
|
|
1002
|
+
* than hanging on a prompt no headless process can answer.
|
|
1003
|
+
*/
|
|
1004
|
+
async function gitPullImpl(input) {
|
|
1005
|
+
const root = await repoRoot(input.worktreePath);
|
|
1006
|
+
if (await hasMergeInProgress(root)) {
|
|
1007
|
+
return {
|
|
1008
|
+
pulled: false,
|
|
1009
|
+
error: 'A merge is already in progress here — finish it or abort it first',
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
const upstreamRaw = await git(root, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null);
|
|
1013
|
+
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
1014
|
+
if (!upstream) {
|
|
1015
|
+
return {
|
|
1016
|
+
pulled: false,
|
|
1017
|
+
error: 'This branch has no upstream branch yet — press Push once and it will get one',
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
const remote = upstream.split('/')[0] ?? '';
|
|
1021
|
+
const remotes = await git(root, 'remote')
|
|
1022
|
+
.then((raw) => raw.split('\n').filter(Boolean))
|
|
1023
|
+
.catch(() => []);
|
|
1024
|
+
if (!remotes.includes(remote)) {
|
|
1025
|
+
return { pulled: false, upstream, error: `This repository has no remote named ${remote}` };
|
|
1026
|
+
}
|
|
1027
|
+
try {
|
|
1028
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'fetch', '--prune', remote], {
|
|
1029
|
+
cwd: root,
|
|
1030
|
+
timeout: PUSH_TIMEOUT_MS,
|
|
1031
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1032
|
+
env: await networkGitEnv(root),
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
catch (error) {
|
|
1036
|
+
return { pulled: false, upstream, remote, error: pushError(error) };
|
|
1037
|
+
}
|
|
1038
|
+
const distance = await aheadBehind(root, upstream, 'HEAD');
|
|
1039
|
+
if (distance && distance.behind === 0) {
|
|
1040
|
+
return {
|
|
1041
|
+
pulled: true,
|
|
1042
|
+
alreadyUpToDate: true,
|
|
1043
|
+
upstream,
|
|
1044
|
+
remote,
|
|
1045
|
+
ahead: distance.ahead,
|
|
1046
|
+
behind: 0,
|
|
1047
|
+
...(await headSha(root)),
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
const fastForwarded = await mergeWithTimeout(root, ['merge', '--ff-only', upstream])
|
|
1051
|
+
.then(() => true)
|
|
1052
|
+
.catch(() => false);
|
|
1053
|
+
if (fastForwarded) {
|
|
1054
|
+
return {
|
|
1055
|
+
pulled: true,
|
|
1056
|
+
fastForward: true,
|
|
1057
|
+
upstream,
|
|
1058
|
+
remote,
|
|
1059
|
+
...(await headSha(root)),
|
|
1060
|
+
...(await upstreamDistanceOf(root, upstream)),
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
try {
|
|
1064
|
+
await mergeWithTimeout(root, ['merge', '--no-edit', upstream]);
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
if (isMergeConflict(error) || (await hasMergeInProgress(root))) {
|
|
1068
|
+
return {
|
|
1069
|
+
pulled: false,
|
|
1070
|
+
conflict: true,
|
|
1071
|
+
conflictPaths: await conflictedPaths(root),
|
|
1072
|
+
upstream,
|
|
1073
|
+
remote,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
return { pulled: false, upstream, remote, error: shortError(error) };
|
|
1077
|
+
}
|
|
1078
|
+
return {
|
|
1079
|
+
pulled: true,
|
|
1080
|
+
fastForward: false,
|
|
1081
|
+
upstream,
|
|
1082
|
+
remote,
|
|
1083
|
+
...(await headSha(root)),
|
|
1084
|
+
...(await upstreamDistanceOf(root, upstream)),
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
async function headSha(root) {
|
|
1088
|
+
const sha = await git(root, 'rev-parse', 'HEAD').catch(() => null);
|
|
1089
|
+
return sha ? { commitSha: sha } : {};
|
|
1090
|
+
}
|
|
1091
|
+
async function upstreamDistanceOf(root, upstream) {
|
|
1092
|
+
const distance = await aheadBehind(root, upstream, 'HEAD');
|
|
1093
|
+
return distance ? { ahead: distance.ahead, behind: distance.behind } : {};
|
|
1094
|
+
}
|
|
1095
|
+
/** A merge is allowed to take longer than a status call. */
|
|
1096
|
+
async function mergeWithTimeout(root, args) {
|
|
1097
|
+
const { stdout } = await execFileAsync('git', [...GIT_GLOBAL_ARGS, ...args], {
|
|
1098
|
+
cwd: root,
|
|
1099
|
+
timeout: MERGE_TIMEOUT_MS,
|
|
1100
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1101
|
+
});
|
|
1102
|
+
return stdout;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* `git merge --abort` — the way back out of a pull that conflicted.
|
|
1106
|
+
*
|
|
1107
|
+
* Without it a failed pull is a dead end reachable by pressing one button, and
|
|
1108
|
+
* the only exit is a shell on a server the person pressing may not have. It
|
|
1109
|
+
* restores the tree to the commit the merge started from; work committed before
|
|
1110
|
+
* the pull is untouched, because the merge never rewrote it.
|
|
1111
|
+
*/
|
|
1112
|
+
async function gitMergeAbortImpl(worktreePath) {
|
|
1113
|
+
const root = await repoRoot(worktreePath);
|
|
1114
|
+
if (!(await hasMergeInProgress(root)))
|
|
1115
|
+
return { aborted: false, reason: 'no-merge' };
|
|
1116
|
+
try {
|
|
1117
|
+
await mergeWithTimeout(root, ['merge', '--abort']);
|
|
1118
|
+
return { aborted: true };
|
|
1119
|
+
}
|
|
1120
|
+
catch (error) {
|
|
1121
|
+
return { aborted: false, reason: shortError(error) };
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/** The one sentence both guards use, so the two cannot drift apart. */
|
|
1125
|
+
const WORKSPACE_DIRTY_MESSAGE = 'The workspace working copy has uncommitted changes — commit or stash them on the server first';
|
|
1126
|
+
async function applySessionImpl(input) {
|
|
1127
|
+
const { workspacePath, worktreePath, sessionBranch, message } = input;
|
|
269
1128
|
const workspaceDirty = await git(workspacePath, 'status', '--porcelain');
|
|
270
1129
|
if (workspaceDirty) {
|
|
1130
|
+
const paths = porcelainPaths(workspaceDirty);
|
|
271
1131
|
return {
|
|
272
1132
|
applied: false,
|
|
273
|
-
|
|
1133
|
+
workspaceDirty: true,
|
|
1134
|
+
workspaceDirtyFiles: paths.slice(0, WORKSPACE_DIRTY_SAMPLE),
|
|
1135
|
+
workspaceDirtyCount: paths.length,
|
|
1136
|
+
// «Commit it and try again» is not advice a conflicted tree can follow.
|
|
1137
|
+
workspaceMergeInProgress: await hasMergeInProgress(workspacePath),
|
|
1138
|
+
error: WORKSPACE_DIRTY_MESSAGE,
|
|
274
1139
|
};
|
|
275
1140
|
}
|
|
276
1141
|
const workspaceBranch = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD');
|
|
277
1142
|
if (workspaceBranch === sessionBranch) {
|
|
278
1143
|
return { applied: false, error: 'The workspace is checked out on the session branch itself' };
|
|
279
1144
|
}
|
|
1145
|
+
// The drift guard, and the reason it comes before anything is written: the
|
|
1146
|
+
// merge below lands in whatever branch the folder is on. Checking a feature
|
|
1147
|
+
// branch out on the server used to redirect «Apply» for every session of the
|
|
1148
|
+
// project without a word.
|
|
1149
|
+
const expectedBase = input.expectedBase ? sanitizeBranch(input.expectedBase) : null;
|
|
1150
|
+
if (expectedBase && expectedBase !== workspaceBranch) {
|
|
1151
|
+
return { applied: false, drifted: true, workspaceBranch, baseBranch: expectedBase };
|
|
1152
|
+
}
|
|
280
1153
|
// Fold whatever the agent left uncommitted into the branch first, so
|
|
281
1154
|
// «Применить» always takes exactly what the user reviewed.
|
|
282
1155
|
const worktreeDirty = await git(worktreePath, 'status', '--porcelain');
|
|
283
1156
|
if (worktreeDirty) {
|
|
284
1157
|
await git(worktreePath, 'add', '-A');
|
|
285
|
-
await git(worktreePath, 'commit', '-m', 'chore(devbridge):
|
|
1158
|
+
await git(worktreePath, 'commit', '-m', 'chore(devbridge): session changes before apply');
|
|
1159
|
+
}
|
|
1160
|
+
const branchSha = await git(workspacePath, 'rev-parse', sessionBranch);
|
|
1161
|
+
const baseShaBefore = await git(workspacePath, 'rev-parse', 'HEAD');
|
|
1162
|
+
// Captured BEFORE anything is written, so a failed merge can tell its own
|
|
1163
|
+
// leftovers from somebody's work that appeared meanwhile.
|
|
1164
|
+
const untrackedBefore = await untrackedPaths(workspacePath);
|
|
1165
|
+
// How much NEW work there is. After a first apply the branch is still «ahead»
|
|
1166
|
+
// of the base — a squash merge is not a merge as far as git is concerned — so
|
|
1167
|
+
// counting from the fork point would keep claiming work that already landed.
|
|
1168
|
+
const repeat = Boolean(input.sinceSha && isCommitSha(input.sinceSha));
|
|
1169
|
+
const sinceCount = repeat
|
|
1170
|
+
? await countSince(workspacePath, input.sinceSha ?? '', sessionBranch, {
|
|
1171
|
+
excludeRef: 'HEAD',
|
|
1172
|
+
noMerges: true,
|
|
1173
|
+
})
|
|
1174
|
+
: null;
|
|
1175
|
+
if (sinceCount === 0) {
|
|
1176
|
+
return {
|
|
1177
|
+
applied: false,
|
|
1178
|
+
noChanges: true,
|
|
1179
|
+
branchSha,
|
|
1180
|
+
baseShaBefore,
|
|
1181
|
+
baseBranch: workspaceBranch,
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
if (!repeat) {
|
|
1185
|
+
const base = await git(workspacePath, 'merge-base', 'HEAD', sessionBranch);
|
|
1186
|
+
const commits = Number(await git(workspacePath, 'rev-list', '--count', `${base}..${sessionBranch}`));
|
|
1187
|
+
if (!commits) {
|
|
1188
|
+
return {
|
|
1189
|
+
applied: false,
|
|
1190
|
+
noChanges: true,
|
|
1191
|
+
branchSha,
|
|
1192
|
+
baseShaBefore,
|
|
1193
|
+
baseBranch: workspaceBranch,
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
286
1196
|
}
|
|
287
|
-
const base = await git(workspacePath, 'merge-base', 'HEAD', sessionBranch);
|
|
288
|
-
const commits = Number(await git(workspacePath, 'rev-list', '--count', `${base}..${sessionBranch}`));
|
|
289
|
-
if (!commits)
|
|
290
|
-
return { applied: false, error: 'The session branch has no changes to apply' };
|
|
291
1197
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
1198
|
+
if (repeat) {
|
|
1199
|
+
// A REPEAT apply is a three-way merge whose base is the branch tip the
|
|
1200
|
+
// PREVIOUS apply swallowed — not the fork point.
|
|
1201
|
+
//
|
|
1202
|
+
// `merge --squash` would use the fork point, because a squash merge never
|
|
1203
|
+
// makes the branch an ancestor of the base. From the fork point's view,
|
|
1204
|
+
// anything the base changed after the first apply looks like «only the
|
|
1205
|
+
// branch changed», so git takes the branch's version with no conflict —
|
|
1206
|
+
// silently undoing work somebody did on the base in between. Verified
|
|
1207
|
+
// with real git: main deliberately reverting a line, then a second apply,
|
|
1208
|
+
// put the line straight back without a word (QA-107).
|
|
1209
|
+
//
|
|
1210
|
+
// `merge-recursive <base> -- <ours> <theirs>` is the plumbing for exactly
|
|
1211
|
+
// this: it merges into the index and working tree and exits 1 on
|
|
1212
|
+
// conflict. Available in every git this project has ever supported.
|
|
1213
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'merge-recursive', input.sinceSha ?? '', '--', 'HEAD', sessionBranch], { cwd: workspacePath, timeout: MERGE_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024 });
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'merge', '--squash', sessionBranch], {
|
|
1217
|
+
cwd: workspacePath,
|
|
1218
|
+
timeout: MERGE_TIMEOUT_MS,
|
|
1219
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
297
1222
|
}
|
|
298
1223
|
catch (error) {
|
|
299
|
-
// Restore a pristine workspace either way.
|
|
300
|
-
|
|
1224
|
+
// Restore a pristine workspace either way. `merge-recursive` leaves no
|
|
1225
|
+
// MERGE_HEAD, so `reset --merge` can decline — fall back to a hard reset,
|
|
1226
|
+
// which is safe precisely because the tree was verified clean above.
|
|
1227
|
+
await restoreCleanWorkspace(workspacePath, untrackedBefore);
|
|
301
1228
|
// Only a real content conflict is worth handing back to the agent — a
|
|
302
1229
|
// dirty index or a failing hook would send it on a pointless rebase
|
|
303
|
-
// errand (QA-99 MINOR-7).
|
|
304
|
-
|
|
305
|
-
|
|
1230
|
+
// errand (QA-99 MINOR-7). `merge-recursive` exits 1 for a conflict and
|
|
1231
|
+
// says so in its own words, which `isMergeConflict` already recognises.
|
|
1232
|
+
if (isMergeConflict(error) || (repeat && exitedWithConflict(error))) {
|
|
1233
|
+
return { applied: false, conflict: true, baseBranch: workspaceBranch, branchSha };
|
|
306
1234
|
}
|
|
307
1235
|
return { applied: false, error: `Merge failed: ${shortError(error)}` };
|
|
308
1236
|
}
|
|
1237
|
+
// A repeated apply whose new commits only touch things the base already has
|
|
1238
|
+
// (a revert-and-redo, a whitespace round trip) stages nothing. That is
|
|
1239
|
+
// «nothing to apply», not a failure — and it has to be caught here, because
|
|
1240
|
+
// `git commit` would otherwise fail with a message about the index.
|
|
1241
|
+
const stillDirty = await git(workspacePath, 'status', '--porcelain');
|
|
1242
|
+
if (!stillDirty) {
|
|
1243
|
+
await restoreCleanWorkspace(workspacePath, untrackedBefore);
|
|
1244
|
+
return {
|
|
1245
|
+
applied: false,
|
|
1246
|
+
noChanges: true,
|
|
1247
|
+
branchSha,
|
|
1248
|
+
baseShaBefore,
|
|
1249
|
+
baseBranch: workspaceBranch,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
309
1252
|
try {
|
|
310
1253
|
await git(workspacePath, 'commit', '-m', message);
|
|
311
1254
|
}
|
|
@@ -313,11 +1256,250 @@ export async function applySession(workspacePath, worktreePath, sessionBranch, m
|
|
|
313
1256
|
await git(workspacePath, 'reset', '--merge').catch(() => undefined);
|
|
314
1257
|
return { applied: false, error: `Commit failed: ${shortError(error)}` };
|
|
315
1258
|
}
|
|
316
|
-
return {
|
|
1259
|
+
return {
|
|
1260
|
+
applied: true,
|
|
1261
|
+
commitSha: await git(workspacePath, 'rev-parse', 'HEAD'),
|
|
1262
|
+
branchSha,
|
|
1263
|
+
baseShaBefore,
|
|
1264
|
+
baseBranch: workspaceBranch,
|
|
1265
|
+
};
|
|
317
1266
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
1267
|
+
/**
|
|
1268
|
+
* Bring the base branch into the session branch — merge, never rebase.
|
|
1269
|
+
*
|
|
1270
|
+
* Rebase is the tempting one and it is the wrong one: it rewrites commits the
|
|
1271
|
+
* History tab has already shown a human, and «the commit you were looking at no
|
|
1272
|
+
* longer exists» is not a trade worth a linear graph.
|
|
1273
|
+
*
|
|
1274
|
+
* Runs inside the session worktree, so the project folder is never touched.
|
|
1275
|
+
*/
|
|
1276
|
+
async function updateFromBaseImpl(input) {
|
|
1277
|
+
const { workspacePath, worktreePath, sessionBranch } = input;
|
|
1278
|
+
const { baseBranch, baseRef, baseMissing } = await resolveBase(workspacePath, sessionBranch, input.baseBranch);
|
|
1279
|
+
if (baseMissing) {
|
|
1280
|
+
return { updated: false, baseBranch, error: `Branch ${input.baseBranch} no longer exists` };
|
|
1281
|
+
}
|
|
1282
|
+
assertRefArgument(baseRef);
|
|
1283
|
+
const dirty = await git(worktreePath, 'status', '--porcelain');
|
|
1284
|
+
if (dirty) {
|
|
1285
|
+
return {
|
|
1286
|
+
updated: false,
|
|
1287
|
+
baseBranch,
|
|
1288
|
+
error: 'The session has uncommitted changes — commit them first, then update from the base branch',
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const behindRaw = await git(worktreePath, 'rev-list', '--count', `HEAD..${baseRef}`).catch(() => null);
|
|
1292
|
+
if (behindRaw !== null && Number(behindRaw) === 0) {
|
|
1293
|
+
return { updated: true, alreadyUpToDate: true, baseBranch };
|
|
1294
|
+
}
|
|
1295
|
+
try {
|
|
1296
|
+
await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'merge', '--no-edit', baseRef], {
|
|
1297
|
+
cwd: worktreePath,
|
|
1298
|
+
timeout: MERGE_TIMEOUT_MS,
|
|
1299
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
// The conflicting paths only exist between the failure and the abort —
|
|
1304
|
+
// read them first or they are gone.
|
|
1305
|
+
const conflicted = await git(worktreePath, 'diff', '--name-only', '--diff-filter=U').catch(() => '');
|
|
1306
|
+
const conflictPaths = conflicted
|
|
1307
|
+
.split('\n')
|
|
1308
|
+
.filter(Boolean)
|
|
1309
|
+
.slice(0, 100)
|
|
1310
|
+
.map((entry) => entry.slice(0, 500));
|
|
1311
|
+
await git(worktreePath, 'merge', '--abort').catch(() => undefined);
|
|
1312
|
+
// «The merge was aborted and the working tree is clean» is a promise the
|
|
1313
|
+
// agent acts on — so check it rather than assume it. An abort can itself
|
|
1314
|
+
// fail (a lock left by a crashed git, a read-only file), and telling the
|
|
1315
|
+
// agent to go resolve conflicts in a tree that is still mid-merge sends it
|
|
1316
|
+
// into a state it cannot get out of (QA-107).
|
|
1317
|
+
const leftDirty = await git(worktreePath, 'status', '--porcelain').catch(() => '');
|
|
1318
|
+
if (leftDirty) {
|
|
1319
|
+
return {
|
|
1320
|
+
updated: false,
|
|
1321
|
+
baseBranch,
|
|
1322
|
+
error: 'The merge conflicted and could not be undone — the session worktree is still mid-merge. Run `git merge --abort` in it on the server.',
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
if (isMergeConflict(error) || conflictPaths.length > 0) {
|
|
1326
|
+
return { updated: false, conflict: true, conflictPaths, baseBranch };
|
|
1327
|
+
}
|
|
1328
|
+
return { updated: false, baseBranch, error: `Merge failed: ${shortError(error)}` };
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
updated: true,
|
|
1332
|
+
commitSha: await git(worktreePath, 'rev-parse', 'HEAD'),
|
|
1333
|
+
baseBranch,
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
const MAX_BRANCHES = 200;
|
|
1337
|
+
/**
|
|
1338
|
+
* Every local branch, with the one fact the user cannot see anywhere else:
|
|
1339
|
+
* whether it is already checked out in another worktree. Git refuses to check
|
|
1340
|
+
* one branch out twice, so «continue this branch» has to know before it starts,
|
|
1341
|
+
* not after the worktree command fails.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function gitBranches(workspacePath) {
|
|
1344
|
+
const [rawBranches, rawWorktrees, currentBranch, remotesRaw] = await Promise.all([
|
|
1345
|
+
git(workspacePath, 'for-each-ref', `--count=${MAX_BRANCHES + 1}`, '--sort=-committerdate', '--format=%(refname:short)%00%(objectname)%00%(committerdate:iso-strict)%00%(subject)', 'refs/heads/'),
|
|
1346
|
+
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1347
|
+
git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
|
|
1348
|
+
git(workspacePath, 'remote').catch(() => ''),
|
|
1349
|
+
]);
|
|
1350
|
+
// `worktree list --porcelain` emits stanzas: `worktree <path>` … `branch <ref>`.
|
|
1351
|
+
const checkedOut = new Map();
|
|
1352
|
+
let currentWorktree = null;
|
|
1353
|
+
for (const line of rawWorktrees.split('\n')) {
|
|
1354
|
+
if (line.startsWith('worktree '))
|
|
1355
|
+
currentWorktree = line.slice('worktree '.length).trim();
|
|
1356
|
+
else if (line.startsWith('branch ') && currentWorktree) {
|
|
1357
|
+
const ref = line.slice('branch '.length).trim();
|
|
1358
|
+
checkedOut.set(ref.replace(/^refs\/heads\//, ''), currentWorktree);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
const lines = rawBranches.split('\n').filter(Boolean);
|
|
1362
|
+
const truncated = lines.length > MAX_BRANCHES;
|
|
1363
|
+
const branches = [];
|
|
1364
|
+
for (const line of lines.slice(0, MAX_BRANCHES)) {
|
|
1365
|
+
const [name, sha, date, ...subjectParts] = line.split('\0');
|
|
1366
|
+
if (!name || !sha)
|
|
1367
|
+
continue;
|
|
1368
|
+
branches.push({
|
|
1369
|
+
name: name.slice(0, 200),
|
|
1370
|
+
sha,
|
|
1371
|
+
subject: maskString(subjectParts.join('\0')).slice(0, 300),
|
|
1372
|
+
date: (date ?? '').slice(0, 40),
|
|
1373
|
+
checkedOutIn: checkedOut.get(name) ?? null,
|
|
1374
|
+
merged: false,
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
// «Already merged» is answered against the folder's own HEAD — the branch the
|
|
1378
|
+
// user would be merging into. One batched call: `--merged` on for-each-ref.
|
|
1379
|
+
const mergedRaw = await git(workspacePath, 'for-each-ref', '--merged', 'HEAD', '--format=%(refname:short)', 'refs/heads/').catch(() => '');
|
|
1380
|
+
const mergedSet = new Set(mergedRaw.split('\n').filter(Boolean));
|
|
1381
|
+
for (const branch of branches)
|
|
1382
|
+
branch.merged = mergedSet.has(branch.name);
|
|
1383
|
+
return {
|
|
1384
|
+
branches,
|
|
1385
|
+
currentBranch: currentBranch === 'HEAD' ? null : currentBranch,
|
|
1386
|
+
currentSha: await git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1387
|
+
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1388
|
+
truncated,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/** Push is the first thing DevBridge does that leaves the machine. */
|
|
1392
|
+
const PUSH_TIMEOUT_MS = 120_000;
|
|
1393
|
+
/**
|
|
1394
|
+
* Send exactly one branch to exactly one existing remote.
|
|
1395
|
+
*
|
|
1396
|
+
* The argument vector is built here, in code, from two validated values — never
|
|
1397
|
+
* from a string the caller composed. There is no `--force`, no user refspec and
|
|
1398
|
+
* no way to name a different branch: an outgoing write to somebody else's
|
|
1399
|
+
* repository is an incident when it goes wrong, not a defect.
|
|
1400
|
+
*
|
|
1401
|
+
* Credentials are the user's own (ssh agent or a git credential helper on their
|
|
1402
|
+
* machine). We never see them — and we make sure git fails instead of hanging
|
|
1403
|
+
* on a prompt when they are missing.
|
|
1404
|
+
*/
|
|
1405
|
+
/**
|
|
1406
|
+
* The environment for the two commands that reach the network.
|
|
1407
|
+
*
|
|
1408
|
+
* A repo that configures its own ssh command (a deploy key, a jump host) has
|
|
1409
|
+
* done so on purpose, and GIT_SSH_COMMAND outranks `core.sshCommand` — so
|
|
1410
|
+
* overriding it unconditionally would break exactly the setups that need it.
|
|
1411
|
+
* Batch mode is only there to make a missing credential FAIL rather than hang
|
|
1412
|
+
* on a prompt nobody can answer; when the repo has its own command we settle
|
|
1413
|
+
* for GIT_TERMINAL_PROMPT and let it be (QA-107).
|
|
1414
|
+
*/
|
|
1415
|
+
async function networkGitEnv(cwd) {
|
|
1416
|
+
const ownSshCommand = await git(cwd, 'config', '--get', 'core.sshCommand').catch(() => '');
|
|
1417
|
+
return {
|
|
1418
|
+
...process.env,
|
|
1419
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
1420
|
+
GIT_ASKPASS: '',
|
|
1421
|
+
SSH_ASKPASS: '',
|
|
1422
|
+
...(ownSshCommand ? {} : { GIT_SSH_COMMAND: 'ssh -oBatchMode=yes' }),
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
async function gitPushImpl(input) {
|
|
1426
|
+
const branch = sanitizeBranch(input.branch);
|
|
1427
|
+
if (!branch)
|
|
1428
|
+
return { pushed: false, error: 'Invalid branch name' };
|
|
1429
|
+
const remotes = await git(input.workspacePath, 'remote')
|
|
1430
|
+
.then((raw) => raw.split('\n').filter(Boolean))
|
|
1431
|
+
.catch(() => []);
|
|
1432
|
+
if (remotes.length === 0) {
|
|
1433
|
+
return { pushed: false, error: 'This repository has no remote configured' };
|
|
1434
|
+
}
|
|
1435
|
+
// Membership in the repo's own list, not a pattern match: the value reaches
|
|
1436
|
+
// an argv slot, and «origin» being a legal name says nothing about it existing.
|
|
1437
|
+
if (!remotes.includes(input.remote)) {
|
|
1438
|
+
return {
|
|
1439
|
+
pushed: false,
|
|
1440
|
+
error: `This repository has no remote named ${input.remote} (it has: ${remotes.join(', ')})`,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (!(await branchExists(input.workspacePath, branch))) {
|
|
1444
|
+
return { pushed: false, error: `Branch ${branch} no longer exists in this repository` };
|
|
1445
|
+
}
|
|
1446
|
+
const commitSha = await git(input.workspacePath, 'rev-parse', branch);
|
|
1447
|
+
// Session 16: the first push of a branch also sets its upstream, so the very
|
|
1448
|
+
// next Pull has something to pull FROM. Without this the panel offered Pull
|
|
1449
|
+
// on a branch that could only ever answer «no upstream» — a button that is
|
|
1450
|
+
// wrong exactly once, on the day somebody first tries it.
|
|
1451
|
+
const hasUpstream = await git(input.workspacePath, 'config', '--get', `branch.${branch}.merge`)
|
|
1452
|
+
.then((value) => Boolean(value))
|
|
1453
|
+
.catch(() => false);
|
|
1454
|
+
try {
|
|
1455
|
+
const { stdout, stderr } = await execFileAsync('git', [
|
|
1456
|
+
...GIT_GLOBAL_ARGS,
|
|
1457
|
+
'push',
|
|
1458
|
+
...(hasUpstream ? [] : ['--set-upstream']),
|
|
1459
|
+
input.remote,
|
|
1460
|
+
`refs/heads/${branch}:refs/heads/${branch}`,
|
|
1461
|
+
], {
|
|
1462
|
+
cwd: input.workspacePath,
|
|
1463
|
+
timeout: PUSH_TIMEOUT_MS,
|
|
1464
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
1465
|
+
env: await networkGitEnv(input.workspacePath),
|
|
1466
|
+
});
|
|
1467
|
+
const output = `${stdout}${stderr}`;
|
|
1468
|
+
return {
|
|
1469
|
+
pushed: true,
|
|
1470
|
+
remote: input.remote,
|
|
1471
|
+
branch,
|
|
1472
|
+
commitSha,
|
|
1473
|
+
alreadyUpToDate: /Everything up-to-date/i.test(output),
|
|
1474
|
+
...(hasUpstream ? {} : { upstreamSet: true }),
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
// Git's own words, masked — an auth failure has to be readable or the user
|
|
1479
|
+
// cannot fix it on their side.
|
|
1480
|
+
return { pushed: false, remote: input.remote, branch, error: pushError(error) };
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
function pushError(error) {
|
|
1484
|
+
const stderr = String(error?.stderr ?? '');
|
|
1485
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1486
|
+
return maskString(stderr.trim() || message).slice(0, 1_000);
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* ⚠️ Both formats are parsed POSITIONALLY, in two different functions. Adding a
|
|
1490
|
+
* placeholder here without moving the matching destructure below silently
|
|
1491
|
+
* shifts every later field — the subject becomes the decoration and nobody
|
|
1492
|
+
* notices until a user reads it. Change format and parse in one edit.
|
|
1493
|
+
*/
|
|
1494
|
+
const LOG_FORMAT = '%H%x00%P%x00%an%x00%aI%x00%cn%x00%cI%x00%D%x00%s';
|
|
1495
|
+
const SHOW_META_FORMAT = '%H%x00%P%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%D%x00%s%x00%b';
|
|
1496
|
+
/**
|
|
1497
|
+
* Session 14 raised this from 200 for the repository graph, where one page is a
|
|
1498
|
+
* whole screen of a busy `main`. It is bounded by two things above it, and both
|
|
1499
|
+
* were raised in the same breath: the API's own `max(limit)` and the 512 KB WS
|
|
1500
|
+
* frame — 500 commits of ~250 bytes each is ~125 KB, comfortably inside it.
|
|
1501
|
+
*/
|
|
1502
|
+
const MAX_LOG_LIMIT = 500;
|
|
321
1503
|
const MAX_COMMIT_FILES = 500;
|
|
322
1504
|
const SUBJECT_CAP = 500;
|
|
323
1505
|
const BODY_CAP = 4_000;
|
|
@@ -356,7 +1538,7 @@ function safeHistoryPath(filePath) {
|
|
|
356
1538
|
if (path.isAbsolute(normalized) || normalized.split('/').includes('..')) {
|
|
357
1539
|
throw new Error('Diff path must be relative to the repository');
|
|
358
1540
|
}
|
|
359
|
-
if (
|
|
1541
|
+
if (HISTORY_PATHSPEC_MAGIC.test(normalized)) {
|
|
360
1542
|
throw new Error('Diff path must name one file, not a pattern');
|
|
361
1543
|
}
|
|
362
1544
|
// `.`, `./` and a trailing slash all name a directory, and a directory
|
|
@@ -372,15 +1554,188 @@ function safeHistoryPath(filePath) {
|
|
|
372
1554
|
}
|
|
373
1555
|
return normalized;
|
|
374
1556
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
1557
|
+
/**
|
|
1558
|
+
* Ten was too few the moment the graph started showing a live `main`, where a
|
|
1559
|
+
* release commit routinely carries a tag, a remote and two branches.
|
|
1560
|
+
*/
|
|
1561
|
+
const MAX_REFS_PER_COMMIT = 24;
|
|
1562
|
+
/**
|
|
1563
|
+
* `%D` gives a comma-separated decoration: `HEAD -> main, origin/main, tag: v1.2`.
|
|
1564
|
+
*
|
|
1565
|
+
* Session 14 turns that into typed refs. The `tag: ` prefix used to survive into
|
|
1566
|
+
* the badge, `HEAD` was dropped entirely (so «this is what the folder is on» was
|
|
1567
|
+
* unsayable), and a remote-tracking branch looked exactly like a local one.
|
|
1568
|
+
*
|
|
1569
|
+
* The remote test is «the first segment is a configured remote», not «it has a
|
|
1570
|
+
* slash»: `feature/login` has a slash and is local, and a repo with a remote
|
|
1571
|
+
* called `feature` would otherwise mislabel it.
|
|
1572
|
+
*/
|
|
1573
|
+
function parseRefs(decoration, remotes = new Set()) {
|
|
1574
|
+
const refs = [];
|
|
1575
|
+
// git separates decorations with «, » — a bare comma is a LEGAL character
|
|
1576
|
+
// inside a ref name, and splitting on it drew one branch as two badges whose
|
|
1577
|
+
// names resolve to nothing (session 15).
|
|
1578
|
+
for (const raw of decoration.split(', ')) {
|
|
1579
|
+
let entry = raw.trim();
|
|
1580
|
+
if (!entry)
|
|
1581
|
+
continue;
|
|
1582
|
+
let isHead = false;
|
|
1583
|
+
if (entry.startsWith('HEAD -> ')) {
|
|
1584
|
+
isHead = true;
|
|
1585
|
+
entry = entry.slice('HEAD -> '.length).trim();
|
|
1586
|
+
}
|
|
1587
|
+
// A detached HEAD decorates as a bare `HEAD` with nothing to point at.
|
|
1588
|
+
if (entry === 'HEAD')
|
|
1589
|
+
continue;
|
|
1590
|
+
let type = 'head';
|
|
1591
|
+
if (entry.startsWith('tag: ')) {
|
|
1592
|
+
type = 'tag';
|
|
1593
|
+
entry = entry.slice('tag: '.length).trim();
|
|
1594
|
+
}
|
|
1595
|
+
else if (remotes.has(entry.split('/')[0] ?? '')) {
|
|
1596
|
+
type = 'remote';
|
|
1597
|
+
}
|
|
1598
|
+
if (!entry)
|
|
1599
|
+
continue;
|
|
1600
|
+
refs.push({ name: entry.slice(0, 200), type, isHead });
|
|
1601
|
+
if (refs.length >= MAX_REFS_PER_COMMIT)
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1604
|
+
return refs;
|
|
1605
|
+
}
|
|
1606
|
+
/** Remotes configured in this repository — needed to type a decoration. */
|
|
1607
|
+
async function remoteNames(workspacePath) {
|
|
1608
|
+
const raw = await git(workspacePath, 'remote').catch(() => '');
|
|
1609
|
+
return new Set(raw.split('\n').filter(Boolean).slice(0, 50));
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* How many refs a caller may name explicitly.
|
|
1613
|
+
*
|
|
1614
|
+
* Bounded because the visibility check below walks this list one
|
|
1615
|
+
* `merge-base --is-ancestor` at a time, and an unbounded list would be a way to
|
|
1616
|
+
* make the runner do arbitrary work per commit opened.
|
|
1617
|
+
*/
|
|
1618
|
+
const MAX_SELECTED_REFS = 32;
|
|
1619
|
+
/**
|
|
1620
|
+
* Names git treats as refs but which are NOT branches, remotes or tags.
|
|
1621
|
+
*
|
|
1622
|
+
* The reason this list exists at all: `ORIG_HEAD` is what `reset --hard` writes
|
|
1623
|
+
* before it moves, and `refs/stash` holds whatever `git stash` took away — so
|
|
1624
|
+
* naming one of them in the selector would make exactly the commits QA-105 is
|
|
1625
|
+
* about reachable again, through the front door. `sanitizeBranch` accepts them
|
|
1626
|
+
* happily; they look like ordinary names.
|
|
1627
|
+
*
|
|
1628
|
+
* Matched case-insensitively on the whole value and on its first segment, so
|
|
1629
|
+
* `ORIG_HEAD`, `orig_head` and `ORIG_HEAD^{commit}`-shaped variants all fail.
|
|
1630
|
+
*/
|
|
1631
|
+
const PSEUDO_REFS = new Set([
|
|
1632
|
+
'HEAD',
|
|
1633
|
+
'ORIG_HEAD',
|
|
1634
|
+
'FETCH_HEAD',
|
|
1635
|
+
'MERGE_HEAD',
|
|
1636
|
+
'CHERRY_PICK_HEAD',
|
|
1637
|
+
'REVERT_HEAD',
|
|
1638
|
+
'BISECT_HEAD',
|
|
1639
|
+
'REBASE_HEAD',
|
|
1640
|
+
'AUTO_MERGE',
|
|
1641
|
+
'stash',
|
|
1642
|
+
]);
|
|
1643
|
+
/**
|
|
1644
|
+
* Everything a ref name must satisfy before it becomes a git argument.
|
|
1645
|
+
*
|
|
1646
|
+
* The same class of check branch names go through (`sanitizeBranch`), because
|
|
1647
|
+
* that is exactly what these are: `main`, `origin/main`, `v1.2.0`. A leading
|
|
1648
|
+
* dash would be an option, `..` a range, and neither belongs in an argv slot
|
|
1649
|
+
* built from a query string.
|
|
1650
|
+
*
|
|
1651
|
+
* Plus the pseudo-refs above, which are the hole this check exists to close.
|
|
1652
|
+
*/
|
|
1653
|
+
export function sanitizeRefName(value) {
|
|
1654
|
+
if (typeof value !== 'string')
|
|
1655
|
+
return null;
|
|
1656
|
+
const safe = sanitizeBranch(value);
|
|
1657
|
+
if (!safe)
|
|
1658
|
+
return null;
|
|
1659
|
+
// A fully-qualified name may only name one of the three namespaces this
|
|
1660
|
+
// product shows. The check used to STRIP those three and then look for a
|
|
1661
|
+
// pseudo-ref in what was left, so anything else under `refs/` skipped the
|
|
1662
|
+
// denylist by being spelled out: `refs/stash` (what «Set aside» writes),
|
|
1663
|
+
// `refs/ORIG_HEAD` (what `reset --hard` writes), `refs/notes/*`, and
|
|
1664
|
+
// `refs/original/*` — where `filter-branch` keeps the history somebody
|
|
1665
|
+
// rewrote precisely to get rid of. Every one of them is QA-105 through the
|
|
1666
|
+
// front door, and session 15 gave the first of them real content to leak.
|
|
1667
|
+
if (safe.startsWith('refs/') && !/^refs\/(heads|remotes|tags)\//.test(safe))
|
|
1668
|
+
return null;
|
|
1669
|
+
const withoutNamespace = safe.replace(/^refs\/(heads|remotes|tags)\//, '');
|
|
1670
|
+
const first = withoutNamespace.split('/')[0] ?? '';
|
|
1671
|
+
if (PSEUDO_REFS.has(withoutNamespace.toUpperCase()) || PSEUDO_REFS.has(withoutNamespace)) {
|
|
1672
|
+
return null;
|
|
1673
|
+
}
|
|
1674
|
+
if (PSEUDO_REFS.has(first.toUpperCase()) || PSEUDO_REFS.has(first))
|
|
1675
|
+
return null;
|
|
1676
|
+
return safe;
|
|
1677
|
+
}
|
|
1678
|
+
function resolveRefScope(selector, always) {
|
|
1679
|
+
const named = (selector.refs ?? [])
|
|
1680
|
+
.map(sanitizeRefName)
|
|
1681
|
+
.filter((ref) => ref !== null);
|
|
1682
|
+
const refs = [...new Set([...always, ...named])].slice(0, MAX_SELECTED_REFS);
|
|
1683
|
+
const namespaces = [];
|
|
1684
|
+
const namespacePaths = [];
|
|
1685
|
+
const add = (flag, path) => {
|
|
1686
|
+
namespaces.push(flag);
|
|
1687
|
+
namespacePaths.push(path);
|
|
1688
|
+
};
|
|
1689
|
+
// NOT `--all`: that also walks refs/stash and refs/notes, which are not
|
|
1690
|
+
// history anybody asked to see.
|
|
1691
|
+
if (selector.all)
|
|
1692
|
+
add('--branches', 'refs/heads');
|
|
1693
|
+
if (selector.all || selector.includeRemotes)
|
|
1694
|
+
add('--remotes', 'refs/remotes');
|
|
1695
|
+
if (selector.all || selector.includeTags)
|
|
1696
|
+
add('--tags', 'refs/tags');
|
|
1697
|
+
return { refs, namespaces, namespacePaths };
|
|
1698
|
+
}
|
|
1699
|
+
/**
|
|
1700
|
+
* Is `sha` reachable from anything this request was allowed to walk?
|
|
1701
|
+
*
|
|
1702
|
+
* Named refs first and in order, because the caller puts its own branch at the
|
|
1703
|
+
* front and that is the answer nine times out of ten. The namespace sweep is
|
|
1704
|
+
* one call and only runs when the named refs came up empty.
|
|
1705
|
+
*/
|
|
1706
|
+
async function reachableFromScope(workspacePath, sha, scope) {
|
|
1707
|
+
// Belt to the pseudo-ref braces: a name only authorises a read if it really
|
|
1708
|
+
// is a branch, a remote-tracking branch or a tag. `rev-parse` resolves
|
|
1709
|
+
// anything git calls a ref — including the ones a reset leaves behind — so
|
|
1710
|
+
// the namespace is checked explicitly rather than inferred from the name.
|
|
1711
|
+
const real = await realRefNames(workspacePath, scope.refs);
|
|
1712
|
+
for (const ref of scope.refs.filter((name) => real.has(name))) {
|
|
1713
|
+
const reachable = await git(workspacePath, 'merge-base', '--is-ancestor', sha, assertRefArgument(ref))
|
|
1714
|
+
.then(() => true)
|
|
1715
|
+
.catch(() => false);
|
|
1716
|
+
if (reachable)
|
|
1717
|
+
return true;
|
|
1718
|
+
}
|
|
1719
|
+
if (scope.namespacePaths.length === 0)
|
|
1720
|
+
return false;
|
|
1721
|
+
const hit = await git(workspacePath, 'for-each-ref', '--count=1', `--contains=${sha}`, '--format=%(refname)', ...scope.namespacePaths).catch(() => '');
|
|
1722
|
+
return hit.trim().length > 0;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* Which of these names actually live under refs/heads, refs/remotes or
|
|
1726
|
+
* refs/tags — one `for-each-ref`, whatever the list length.
|
|
1727
|
+
*/
|
|
1728
|
+
async function realRefNames(workspacePath, names) {
|
|
1729
|
+
if (names.length === 0)
|
|
1730
|
+
return new Set();
|
|
1731
|
+
const raw = await git(workspacePath, 'for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes', 'refs/tags').catch(() => '');
|
|
1732
|
+
const existing = new Set(raw.split('\n').filter(Boolean));
|
|
1733
|
+
// `%(refname:short)` prints `main`, `origin/main`, `v1.2.0` — so a caller
|
|
1734
|
+
// that spelled a ref out in full (`refs/heads/main`, which `sanitizeRefName`
|
|
1735
|
+
// accepts) has to be compared on the same footing, or a real branch is
|
|
1736
|
+
// rejected for being over-specified (session 15). Only the three namespaces
|
|
1737
|
+
// this product shows are strippable, which is exactly the allowlist.
|
|
1738
|
+
return new Set(names.filter((name) => existing.has(name) || existing.has(name.replace(/^refs\/(heads|remotes|tags)\//, ''))));
|
|
384
1739
|
}
|
|
385
1740
|
/**
|
|
386
1741
|
* `--numstat` names a rename as `old => new` (or `dir/{old => new}/file`),
|
|
@@ -404,38 +1759,98 @@ async function branchExists(workspacePath, ref) {
|
|
|
404
1759
|
.catch(() => false);
|
|
405
1760
|
}
|
|
406
1761
|
export async function gitLog(input) {
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
1762
|
+
const sessionBranch = input.branch ? requireSessionBranch(input.branch) : null;
|
|
1763
|
+
const selectorOnly = sessionBranch === null;
|
|
1764
|
+
// Without a session there is no base to resolve and no fork point to mark —
|
|
1765
|
+
// the repository graph stands on the refs it was asked for and nothing else.
|
|
1766
|
+
const base = selectorOnly
|
|
1767
|
+
? null
|
|
1768
|
+
: await resolveBase(input.workspacePath, sessionBranch, input.baseBranch);
|
|
1769
|
+
if (base)
|
|
1770
|
+
assertRefArgument(base.baseRef);
|
|
1771
|
+
if (sessionBranch && !(await branchExists(input.workspacePath, sessionBranch))) {
|
|
1772
|
+
throw new Error(`Branch ${sessionBranch} no longer exists in this repository`);
|
|
412
1773
|
}
|
|
413
1774
|
// Between the BRANCH and the base — not between the workspace's HEAD and the
|
|
414
1775
|
// base, which is what `mergeBase()` computes for the Changes panel from
|
|
415
1776
|
// inside the worktree. A base branch that is gone (or a repo with a single
|
|
416
1777
|
// commit) leaves the marker missing, not the whole panel failing.
|
|
417
|
-
const
|
|
1778
|
+
const forkPoint = sessionBranch && base
|
|
1779
|
+
? await git(input.workspacePath, 'merge-base', sessionBranch, base.baseRef).catch(() => null)
|
|
1780
|
+
: null;
|
|
418
1781
|
const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50), 1), MAX_LOG_LIMIT);
|
|
419
1782
|
const skip = Math.min(Math.max(Math.trunc(input.skip ?? 0), 0), 100_000);
|
|
420
1783
|
const scope = input.scope ?? 'all';
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
1784
|
+
// Session 11's three scopes stay exactly as they were when a session branch
|
|
1785
|
+
// is given AND the caller named no refs of its own — every existing caller
|
|
1786
|
+
// lands here and nothing about it changed.
|
|
1787
|
+
const usesSelector = selectorOnly ||
|
|
1788
|
+
Boolean(input.all || input.includeRemotes || input.includeTags || input.refs?.length);
|
|
1789
|
+
let range;
|
|
1790
|
+
let scopeRefs;
|
|
1791
|
+
if (usesSelector) {
|
|
1792
|
+
// The session's own branch and base are always in the picture: the graph is
|
|
1793
|
+
// reached from a session, and hiding the branch you came from would be a
|
|
1794
|
+
// strange thing for it to do.
|
|
1795
|
+
const always = [
|
|
1796
|
+
...(sessionBranch ? [sessionBranch] : []),
|
|
1797
|
+
...(base?.baseRef ? [base.baseRef] : []),
|
|
1798
|
+
];
|
|
1799
|
+
const scope = resolveRefScope(input, always);
|
|
1800
|
+
// The SAME list the commit read will accept, not a wider one (session 15).
|
|
1801
|
+
//
|
|
1802
|
+
// `resolveRefScope` promises the two halves «can never disagree about what
|
|
1803
|
+
// this request was allowed to see», and only the read kept the promise:
|
|
1804
|
+
// `reachableFromScope` filters through `realRefNames` (a live entry under
|
|
1805
|
+
// refs/heads, refs/remotes or refs/tags), while the walk fed `scope.refs`
|
|
1806
|
+
// straight into argv. So the log listed commits whose detail view then
|
|
1807
|
+
// refused to open.
|
|
1808
|
+
const walkable = await realRefNames(input.workspacePath, scope.refs);
|
|
1809
|
+
range = [
|
|
1810
|
+
...scope.refs.filter((ref) => walkable.has(ref)).map(assertRefArgument),
|
|
1811
|
+
...scope.namespaces,
|
|
1812
|
+
];
|
|
1813
|
+
scopeRefs = [...scope.refs, ...scope.namespacePaths];
|
|
1814
|
+
// A selector that resolved to nothing at all would make `git log` walk HEAD
|
|
1815
|
+
// by default — the one outcome that is neither what was asked for nor safe
|
|
1816
|
+
// to guess at.
|
|
1817
|
+
if (range.length === 0) {
|
|
1818
|
+
return {
|
|
1819
|
+
branch: sessionBranch ?? '',
|
|
1820
|
+
baseBranch: base?.baseBranch ?? '',
|
|
1821
|
+
mergeBase: forkPoint,
|
|
1822
|
+
commits: [],
|
|
1823
|
+
hasMore: false,
|
|
1824
|
+
scopeRefs: [],
|
|
1825
|
+
nextSkip: 0,
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
else {
|
|
1830
|
+
const baseRef = base?.baseRef ?? '';
|
|
1831
|
+
// `branch --not baseRef` is exactly `base..branch`, but it does not depend
|
|
1832
|
+
// on merge-base succeeding: a repository where the two histories never met
|
|
1833
|
+
// would otherwise silently fall back to the WHOLE branch while the UI still
|
|
1834
|
+
// said «only what the agent committed» (QA-105).
|
|
1835
|
+
range =
|
|
1836
|
+
scope === 'session'
|
|
1837
|
+
? [sessionBranch, '--not', baseRef]
|
|
1838
|
+
: scope === 'branch'
|
|
1839
|
+
? [sessionBranch]
|
|
1840
|
+
: [sessionBranch, baseRef];
|
|
1841
|
+
scopeRefs = scope === 'branch' ? [sessionBranch] : [sessionBranch, baseRef];
|
|
1842
|
+
}
|
|
430
1843
|
// `--topo-order` is not cosmetic: the lane layout on the dashboard assumes a
|
|
431
1844
|
// parent never appears before its child, and the default (date order) breaks
|
|
432
1845
|
// exactly that on branches committed out of order.
|
|
433
|
-
const raw = await git(input.workspacePath, 'log', '--topo-order', `--max-count=${limit + 1}`, `--skip=${skip}
|
|
1846
|
+
const raw = await git(input.workspacePath, 'log', '--topo-order', `--max-count=${limit + 1}`, ...(skip > 0 ? [`--skip=${skip}`] : []), `--format=${LOG_FORMAT}`, ...range, '--');
|
|
1847
|
+
const remotes = await remoteNames(input.workspacePath);
|
|
434
1848
|
const lines = raw.split('\n').filter((line) => line.length > 0);
|
|
435
1849
|
const hasMore = lines.length > limit;
|
|
436
1850
|
const commits = [];
|
|
437
1851
|
for (const line of lines.slice(0, limit)) {
|
|
438
|
-
|
|
1852
|
+
// ⚠️ Positional — mirrors LOG_FORMAT exactly.
|
|
1853
|
+
const [sha, parents, author, date, committer, committerDate, decoration, subject] = line.split('\0');
|
|
439
1854
|
if (!sha)
|
|
440
1855
|
continue;
|
|
441
1856
|
commits.push({
|
|
@@ -443,11 +1858,123 @@ export async function gitLog(input) {
|
|
|
443
1858
|
parents: parseParents(parents ?? ''),
|
|
444
1859
|
author: maskString(author ?? '').slice(0, 200),
|
|
445
1860
|
date: (date ?? '').slice(0, 40),
|
|
1861
|
+
committer: maskString(committer ?? '').slice(0, 200),
|
|
1862
|
+
committerDate: (committerDate ?? '').slice(0, 40),
|
|
446
1863
|
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
447
|
-
refs: parseRefs(decoration ?? ''),
|
|
1864
|
+
refs: parseRefs(decoration ?? '', remotes),
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
return {
|
|
1868
|
+
branch: sessionBranch ?? '',
|
|
1869
|
+
baseBranch: base?.baseBranch ?? '',
|
|
1870
|
+
mergeBase: forkPoint,
|
|
1871
|
+
commits,
|
|
1872
|
+
hasMore,
|
|
1873
|
+
scopeRefs,
|
|
1874
|
+
// The offset the next page starts at. Computed here so a client cannot get
|
|
1875
|
+
// it wrong, and so a page that returned fewer commits than it asked for
|
|
1876
|
+
// still advances correctly.
|
|
1877
|
+
nextSkip: hasMore ? skip + commits.length : 0,
|
|
1878
|
+
};
|
|
1879
|
+
}
|
|
1880
|
+
const MAX_REFS = 500;
|
|
1881
|
+
/**
|
|
1882
|
+
* Every ref worth drawing, with the two facts a picker cannot get anywhere
|
|
1883
|
+
* else: which worktree holds a branch, and what it tracks.
|
|
1884
|
+
*
|
|
1885
|
+
* One `for-each-ref` over the three namespaces rather than three calls — the
|
|
1886
|
+
* type comes from the ref path, which is more reliable than guessing from the
|
|
1887
|
+
* short name.
|
|
1888
|
+
*/
|
|
1889
|
+
export async function gitRefs(workspacePath) {
|
|
1890
|
+
const [raw, rawWorktrees, currentBranch, currentSha, remotesRaw] = await Promise.all([
|
|
1891
|
+
git(workspacePath, 'for-each-ref', `--count=${MAX_REFS + 1}`, '--sort=-committerdate', '--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)%00%(upstream:short)%00%(subject)', 'refs/heads', 'refs/remotes', 'refs/tags'),
|
|
1892
|
+
git(workspacePath, 'worktree', 'list', '--porcelain').catch(() => ''),
|
|
1893
|
+
git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
|
|
1894
|
+
git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1895
|
+
git(workspacePath, 'remote').catch(() => ''),
|
|
1896
|
+
]);
|
|
1897
|
+
const checkedOut = new Map();
|
|
1898
|
+
let worktree = null;
|
|
1899
|
+
for (const line of rawWorktrees.split('\n')) {
|
|
1900
|
+
if (line.startsWith('worktree '))
|
|
1901
|
+
worktree = line.slice('worktree '.length).trim();
|
|
1902
|
+
else if (line.startsWith('branch ') && worktree) {
|
|
1903
|
+
checkedOut.set(line
|
|
1904
|
+
.slice('branch '.length)
|
|
1905
|
+
.trim()
|
|
1906
|
+
.replace(/^refs\/heads\//, ''), worktree);
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
1910
|
+
const truncated = lines.length > MAX_REFS;
|
|
1911
|
+
const refs = [];
|
|
1912
|
+
for (const line of lines.slice(0, MAX_REFS)) {
|
|
1913
|
+
const [refname, sha, date, upstream, ...subjectParts] = line.split('\0');
|
|
1914
|
+
if (!refname || !sha)
|
|
1915
|
+
continue;
|
|
1916
|
+
// `refs/remotes/origin/HEAD` is a symbolic pointer, not a branch — drawing
|
|
1917
|
+
// it puts a duplicate of the default branch in every picker.
|
|
1918
|
+
if (refname.endsWith('/HEAD'))
|
|
1919
|
+
continue;
|
|
1920
|
+
const type = refname.startsWith('refs/tags/')
|
|
1921
|
+
? 'tag'
|
|
1922
|
+
: refname.startsWith('refs/remotes/')
|
|
1923
|
+
? 'remote'
|
|
1924
|
+
: 'head';
|
|
1925
|
+
const name = refname.replace(/^refs\/(heads|remotes|tags)\//, '');
|
|
1926
|
+
refs.push({
|
|
1927
|
+
name: name.slice(0, 200),
|
|
1928
|
+
type,
|
|
1929
|
+
isHead: type === 'head' && name === currentBranch,
|
|
1930
|
+
sha,
|
|
1931
|
+
subject: maskString(subjectParts.join('\0')).slice(0, 300),
|
|
1932
|
+
date: (date ?? '').slice(0, 40),
|
|
1933
|
+
upstream: upstream ? upstream.slice(0, 200) : null,
|
|
1934
|
+
checkedOutIn: type === 'head' ? (checkedOut.get(name) ?? null) : null,
|
|
448
1935
|
});
|
|
449
1936
|
}
|
|
450
|
-
return {
|
|
1937
|
+
return {
|
|
1938
|
+
refs,
|
|
1939
|
+
currentBranch: currentBranch === 'HEAD' ? null : currentBranch,
|
|
1940
|
+
currentSha,
|
|
1941
|
+
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1942
|
+
truncated,
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* The truth about the checkout everything is actually built from.
|
|
1947
|
+
*
|
|
1948
|
+
* The product used to collect exactly this during path validation and throw it
|
|
1949
|
+
* away, which is why it could never answer «is the running build yours» — the
|
|
1950
|
+
* question behind «how do I know docker was not rebuilt». Read-only.
|
|
1951
|
+
*/
|
|
1952
|
+
export async function workspaceState(workspacePath) {
|
|
1953
|
+
const [branchRaw, sha, porcelain, upstreamRaw, remotesRaw, meta] = await Promise.all([
|
|
1954
|
+
git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null),
|
|
1955
|
+
git(workspacePath, 'rev-parse', 'HEAD').catch(() => null),
|
|
1956
|
+
git(workspacePath, 'status', '--porcelain').catch(() => ''),
|
|
1957
|
+
git(workspacePath, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}').catch(() => null),
|
|
1958
|
+
git(workspacePath, 'remote').catch(() => ''),
|
|
1959
|
+
git(workspacePath, 'log', '-1', '--format=%s%x00%cI', 'HEAD', '--').catch(() => ''),
|
|
1960
|
+
]);
|
|
1961
|
+
const branch = branchRaw === 'HEAD' ? null : branchRaw;
|
|
1962
|
+
const upstream = upstreamRaw ? sanitizeBranch(upstreamRaw) : null;
|
|
1963
|
+
const distance = upstream ? await aheadBehind(workspacePath, upstream, 'HEAD') : null;
|
|
1964
|
+
const [subject, date] = meta.split('\0');
|
|
1965
|
+
const dirtyLines = porcelain.split('\n').filter(Boolean);
|
|
1966
|
+
return {
|
|
1967
|
+
branch,
|
|
1968
|
+
sha,
|
|
1969
|
+
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
1970
|
+
date: (date ?? '').slice(0, 40),
|
|
1971
|
+
dirty: dirtyLines.length > 0,
|
|
1972
|
+
dirtyFiles: dirtyLines.length,
|
|
1973
|
+
upstream,
|
|
1974
|
+
ahead: distance?.ahead ?? null,
|
|
1975
|
+
behind: distance?.behind ?? null,
|
|
1976
|
+
remotes: remotesRaw.split('\n').filter(Boolean).slice(0, 20),
|
|
1977
|
+
};
|
|
451
1978
|
}
|
|
452
1979
|
/**
|
|
453
1980
|
* A path in a commit must name a FILE. A directory pathspec matches everything
|
|
@@ -476,19 +2003,31 @@ async function assertPathIsBlob(workspacePath, sha, relPath) {
|
|
|
476
2003
|
* thrown away with `reset --hard`, which is still in the object store
|
|
477
2004
|
* (QA-105 security round).
|
|
478
2005
|
*/
|
|
479
|
-
async function assertCommitIsVisible(workspacePath, sha, branch) {
|
|
480
|
-
const safeBranch = sanitizeBranch(branch);
|
|
481
|
-
if (!safeBranch)
|
|
2006
|
+
async function assertCommitIsVisible(workspacePath, sha, branch, baseBranch, selector) {
|
|
2007
|
+
const safeBranch = branch ? sanitizeBranch(branch) : null;
|
|
2008
|
+
if (branch && !safeBranch)
|
|
482
2009
|
throw new Error('Invalid branch name');
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (reachable)
|
|
489
|
-
return;
|
|
2010
|
+
const always = [];
|
|
2011
|
+
if (safeBranch) {
|
|
2012
|
+
always.push(safeBranch);
|
|
2013
|
+
const { baseRef } = await resolveBase(workspacePath, safeBranch, baseBranch);
|
|
2014
|
+
always.push(assertRefArgument(baseRef));
|
|
490
2015
|
}
|
|
491
|
-
|
|
2016
|
+
// Session 14 widens this by exactly the refs the LOG was allowed to walk —
|
|
2017
|
+
// no wider. Without a selector it stays the session's two refs, which is what
|
|
2018
|
+
// closed the QA-105 hole: naming the sha of a commit thrown away with
|
|
2019
|
+
// `reset --hard` used to read it straight out of the object store.
|
|
2020
|
+
const scope = selector
|
|
2021
|
+
? resolveRefScope(selector, always)
|
|
2022
|
+
: { refs: always, namespaces: [], namespacePaths: [] };
|
|
2023
|
+
if (scope.refs.length === 0 && scope.namespacePaths.length === 0) {
|
|
2024
|
+
throw new Error('That commit is not part of this history');
|
|
2025
|
+
}
|
|
2026
|
+
if (await reachableFromScope(workspacePath, sha, scope))
|
|
2027
|
+
return;
|
|
2028
|
+
throw new Error(safeBranch
|
|
2029
|
+
? 'That commit is not part of this session history'
|
|
2030
|
+
: 'That commit is not reachable from any branch, remote or tag being shown');
|
|
492
2031
|
}
|
|
493
2032
|
/**
|
|
494
2033
|
* One commit: its metadata and the files it touched, or — when `filePath` is
|
|
@@ -499,11 +2038,17 @@ async function assertCommitIsVisible(workspacePath, sha, branch) {
|
|
|
499
2038
|
* parents, and the answer to "what did this merge bring onto my branch" comes
|
|
500
2039
|
* out empty. For an ordinary commit the flag changes nothing.
|
|
501
2040
|
*/
|
|
502
|
-
export async function gitShow(workspacePath, sha, filePath, branch) {
|
|
2041
|
+
export async function gitShow(workspacePath, sha, filePath, branch, baseBranch, selector) {
|
|
503
2042
|
if (!isCommitSha(sha))
|
|
504
2043
|
throw new Error('Invalid commit sha');
|
|
505
|
-
|
|
506
|
-
|
|
2044
|
+
// A read with no visibility scope at all is refused rather than allowed: the
|
|
2045
|
+
// workspace-level graph (session 14) has no session branch, and «no branch
|
|
2046
|
+
// given» used to mean «skip the check», which would have handed that route
|
|
2047
|
+
// every object in the repository.
|
|
2048
|
+
if (branch === undefined && selector === undefined) {
|
|
2049
|
+
throw new Error('A commit read must name the history it belongs to');
|
|
2050
|
+
}
|
|
2051
|
+
await assertCommitIsVisible(workspacePath, sha, branch, baseBranch, selector);
|
|
507
2052
|
if (filePath !== undefined) {
|
|
508
2053
|
const relPath = safeHistoryPath(filePath);
|
|
509
2054
|
await assertPathIsBlob(workspacePath, sha, relPath);
|
|
@@ -520,7 +2065,8 @@ export async function gitShow(workspacePath, sha, filePath, branch) {
|
|
|
520
2065
|
git(workspacePath, 'show', '--format=', '--numstat', '-M', '--diff-merges=first-parent', sha, '--'),
|
|
521
2066
|
git(workspacePath, 'show', '--format=', '--name-status', '-M', '--diff-merges=first-parent', sha, '--'),
|
|
522
2067
|
]));
|
|
523
|
-
|
|
2068
|
+
// ⚠️ Positional — mirrors SHOW_META_FORMAT exactly.
|
|
2069
|
+
const [fullSha, parents, author, email, date, committer, committerEmail, committerDate, decoration, subject, ...bodyParts] = metaRaw.split('\0');
|
|
524
2070
|
if (!fullSha)
|
|
525
2071
|
throw new Error('Commit not found');
|
|
526
2072
|
const numstat = new Map();
|
|
@@ -560,24 +2106,59 @@ export async function gitShow(workspacePath, sha, filePath, branch) {
|
|
|
560
2106
|
author: maskString(author ?? '').slice(0, 200),
|
|
561
2107
|
authorEmail: maskString(email ?? '').slice(0, 200),
|
|
562
2108
|
date: (date ?? '').slice(0, 40),
|
|
2109
|
+
committer: maskString(committer ?? '').slice(0, 200),
|
|
2110
|
+
committerEmail: maskString(committerEmail ?? '').slice(0, 200),
|
|
2111
|
+
committerDate: (committerDate ?? '').slice(0, 40),
|
|
563
2112
|
subject: maskString(subject ?? '').slice(0, SUBJECT_CAP),
|
|
564
2113
|
// The body may itself contain NULs' worth of sections — everything after
|
|
565
|
-
// the
|
|
2114
|
+
// the last named separator belongs to it.
|
|
566
2115
|
body: maskString(bodyParts.join('\0')).slice(0, BODY_CAP).trimEnd(),
|
|
567
|
-
refs: parseRefs(decoration ?? ''),
|
|
2116
|
+
refs: parseRefs(decoration ?? '', await remoteNames(workspacePath)),
|
|
568
2117
|
files,
|
|
569
2118
|
truncated,
|
|
570
2119
|
};
|
|
571
2120
|
}
|
|
572
|
-
|
|
573
|
-
if (
|
|
2121
|
+
async function revertApplyImpl(workspacePath, commitSha, expectedBase) {
|
|
2122
|
+
if (!isCommitSha(commitSha)) {
|
|
574
2123
|
return { reverted: false, error: 'Invalid commit sha' };
|
|
575
2124
|
}
|
|
2125
|
+
// The same drift guard «Apply» has (QA-107): a revert commit lands on
|
|
2126
|
+
// whatever branch the project folder is on, and a folder that moved would
|
|
2127
|
+
// take the revert with it. Reachability alone is not enough — a feature
|
|
2128
|
+
// branch cut after the apply contains the commit too.
|
|
2129
|
+
const safeBase = expectedBase ? sanitizeBranch(expectedBase) : null;
|
|
2130
|
+
if (safeBase) {
|
|
2131
|
+
const on = await git(workspacePath, 'rev-parse', '--abbrev-ref', 'HEAD').catch(() => null);
|
|
2132
|
+
if (on !== safeBase) {
|
|
2133
|
+
return {
|
|
2134
|
+
reverted: false,
|
|
2135
|
+
error: `The project folder is on ${on ?? 'another branch'}, but this work was applied to ${safeBase} — switch it back first`,
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
576
2139
|
const dirty = await git(workspacePath, 'status', '--porcelain');
|
|
577
2140
|
if (dirty) {
|
|
2141
|
+
const paths = porcelainPaths(dirty);
|
|
578
2142
|
return {
|
|
579
2143
|
reverted: false,
|
|
580
|
-
|
|
2144
|
+
workspaceDirty: true,
|
|
2145
|
+
workspaceDirtyFiles: paths.slice(0, WORKSPACE_DIRTY_SAMPLE),
|
|
2146
|
+
workspaceDirtyCount: paths.length,
|
|
2147
|
+
error: WORKSPACE_DIRTY_MESSAGE,
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
// Until session 13 the sha was hard-wired to the session's own apply commit,
|
|
2151
|
+
// so «which commit» was not a question. Now the API chooses it (undoing a
|
|
2152
|
+
// revert reverts the REVERT), which makes the runner the last place that can
|
|
2153
|
+
// check the choice is sane: only something already in the branch we are
|
|
2154
|
+
// standing on may be taken back out of it.
|
|
2155
|
+
const reachable = await git(workspacePath, 'merge-base', '--is-ancestor', commitSha, 'HEAD')
|
|
2156
|
+
.then(() => true)
|
|
2157
|
+
.catch(() => false);
|
|
2158
|
+
if (!reachable) {
|
|
2159
|
+
return {
|
|
2160
|
+
reverted: false,
|
|
2161
|
+
error: 'That commit is not in the branch the project folder is on',
|
|
581
2162
|
};
|
|
582
2163
|
}
|
|
583
2164
|
try {
|
|
@@ -593,4 +2174,44 @@ export async function revertApply(workspacePath, commitSha) {
|
|
|
593
2174
|
}
|
|
594
2175
|
return { reverted: true, revertSha: await git(workspacePath, 'rev-parse', 'HEAD') };
|
|
595
2176
|
}
|
|
2177
|
+
// ─── Writes bump the tree generation (QA-111 M2) ─────────────────────
|
|
2178
|
+
//
|
|
2179
|
+
// Wrappers rather than a line inside each body: every one of these has several
|
|
2180
|
+
// early returns and can throw, and «the tree may have changed» is true on all
|
|
2181
|
+
// of those paths — a conflicted pull leaves a modified tree, a refused discard
|
|
2182
|
+
// may have removed some paths before it stopped. Wrapping is the only shape
|
|
2183
|
+
// that cannot be forgotten on a branch added later.
|
|
2184
|
+
//
|
|
2185
|
+
// The bump happens AFTER the call settles: a read arriving mid-write has
|
|
2186
|
+
// nothing newer to be given, and starting a second scan against a tree being
|
|
2187
|
+
// modified only spends the machine this was written to spare.
|
|
2188
|
+
function afterWrite(fn, treeOf) {
|
|
2189
|
+
return async (...args) => {
|
|
2190
|
+
try {
|
|
2191
|
+
return await fn(...args);
|
|
2192
|
+
}
|
|
2193
|
+
finally {
|
|
2194
|
+
for (const tree of treeOf(...args))
|
|
2195
|
+
bumpTreeGeneration(tree);
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
}
|
|
2199
|
+
export const gitCommit = afterWrite(gitCommitImpl, (worktreePath) => [worktreePath]);
|
|
2200
|
+
export const gitStage = afterWrite(gitStageImpl, (input) => [input.worktreePath]);
|
|
2201
|
+
export const gitUnstage = afterWrite(gitUnstageImpl, (input) => [input.worktreePath]);
|
|
2202
|
+
export const gitDiscard = afterWrite(gitDiscardImpl, (input) => [input.worktreePath]);
|
|
2203
|
+
export const gitPull = afterWrite(gitPullImpl, (input) => [input.worktreePath]);
|
|
2204
|
+
export const gitMergeAbort = afterWrite(gitMergeAbortImpl, (worktreePath) => [worktreePath]);
|
|
2205
|
+
// Apply and revert write to the PROJECT folder, and apply reads the session
|
|
2206
|
+
// worktree on the way — both trees are named so either panel refreshes true.
|
|
2207
|
+
export const applySession = afterWrite(applySessionImpl, (input) => [
|
|
2208
|
+
input.workspacePath,
|
|
2209
|
+
input.worktreePath,
|
|
2210
|
+
]);
|
|
2211
|
+
export const revertApply = afterWrite(revertApplyImpl, (workspacePath) => [workspacePath]);
|
|
2212
|
+
export const updateFromBase = afterWrite(updateFromBaseImpl, (input) => [
|
|
2213
|
+
input.worktreePath,
|
|
2214
|
+
input.workspacePath,
|
|
2215
|
+
]);
|
|
2216
|
+
export const gitPush = afterWrite(gitPushImpl, (input) => [input.workspacePath]);
|
|
596
2217
|
//# sourceMappingURL=gitops.js.map
|