omp-conductor 0.3.4 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/setup.ts CHANGED
@@ -24,6 +24,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import { dirname, join } from "node:path";
26
26
  import { configPath, resolveCaps, stateDir } from "./config.ts";
27
+ import { graphProjectPath, graphRepos } from "./graph.ts";
27
28
  import {
28
29
  CONFIG_VERSION,
29
30
  DEFAULT_AUTHORITY,
@@ -83,6 +84,17 @@ export interface SetupAnswers {
83
84
  * that session to drain, rather than starting a second brain.
84
85
  */
85
86
  orchestratorMode: OrchestratorMode;
87
+ /**
88
+ * Parent directory of the index-only clones workers query, or absent when the
89
+ * operator declined code-graph discovery — in which case no repo gets a
90
+ * `graphProject` and every rendered brief is the one this package shipped
91
+ * before graphs existed.
92
+ *
93
+ * One answer for the whole project rather than one per repo: the clones are
94
+ * derived data with no reason to live apart, and a per-repo prompt would ask
95
+ * the same question four times to arrive at four siblings.
96
+ */
97
+ graphRoot?: string;
86
98
  }
87
99
 
88
100
  /** What `gh auth status` says the active token may do. */
@@ -392,18 +404,27 @@ export async function createMissingLabels(trackerRepo: string, plan: LabelPlan[]
392
404
  *
393
405
  * Split out of `buildConfig` so the plan summary can show the exact project
394
406
  * that would be written — including the derived worktree and mirror paths —
395
- * without assembling a whole config and indexing back into its array.
407
+ * without assembling a whole config and indexing back into its array. Exported
408
+ * for the amend summary's before-and-after, and so a test can pin its round-trip
409
+ * with {@link answersFromProject} — the pair an amend's carry-through rests on.
396
410
  */
397
- function buildProject(a: SetupAnswers): ProjectConfig {
411
+ export function buildProject(a: SetupAnswers): ProjectConfig {
398
412
  const dir = stateDir();
399
413
 
400
414
  const repos: Record<string, RepoTarget> = {};
415
+ const graphRoot = a.graphRoot?.trim();
401
416
  for (const r of a.targetRepos) {
402
417
  repos[r.name] = {
403
418
  name: r.name,
404
419
  cloneUrl: r.cloneUrl,
405
420
  defaultBranch: r.defaultBranch,
406
421
  gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
422
+ // One answered root becomes one clone per routed repo. Omitted entirely
423
+ // when unanswered rather than written empty: the key's absence is what
424
+ // makes an existing config's briefs render exactly as they did before.
425
+ ...(graphRoot === undefined || graphRoot.length === 0
426
+ ? {}
427
+ : { graphProject: graphProjectPath(graphRoot, r.name) }),
407
428
  };
408
429
  }
409
430
 
@@ -467,6 +488,89 @@ export function buildConfig(a: SetupAnswers, existing?: ConductorConfig): Conduc
467
488
  };
468
489
  }
469
490
 
491
+ /**
492
+ * The seed a brand-new project starts from: the shipped defaults, and nothing
493
+ * answered yet.
494
+ *
495
+ * Exists so "what does an unanswered field start as" has exactly one spelling.
496
+ * The wizard pre-fills every prompt from an answers object — this one on a first
497
+ * run, {@link answersFromProject} on a re-run — rather than reaching for a
498
+ * default at each prompt, which is how one prompt comes to disagree with the
499
+ * config key it writes.
500
+ */
501
+ export function defaultAnswers(projectName: string): SetupAnswers {
502
+ return {
503
+ projectName,
504
+ // Empty rather than a plausible guess: both are required, and a pre-filled
505
+ // tracker repo is the one default an operator would Enter straight past.
506
+ trackerRepo: "",
507
+ queueLabel: SETUP_DEFAULTS.queueLabel,
508
+ stateLabels: { ...SETUP_DEFAULTS.stateLabels },
509
+ routingLabelPrefix: SETUP_DEFAULTS.routingLabelPrefix,
510
+ targetRepos: [],
511
+ caps: {},
512
+ fallbackToIssueComment: true,
513
+ authority: { ...SETUP_DEFAULTS.authority },
514
+ orchestratorMode: SETUP_DEFAULTS.orchestratorMode,
515
+ reportScope: DEFAULT_REPORT_SCOPE,
516
+ writeOrchestratorBrief: false,
517
+ };
518
+ }
519
+
520
+ /**
521
+ * The answers that describe a project already on disk — the inverse of
522
+ * {@link buildProject}, and what makes amending one area possible.
523
+ *
524
+ * Every field is derived here, in one function, rather than field by field at
525
+ * each prompt: an amend asks one area's questions and carries everything else
526
+ * through untouched, so anything this forgets is a setting the operator loses by
527
+ * changing an unrelated one. `buildProject(answersFromProject(p))` is pinned to
528
+ * `p` by a test for exactly that reason.
529
+ *
530
+ * Three fields cannot be a straight copy:
531
+ *
532
+ * - `writeOrchestratorBrief` is a decision rather than a value, and it starts
533
+ * `false` so an amend that never visits the brief area leaves that file alone.
534
+ * - `reportScope` reads through {@link DEFAULT_REPORT_SCOPE}, because the key is
535
+ * optional on disk. A config written before it existed gains it explicitly on
536
+ * the next write, saying what it already meant.
537
+ * - `graphRoot` is one answer for a whole project while the config stores one
538
+ * path per repo, so it comes back from whichever repo already has one. Repos
539
+ * that disagree — only a hand-edit can produce that — widen to all of them on
540
+ * the next write exactly as a full re-run would, and the plan summary names
541
+ * every clone before anything is written.
542
+ */
543
+ export function answersFromProject(p: ProjectConfig): SetupAnswers {
544
+ const answers: SetupAnswers = {
545
+ projectName: p.name,
546
+ trackerRepo: p.tracker.repo,
547
+ queueLabel: p.queueLabel,
548
+ stateLabels: { ...p.stateLabels },
549
+ routingLabelPrefix: p.routing.labelPrefix,
550
+ targetRepos: Object.values(p.routing.repos).map((r) => ({
551
+ name: r.name,
552
+ cloneUrl: r.cloneUrl,
553
+ defaultBranch: r.defaultBranch,
554
+ gates: r.gates.map((g) => ({ cmd: g.cmd, cwd: g.cwd })),
555
+ })),
556
+ caps: { ...p.caps },
557
+ fallbackToIssueComment: p.escalation.fallbackToIssueComment,
558
+ authority: { ...p.authority },
559
+ orchestratorMode: p.escalation.orchestrator,
560
+ reportScope: p.reporting?.scope ?? DEFAULT_REPORT_SCOPE,
561
+ writeOrchestratorBrief: false,
562
+ };
563
+
564
+ // Set only when present, never as an explicit `undefined`: an absent key is
565
+ // what keeps the rewritten config identical to the one that was read.
566
+ if (p.workerModel !== undefined) answers.workerModel = p.workerModel;
567
+ if (p.escalation.telegramChatId !== undefined) answers.telegramChatId = p.escalation.telegramChatId;
568
+ const graphed = graphRepos(p)[0];
569
+ if (graphed !== undefined) answers.graphRoot = dirname(graphed.graphProject);
570
+
571
+ return answers;
572
+ }
573
+
470
574
  /**
471
575
  * Where a configured project's brief lives: beside its worktrees, under the state
472
576
  * directory, so it is on the same disk the fleet already owns and survives a
@@ -694,6 +798,19 @@ export function summarisePlan(
694
798
  }
695
799
  }
696
800
 
801
+ // Absent entirely when unanswered: a plan for a project with no graph must
802
+ // read exactly as it did before graphs were a thing this wizard could offer.
803
+ const graphed = graphRepos(project);
804
+ if (graphed.length > 0) {
805
+ lines.push("", "code graph workers query these clones instead of grepping:");
806
+ for (const r of graphed) lines.push(` ${r.name} ${r.graphProject}`);
807
+ lines.push(
808
+ " conductor's own index-only clones — nothing human edits them, and",
809
+ " nothing here creates them. Run `omp-conductor graph-setup` after",
810
+ " setup: it prints the clone, index and systemd-timer commands.",
811
+ );
812
+ }
813
+
697
814
  lines.push("", "caps (effective)");
698
815
  for (const [key, value] of Object.entries(effective)) {
699
816
  const answered = Object.hasOwn(project.caps, key) ? " (answered)" : "";
@@ -761,3 +878,199 @@ export function summarisePlan(
761
878
 
762
879
  return lines.join("\n");
763
880
  }
881
+
882
+ /**
883
+ * Gates as the wizard both shows and reads them back: `cmd`, or `cmd @ cwd` when
884
+ * one runs from a subdirectory. One spelling, so the pre-filled prompt line and
885
+ * the amend menu's current value cannot drift apart.
886
+ */
887
+ export function formatGates(gates: readonly { cmd: string; cwd: string }[]): string {
888
+ return gates.map((g) => (g.cwd === "." ? g.cmd : `${g.cmd} @ ${g.cwd}`)).join(", ");
889
+ }
890
+
891
+ /**
892
+ * The wizard's questions, grouped as the areas a re-run can amend one of, in the
893
+ * order the full interview asks them.
894
+ *
895
+ * Data rather than a switch so the menu, the exhaustiveness of the dialog table
896
+ * in ./plugin.ts, and the amend summary all enumerate the same eight areas: an
897
+ * added area fails to compile until it has a name, a current value and a set of
898
+ * questions.
899
+ */
900
+ export const AMEND_AREA_IDS = [
901
+ "tracker",
902
+ "gates",
903
+ "caps",
904
+ "graph",
905
+ "authority",
906
+ "escalation",
907
+ "reporting",
908
+ "brief",
909
+ ] as const;
910
+
911
+ export type AmendAreaId = (typeof AMEND_AREA_IDS)[number];
912
+
913
+ /**
914
+ * The pure half of amend mode: what each area is called, what choosing it asks,
915
+ * and what it says right now.
916
+ *
917
+ * `describe` is the reason the pick-list is worth anything — an operator picking
918
+ * blind from eight nouns cannot tell which one holds the setting they came to
919
+ * change, so every row carries its own current value. It reads only the config,
920
+ * so the whole menu can be rendered and reviewed without a terminal.
921
+ */
922
+ export const AMEND_AREAS: {
923
+ readonly [K in AmendAreaId]: {
924
+ readonly name: string;
925
+ readonly asks: string;
926
+ readonly describe: (p: ProjectConfig) => string;
927
+ };
928
+ } = {
929
+ tracker: {
930
+ name: "tracker & repos",
931
+ asks: "tracker repo, queue and state labels, routing prefix, then every routed repo with its gates",
932
+ describe: (p) => {
933
+ const names = Object.values(p.routing.repos).map((r) => r.name);
934
+ return (
935
+ `${p.tracker.repo}, queue "${p.queueLabel}", ` +
936
+ `"${p.routing.labelPrefix}" → ${names.join(", ") || "no repos"}`
937
+ );
938
+ },
939
+ },
940
+ gates: {
941
+ name: "gates",
942
+ asks: "the pre-push commands for each configured repo, and nothing else",
943
+ describe: (p) => {
944
+ const repos = Object.values(p.routing.repos);
945
+ if (repos.length === 0) return "no repos configured";
946
+ return repos.map((r) => `${r.name}: ${r.gates.length === 0 ? "none" : formatGates(r.gates)}`).join("; ");
947
+ },
948
+ },
949
+ caps: {
950
+ // The model rides with the caps because it is the other per-worker knob, and
951
+ // an area no menu offers is a setting only a full re-interview can reach.
952
+ name: "caps & worker model",
953
+ asks: "concurrency, spend, turns, wall clock, attempts per issue — then the worker model",
954
+ describe: (p) => {
955
+ const c = resolveCaps(p, DEFAULT_CAPS);
956
+ const answered = Object.keys(p.caps).length > 0;
957
+ return (
958
+ `${c.maxConcurrentWorkers} workers, ${c.workerMaxTurns} turns, ` +
959
+ `${Math.round(c.workerWallClockMs / 60000)}m, $${c.dailySpendUsd}/day, ` +
960
+ `${c.maxAttemptsPerIssue} attempts${answered ? "" : " (all defaults)"} — ` +
961
+ `${p.workerModel === undefined ? "harness default model" : `model ${p.workerModel}`}`
962
+ );
963
+ },
964
+ },
965
+ graph: {
966
+ name: "code graph",
967
+ asks: "whether workers query a code-graph index, and the root its one-clone-per-repo lives under",
968
+ describe: (p) => {
969
+ const graphed = graphRepos(p);
970
+ const first = graphed[0];
971
+ if (first === undefined) return "not configured — workers grep";
972
+ return `${dirname(first.graphProject)} — ${graphed.length} clone(s): ${graphed.map((r) => r.name).join(", ")}`;
973
+ },
974
+ },
975
+ authority: {
976
+ name: "authority",
977
+ asks: "who lands green PRs, and who cuts releases",
978
+ describe: (p) => `merge=${p.authority.merge}, release=${p.authority.release}`,
979
+ },
980
+ escalation: {
981
+ name: "escalation & triage",
982
+ asks: "the tier-2 Telegram chat, whether escalations also comment, and where the orchestrator session lives",
983
+ describe: (p) =>
984
+ [
985
+ p.escalation.telegramChatId === undefined
986
+ ? "tier 2 by issue comment only"
987
+ : `tier 2 pages Telegram ${p.escalation.telegramChatId}`,
988
+ p.escalation.fallbackToIssueComment ? "comments too" : "no comment fallback",
989
+ `triage ${p.escalation.orchestrator}`,
990
+ ].join(", "),
991
+ },
992
+ reporting: {
993
+ name: "reporting scope",
994
+ asks: "how much the orchestrator says unprompted",
995
+ describe: (p) => {
996
+ const scope = p.reporting?.scope ?? DEFAULT_REPORT_SCOPE;
997
+ const choice = REPORT_SCOPE_CHOICES.find((c) => c.scope === scope);
998
+ return `${scope} — ${choice?.description ?? "unknown scope"}`;
999
+ },
1000
+ },
1001
+ brief: {
1002
+ name: "orchestrator brief",
1003
+ asks: `whether to render ${ORCHESTRATOR_BRIEF_NAME} — the one area that writes no config key`,
1004
+ describe: (p) => {
1005
+ const path = briefPathForProject(p);
1006
+ return existsSync(path) ? `written at ${path}` : `none at ${path}`;
1007
+ },
1008
+ },
1009
+ };
1010
+
1011
+ /**
1012
+ * How much of a current value fits on a menu row before it costs more than it
1013
+ * tells. Chosen so a four-repo fleet's tracker row — the longest one worth
1014
+ * keeping whole — survives intact. The full text is never lost either way: the
1015
+ * amend summary prints it unelided, and the area's own prompts pre-fill from it.
1016
+ */
1017
+ const AMEND_LABEL_MAX = 96;
1018
+
1019
+ /**
1020
+ * The amend pick-list, rendered.
1021
+ *
1022
+ * The label carries the current value because the harness's select resolves to
1023
+ * the label it showed, so the row an operator picked has to be recognisable from
1024
+ * its own text alone — and it is the value, not the noun, that tells them
1025
+ * whether this is the row they came for.
1026
+ */
1027
+ export function amendChoices(p: ProjectConfig): { id: AmendAreaId; label: string; description: string }[] {
1028
+ return AMEND_AREA_IDS.map((id) => {
1029
+ const area = AMEND_AREAS[id];
1030
+ const current = area.describe(p);
1031
+ return {
1032
+ id,
1033
+ label: `${area.name} — ${current.length > AMEND_LABEL_MAX ? `${current.slice(0, AMEND_LABEL_MAX - 1).trimEnd()}…` : current}`,
1034
+ description: area.asks,
1035
+ };
1036
+ });
1037
+ }
1038
+
1039
+ /**
1040
+ * What an amend leads its consent screen with: the area, what it said, what it
1041
+ * would say, and the seven areas nobody was asked about.
1042
+ *
1043
+ * The whole plan still follows this, because the confirm has to name every
1044
+ * mutation it authorises — creating labels, writing the config, replacing a
1045
+ * brief — and a delta alone names none of them. What this adds is the sentence
1046
+ * the operator is actually looking for: one area changed, everything else came
1047
+ * back off disk.
1048
+ */
1049
+ export function summariseAmend(area: AmendAreaId, before: ProjectConfig, a: SetupAnswers): string {
1050
+ const it = AMEND_AREAS[area];
1051
+ const was = it.describe(before);
1052
+ // The brief is a decision, not a config key, so its "after" is what the wizard
1053
+ // is about to do rather than what a rebuilt project would say.
1054
+ const now =
1055
+ area === "brief"
1056
+ ? a.writeOrchestratorBrief
1057
+ ? `would ${existsSync(orchestratorBriefPath(a)) ? "OVERWRITE" : "write"} ${orchestratorBriefPath(a)}`
1058
+ : "not written — left exactly as it is"
1059
+ : it.describe(buildProject(a));
1060
+
1061
+ const others = AMEND_AREA_IDS.filter((o) => o !== area).map((o) => AMEND_AREAS[o].name);
1062
+ const lines = [`amending ${it.name} — project ${before.name}`];
1063
+ if (was === now) {
1064
+ lines.push(` no change ${was}`, " you answered through without changing anything here");
1065
+ } else {
1066
+ lines.push(` was ${was}`, ` now ${now}`);
1067
+ }
1068
+ lines.push(
1069
+ ` carried over ${others.join(", ")}`,
1070
+ ` read back from ${configPath()} and rewritten unchanged`,
1071
+ "",
1072
+ "The whole project as it would then be written:",
1073
+ "",
1074
+ );
1075
+ return lines.join("\n");
1076
+ }
package/src/types.ts CHANGED
@@ -46,6 +46,31 @@ export interface RepoTarget {
46
46
  * subset lets lint errors outside the source dir reach the runners.
47
47
  */
48
48
  gates: { cmd: string; cwd: string }[];
49
+ /**
50
+ * Absolute path of the **conductor-owned, index-only clone** of this repo
51
+ * whose code-graph index workers query. Optional: absent means this repo has
52
+ * no graph, and the worker brief says nothing about one.
53
+ *
54
+ * Two things it deliberately is not, and both were paid for:
55
+ *
56
+ * - **Not a worker's worktree.** A code-graph index is keyed by the realpath
57
+ * of the directory it was built from, with no git-worktree awareness, so a
58
+ * run's throwaway `worktrees/<issue>` path is always an empty project. A
59
+ * worker that queried its own cwd would find nothing, conclude there is no
60
+ * graph, and go back to grepping — which is the entire cost this field
61
+ * exists to remove.
62
+ * - **Not a human's checkout.** Refreshing an index means hard-resetting the
63
+ * clone to its default branch. Doing that where somebody works destroys
64
+ * their uncommitted edits; making it safe instead (a fast-forward pull)
65
+ * means the graph reflects whatever feature branch they left checked out.
66
+ * So this names a disposable clone nothing human ever edits, which is what
67
+ * makes the reset both safe and deterministic.
68
+ *
69
+ * `omp-conductor graph-setup` prints how to create and refresh it. Nothing in
70
+ * this package reads an index itself: the daemon only passes this path into
71
+ * the worker brief.
72
+ */
73
+ graphProject?: string;
49
74
  }
50
75
 
51
76
  /**
package/src/worktree.ts CHANGED
@@ -8,8 +8,8 @@
8
8
  * for the delta.
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, rmSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
13
 
14
14
  import type { RepoTarget } from "./types.ts";
15
15
 
@@ -96,6 +96,50 @@ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
96
96
  return code === 0;
97
97
  }
98
98
 
99
+ /** Fences the block below so it can be found, replaced, and never duplicated. */
100
+ const EXCLUDE_BEGIN = "# >>> omp-conductor (managed; edit outside this block)";
101
+ const EXCLUDE_END = "# <<< omp-conductor";
102
+
103
+ /**
104
+ * Appended to every mirror's `info/exclude`, and so in force in every worktree
105
+ * cut from it. Deliberately the same shapes salvage therefore skips, and
106
+ * for the same reason — a worker's own scaffolding is not the repo's business.
107
+ *
108
+ * Two layers because they catch different moments: this one keeps scratch out
109
+ * of a worker's own `git add -A` and out of its `git status`, which salvage
110
+ * never observes; the salvage list catches whatever a worker created before
111
+ * this landed, or wrote past an ignore with `add -f`.
112
+ */
113
+ const LOCAL_EXCLUDE = [".scratch*/", ".scratch*", ".env.local", "*.local.sh"];
114
+
115
+ /**
116
+ * Adds the managed block to an `info/exclude`, preserving everything else.
117
+ *
118
+ * `info/exclude` is a *local* ignore file, which means it is exactly where an
119
+ * operator or another tool puts patterns they could not put in the tracked
120
+ * `.gitignore` — so overwriting it would silently destroy work that has no
121
+ * other copy. The block is fenced and replaced in place, so re-running this on
122
+ * every dispatch neither duplicates our lines nor disturbs theirs.
123
+ */
124
+ export function mergeExclude(existing: string): string {
125
+ const begin = existing.indexOf(EXCLUDE_BEGIN);
126
+ const end = existing.indexOf(EXCLUDE_END);
127
+ const theirs =
128
+ begin === -1 || end === -1 || end < begin
129
+ ? existing
130
+ : existing.slice(0, begin) + existing.slice(end + EXCLUDE_END.length + 1);
131
+
132
+ // Normalised before recomposing, so the result is byte-identical on every
133
+ // call. Trimming the tail matters twice over: a hand-edited file often has no
134
+ // trailing newline (the first managed line would glue onto their last
135
+ // pattern and match nothing), and without it the blank separator below would
136
+ // accumulate one more newline on each of the thousands of dispatches that
137
+ // call this.
138
+ const body = theirs.replace(/\n+$/, "");
139
+ const head = body === "" ? [] : [body, ""];
140
+ return [...head, EXCLUDE_BEGIN, ...LOCAL_EXCLUDE, EXCLUDE_END, ""].join("\n");
141
+ }
142
+
99
143
  /**
100
144
  * Rewrites the two `clone --mirror` defaults that are actively dangerous for a
101
145
  * cache we cut worktrees from. Applied on every `ensureMirror` so a mirror left
@@ -122,6 +166,20 @@ async function configureMirror(mirrorPath: string): Promise<void> {
122
166
  ["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC],
123
167
  mirrorPath,
124
168
  );
169
+
170
+ // A mirror's `info/exclude` is the common git dir for every worktree cut from
171
+ // it, so one write here keeps a worker's own scratch out of `git status` in
172
+ // all of them — without touching the repo's tracked `.gitignore`, which is
173
+ // the operator's file and not ours to edit.
174
+ //
175
+ // Belt and braces with the salvage excludes rather than a replacement for
176
+ // them: this stops scratch reaching a *worker's* own `git add`, which salvage
177
+ // never sees. Merged rather than written, because `info/exclude` is precisely
178
+ // where an operator keeps patterns that cannot go in the tracked file — and
179
+ // this runs on every dispatch, so overwriting would destroy them repeatedly.
180
+ const exclude = join(mirrorPath, "info", "exclude");
181
+ mkdirSync(dirname(exclude), { recursive: true });
182
+ writeFileSync(exclude, mergeExclude(existsSync(exclude) ? readFileSync(exclude, "utf8") : ""));
125
183
  }
126
184
 
127
185
  /**
@@ -343,7 +401,28 @@ export async function salvageWip(
343
401
  const branch = await git(["rev-parse", "--abbrev-ref", "HEAD"], worktree);
344
402
 
345
403
  // `-A` on purpose: the losses this exists for were mostly *new* files.
404
+ //
405
+ // Filtering scratch is git's job, not a pathspec's. The mirror's
406
+ // `info/exclude` (see {@link mergeExclude}) is the common git dir for this
407
+ // worktree, and git's own rules then give exactly the semantics needed:
408
+ // untracked ignored files are skipped, while modifications to *tracked*
409
+ // files are staged even when the name matches an ignore. That second half
410
+ // is why an exclude pathspec here was wrong — it matched on filename alone,
411
+ // so a repo legitimately versioning a `bootstrap.local.sh` would have lost
412
+ // a worker's edits to it, salvage destroying the work it exists to save.
413
+ //
414
+ // A literal `:(exclude)<path>` was also an outright bug: git counts it as
415
+ // naming the path, so an already-ignored file made `add` exit 1 and every
416
+ // cap-kill would have reported a salvage *failure*.
346
417
  await git(["add", "-A"], worktree);
418
+
419
+ // The dirty check above ran before git applied its ignores, so a tree whose
420
+ // only changes were ignored scratch had work by that test and none by this.
421
+ // Without this, `commit` exits non-zero on an empty index and a tree
422
+ // holding nothing worth keeping gets reported as a salvage *failure*.
423
+ if ((await git(["diff", "--cached", "--name-only"], worktree)) === "") {
424
+ return { kind: "nothing" };
425
+ }
347
426
  await git(
348
427
  [
349
428
  ...SALVAGE_COMMIT_CONFIG,