@bridge4dev/runner 0.11.0 → 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.
Files changed (45) hide show
  1. package/dist/adapters/claude.d.ts +15 -7
  2. package/dist/adapters/claude.js +1024 -70
  3. package/dist/adapters/codex.d.ts +18 -3
  4. package/dist/adapters/codex.js +224 -65
  5. package/dist/adapters/questions.d.ts +42 -0
  6. package/dist/adapters/questions.js +86 -0
  7. package/dist/adapters/types.d.ts +200 -4
  8. package/dist/attachments.d.ts +8 -1
  9. package/dist/attachments.js +22 -4
  10. package/dist/auto-resume.d.ts +18 -0
  11. package/dist/auto-resume.js +104 -0
  12. package/dist/commit-message.d.ts +51 -0
  13. package/dist/commit-message.js +224 -0
  14. package/dist/config.d.ts +29 -6
  15. package/dist/config.js +15 -0
  16. package/dist/crash-note.d.ts +54 -0
  17. package/dist/crash-note.js +105 -0
  18. package/dist/git.d.ts +71 -0
  19. package/dist/git.js +207 -10
  20. package/dist/gitops.d.ts +489 -12
  21. package/dist/gitops.js +1717 -96
  22. package/dist/index.js +435 -32
  23. package/dist/paths.d.ts +26 -0
  24. package/dist/paths.js +34 -0
  25. package/dist/policy.d.ts +63 -0
  26. package/dist/policy.js +412 -10
  27. package/dist/protocol.d.ts +382 -60
  28. package/dist/protocol.js +104 -1
  29. package/dist/recipe-schema.d.ts +310 -0
  30. package/dist/recipe-schema.js +103 -0
  31. package/dist/recipe.d.ts +94 -0
  32. package/dist/recipe.js +238 -0
  33. package/dist/self-update.d.ts +7 -0
  34. package/dist/self-update.js +171 -23
  35. package/dist/service-unit.d.ts +79 -0
  36. package/dist/service-unit.js +211 -0
  37. package/dist/supervisor.d.ts +108 -1
  38. package/dist/supervisor.js +1010 -56
  39. package/dist/verify-queue.d.ts +17 -0
  40. package/dist/verify-queue.js +100 -0
  41. package/dist/verify.d.ts +203 -0
  42. package/dist/verify.js +788 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +2 -2
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
- /** Pathspec magic characters — refused outright, belt to the flag's braces. */
35
- const PATHSPEC_MAGIC = /[*?[\]]|^:/;
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 base the session will be applied onto: the branch the workspace repo is
52
- * currently on (its HEAD commit is what merge-base is computed against). If
53
- * the workspace somehow sits on the session branch itself, fall back to
54
- * main/master.
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
- export async function gitStatus(worktreePath, workspacePath, sessionBranch) {
75
- const { baseBranch, baseRef } = await resolveBase(workspacePath, sessionBranch);
76
- const base = await mergeBase(worktreePath, baseRef);
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: sessionBranch,
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
- const { baseRef } = await resolveBase(workspacePath, sessionBranch);
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
- // No diff against the base. The /dev/null fallback shows file CONTENT, so
250
- // it is allowed only for genuinely UNTRACKED files (status `??`) — never
251
- // for unchanged tracked files and never for gitignored ones (QA-98 F1).
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
- export async function gitCommit(worktreePath, message) {
261
- const porcelain = await git(worktreePath, 'status', '--porcelain');
262
- if (!porcelain)
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
- await git(worktreePath, 'add', '-A');
265
- await git(worktreePath, 'commit', '-m', message);
266
- return { committed: true, sha: await git(worktreePath, 'rev-parse', 'HEAD') };
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
- export async function applySession(workspacePath, worktreePath, sessionBranch, message) {
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
- error: 'The workspace working copy has uncommitted changes — commit or stash them on the server first',
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
- await execFileAsync('git', [...GIT_GLOBAL_ARGS, 'merge', '--squash', sessionBranch], {
293
- cwd: workspacePath,
294
- timeout: MERGE_TIMEOUT_MS,
295
- maxBuffer: 8 * 1024 * 1024,
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
- await git(workspacePath, 'reset', '--merge').catch(() => undefined);
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
- if (isMergeConflict(error)) {
305
- return { applied: false, conflict: true, baseBranch: workspaceBranch };
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 { applied: true, commitSha: await git(workspacePath, 'rev-parse', 'HEAD') };
1259
+ return {
1260
+ applied: true,
1261
+ commitSha: await git(workspacePath, 'rev-parse', 'HEAD'),
1262
+ branchSha,
1263
+ baseShaBefore,
1264
+ baseBranch: workspaceBranch,
1265
+ };
317
1266
  }
318
- const LOG_FORMAT = '%H%x00%P%x00%an%x00%aI%x00%D%x00%s';
319
- const SHOW_META_FORMAT = '%H%x00%P%x00%an%x00%ae%x00%aI%x00%D%x00%s%x00%b';
320
- const MAX_LOG_LIMIT = 200;
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 (PATHSPEC_MAGIC.test(normalized)) {
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
- function parseRefs(decoration) {
376
- return decoration
377
- .split(',')
378
- .map((entry) => entry.trim())
379
- .filter(Boolean)
380
- .map((entry) => (entry.startsWith('HEAD -> ') ? entry.slice('HEAD -> '.length) : entry))
381
- .filter((entry) => entry !== 'HEAD')
382
- .slice(0, 10)
383
- .map((entry) => entry.slice(0, 200));
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 branch = requireSessionBranch(input.branch);
408
- const { baseBranch, baseRef } = await resolveBase(input.workspacePath, branch);
409
- assertRefArgument(baseRef);
410
- if (!(await branchExists(input.workspacePath, branch))) {
411
- throw new Error(`Branch ${branch} no longer exists in this repository`);
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 base = await git(input.workspacePath, 'merge-base', branch, baseRef).catch(() => null);
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
- // `branch --not baseRef` is exactly `base..branch`, but it does not depend on
422
- // merge-base succeeding: a repository where the two histories never met would
423
- // otherwise silently fall back to the WHOLE branch while the UI still said
424
- // «only what the agent committed» (QA-105).
425
- const range = scope === 'session'
426
- ? [branch, '--not', baseRef]
427
- : scope === 'branch'
428
- ? [branch]
429
- : [branch, baseRef];
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}`, `--format=${LOG_FORMAT}`, ...range, '--');
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
- const [sha, parents, author, date, decoration, subject] = line.split('\0');
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 { branch, baseBranch, mergeBase: base, commits, hasMore };
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 { baseRef } = await resolveBase(workspacePath, safeBranch);
484
- for (const ref of [safeBranch, assertRefArgument(baseRef)]) {
485
- const reachable = await git(workspacePath, 'merge-base', '--is-ancestor', sha, ref)
486
- .then(() => true)
487
- .catch(() => false);
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
- throw new Error('That commit is not part of this session history');
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
- if (branch !== undefined)
506
- await assertCommitIsVisible(workspacePath, sha, branch);
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
- const [fullSha, parents, author, email, date, decoration, subject, ...bodyParts] = metaRaw.split('\0');
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 seventh separator belongs to it.
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
- export async function revertApply(workspacePath, commitSha) {
573
- if (!/^[0-9a-f]{7,64}$/i.test(commitSha)) {
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
- error: 'The workspace working copy has uncommitted changes — commit or stash them on the server first',
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