omp-conductor 0.3.3 → 0.3.5

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/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,