@brainervirus/workit-core 7.6.0 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-fanout/SKILL.md +46 -46
- package/skills/workit-fanout/references/brief.md +15 -8
- package/skills/workit-fanout/references/orchestration.md +117 -0
- package/skills/workit-ship/SKILL.md +2 -2
- package/src/autonomy.ts +8 -6
- package/src/core/pr-create.ts +0 -232
- package/src/fanout-brief.ts +178 -0
- package/src/fanout-check.ts +2 -1
- package/src/fanout-landed.ts +41 -16
- package/src/fanout-status.ts +7 -3
- package/src/fanout-worktree.ts +89 -19
- package/src/fanout.ts +148 -3
- package/src/forge/pr-ops.ts +89 -28
- package/src/ledger.ts +203 -15
- package/src/stack.ts +45 -7
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `workit fanout brief <slice>`: the complete worker brief, rendered from the
|
|
2
|
+
// plan, the standing orders in force for the fanout (`workit ledger standing`)
|
|
3
|
+
// and the slice's scratch dir, so the lead passes it to the worker verbatim.
|
|
4
|
+
//
|
|
5
|
+
// MODE is `resume` when the branch already exists (a retry continues it),
|
|
6
|
+
// else `new`; `--attempt <n>` renders one attempt of a race on its own branch
|
|
7
|
+
// `<branch>-try<n>` (refused when another slice has that branch), worktree
|
|
8
|
+
// `<worktree>+try<n>` and session `<lead>-w-<slice>+try<n>`: slice ids cannot
|
|
9
|
+
// contain `+`, so no attempt collides with another slice's path or session.
|
|
10
|
+
// The lead must have a WORKIT_SESSION_ID: worker ids derive from it. A
|
|
11
|
+
// worker id longer than the ledger's 128 characters hashes its slice part. The fan-in line follows the plan's `fanIn`:
|
|
12
|
+
// one PR per slice forbids every merge; integration mode has the worker merge
|
|
13
|
+
// the integration tip before reporting, and nothing else. It only reads, apart
|
|
14
|
+
// from hiding `.workit-scratch/` in the repository's info/exclude.
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { activeStanding, readLedger, type LedgerActor } from "./ledger";
|
|
17
|
+
import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
|
|
18
|
+
import {
|
|
19
|
+
SCRATCH_DIR,
|
|
20
|
+
excludeScratch,
|
|
21
|
+
findSlice,
|
|
22
|
+
listWorktrees,
|
|
23
|
+
samePath,
|
|
24
|
+
slicePath,
|
|
25
|
+
} from "./fanout-worktree";
|
|
26
|
+
|
|
27
|
+
export type BriefMode = "new" | "resume";
|
|
28
|
+
|
|
29
|
+
export type BriefOutcome = {
|
|
30
|
+
fanout: string;
|
|
31
|
+
slice: string;
|
|
32
|
+
mode: BriefMode;
|
|
33
|
+
attempt: number | null;
|
|
34
|
+
branch: string;
|
|
35
|
+
base: string;
|
|
36
|
+
tier: string;
|
|
37
|
+
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
38
|
+
scratch: string;
|
|
39
|
+
session: string;
|
|
40
|
+
standing: Array<{ id: string; what: string }>;
|
|
41
|
+
text: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_TIMEBOX = "30 minutes";
|
|
45
|
+
|
|
46
|
+
const REPORT = [
|
|
47
|
+
"REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,",
|
|
48
|
+
" each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),",
|
|
49
|
+
" anything out of scope as a follow-up, not a diff.",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const rules = (mode: BriefMode, branch: string, base: string): string[] => [
|
|
53
|
+
"RULES:",
|
|
54
|
+
mode === "new"
|
|
55
|
+
? ` 1. First command: \`workit git branch ${branch} --base ${base}\` (your worktree may start on another name).`
|
|
56
|
+
: ` 1. First command: \`git switch ${branch}\`, then continue from its head.`,
|
|
57
|
+
' 2. Decide ambiguities yourself: `workit ledger ruling "<what>" --why "<why>" --cost-if-wrong "<cost>"`; stop only for an irreversible, security-sensitive or out-of-worktree action.',
|
|
58
|
+
" 3. Commit with `workit git commit -m <msg> -- <paths in SCOPE>`; push only if this brief says so.",
|
|
59
|
+
" 4. Never record a verdict on your own work.",
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** A session id fragment that the ledger accepts ([A-Za-z0-9_.:@/+-]). */
|
|
63
|
+
const sessionSafe = (text: string): string => text.replace(/[^A-Za-z0-9_.:@/+-]/gu, "-");
|
|
64
|
+
|
|
65
|
+
/** The ledger's session id limit. */
|
|
66
|
+
const SESSION_MAX = 128;
|
|
67
|
+
|
|
68
|
+
/** `<lead>-w-<slice>[+try<n>]`, with the slice part hashed when the whole would be too long. */
|
|
69
|
+
function workerSession(lead: string, slice: string, attempt: number | null): string {
|
|
70
|
+
const tail = attempt === null ? "" : `+try${attempt}`;
|
|
71
|
+
const full = `${lead}-w-${slice}${tail}`;
|
|
72
|
+
if (full.length <= SESSION_MAX) return full;
|
|
73
|
+
const digest = createHash("sha256").update(slice).digest("hex").slice(0, 12);
|
|
74
|
+
return `${lead.slice(0, SESSION_MAX - 3 - digest.length - tail.length)}-w-${digest}${tail}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function renderBrief(
|
|
78
|
+
cwd: string,
|
|
79
|
+
plan: FanoutFile,
|
|
80
|
+
input: { slice: string; mode: BriefMode | null; attempt: number | null; actor: LedgerActor },
|
|
81
|
+
): FanoutResult<BriefOutcome> {
|
|
82
|
+
const found = findSlice(plan, input.slice);
|
|
83
|
+
if (!found.ok) return found;
|
|
84
|
+
const slice = found.data;
|
|
85
|
+
const ledger = readLedger(cwd);
|
|
86
|
+
if (!ledger.ok) return fanoutFail(ledger.code, ledger.error, ledger.unblock);
|
|
87
|
+
if (!input.actor.session)
|
|
88
|
+
return fanoutFail(
|
|
89
|
+
"blocked",
|
|
90
|
+
"WORKIT_SESSION_ID is not set: worker session ids derive from the lead's",
|
|
91
|
+
"export WORKIT_SESSION_ID=<your session id>, then render the brief again",
|
|
92
|
+
);
|
|
93
|
+
const branch = input.attempt === null ? slice.branch : `${slice.branch}-try${input.attempt}`;
|
|
94
|
+
const taken = plan.slices.find((other) => other.id !== slice.id && other.branch === branch);
|
|
95
|
+
if (taken)
|
|
96
|
+
return fanoutFail(
|
|
97
|
+
"invalid_input",
|
|
98
|
+
`attempt branch ${branch} is slice ${taken.id}'s branch`,
|
|
99
|
+
"pick another --attempt number, or rename one of the branches in the plan",
|
|
100
|
+
);
|
|
101
|
+
const head = branchRef(cwd, branch);
|
|
102
|
+
const mode: BriefMode = input.mode ?? (head ? "resume" : "new");
|
|
103
|
+
if (mode === "resume" && !head)
|
|
104
|
+
return fanoutFail(
|
|
105
|
+
"not_found",
|
|
106
|
+
`MODE resume needs the branch ${branch}, which does not exist here`,
|
|
107
|
+
`workit fanout brief ${slice.id} --mode new`,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const planned = `${slicePath(cwd, slice)}${input.attempt === null ? "" : `+try${input.attempt}`}`;
|
|
111
|
+
const exists = listWorktrees(cwd).some((entry) => samePath(entry.path, planned));
|
|
112
|
+
excludeScratch(cwd);
|
|
113
|
+
const scratch = exists ? `${planned}/${SCRATCH_DIR}` : SCRATCH_DIR;
|
|
114
|
+
const scratchLine = exists
|
|
115
|
+
? `SCRATCH: ${scratch} (yours alone; temp files go here, never a shared path)`
|
|
116
|
+
: `SCRATCH: ${SCRATCH_DIR}/ at your worktree root (mkdir -p it; git ignores it; never a shared path)`;
|
|
117
|
+
|
|
118
|
+
const standing = activeStanding(ledger.value.rows, plan.name, planLeads(plan)).map((row) => ({
|
|
119
|
+
id: row.id,
|
|
120
|
+
what: String(row.what),
|
|
121
|
+
}));
|
|
122
|
+
const session = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
123
|
+
const fanIn =
|
|
124
|
+
plan.fanIn === "integration"
|
|
125
|
+
? [
|
|
126
|
+
`FAN-IN: integration branch ${plan.trunk}. Before you report, merge its tip into your branch`,
|
|
127
|
+
` (\`git merge --no-edit ${plan.trunk}\`), resolve conflicts inside SCOPE and re-run VERIFY, so the`,
|
|
128
|
+
" lead's merge is a fast-forward. No other merge, and no rebase, retarget or force-push.",
|
|
129
|
+
]
|
|
130
|
+
: [
|
|
131
|
+
"FAN-IN: one PR per slice. Never rebase, retarget, merge or force-push: the lead owns topology.",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const lines = [
|
|
135
|
+
`MODE: ${mode}${input.attempt === null ? "" : ` (race attempt ${input.attempt}: the lead keeps the attempt with the best verdict)`}`,
|
|
136
|
+
`GOAL: ${slice.goal}`,
|
|
137
|
+
`SCOPE: ${slice.scope.join(", ")}`,
|
|
138
|
+
...(slice.owns.length ? [` owns: ${slice.owns.join(", ")}`] : []),
|
|
139
|
+
` branch: ${branch} base: ${slice.base}`,
|
|
140
|
+
`CONTEXT: ${slice.context ?? "none given: read the files in SCOPE and the neighbours they import"}`,
|
|
141
|
+
...(mode === "resume" && head
|
|
142
|
+
? [
|
|
143
|
+
` resume: continue ${branch} from ${head.slice(0, 12)}; read \`git log ${slice.base}..${branch}\` and \`workit ledger list --branch ${branch}\` (the last report, verdicts, rulings)`,
|
|
144
|
+
]
|
|
145
|
+
: []),
|
|
146
|
+
"ACCEPTANCE:",
|
|
147
|
+
...slice.acceptance.map((line) => ` - ${line}`),
|
|
148
|
+
`VERIFY: ${slice.verify.join("; ")}`,
|
|
149
|
+
`TIER: ${slice.tier}`,
|
|
150
|
+
`TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
|
|
151
|
+
scratchLine,
|
|
152
|
+
`FORBIDDEN: ${slice.forbidden.join("; ")}`,
|
|
153
|
+
...fanIn,
|
|
154
|
+
...REPORT,
|
|
155
|
+
"STANDING:",
|
|
156
|
+
...(standing.length
|
|
157
|
+
? standing.map((order) => ` - ${order.what}`)
|
|
158
|
+
: [" - none recorded beyond this brief"]),
|
|
159
|
+
` export WORKIT_SESSION_ID=${session}`,
|
|
160
|
+
...rules(mode, branch, slice.base),
|
|
161
|
+
];
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
data: {
|
|
165
|
+
fanout: plan.name,
|
|
166
|
+
slice: slice.id,
|
|
167
|
+
mode,
|
|
168
|
+
attempt: input.attempt,
|
|
169
|
+
branch,
|
|
170
|
+
base: slice.base,
|
|
171
|
+
tier: slice.tier,
|
|
172
|
+
scratch,
|
|
173
|
+
session,
|
|
174
|
+
standing,
|
|
175
|
+
text: `${lines.join("\n")}\n`,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/src/fanout-check.ts
CHANGED
|
@@ -293,7 +293,8 @@ export function sliceLandings(
|
|
|
293
293
|
createdFrom: sliceStart(cwd, rows, plan.name, slice),
|
|
294
294
|
parentHead,
|
|
295
295
|
fanout: plan.name,
|
|
296
|
-
since: plan.createdAt || null,
|
|
296
|
+
since: slice.hashSince || plan.createdAt || null,
|
|
297
|
+
sliceHash: slice.hash || null,
|
|
297
298
|
rows,
|
|
298
299
|
}),
|
|
299
300
|
);
|
package/src/fanout-landed.ts
CHANGED
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
// commit (a squash merge that applied without the trunk touching the same
|
|
13
13
|
// lines). Anything else reads as not landed.
|
|
14
14
|
// A merged PR whose branch is gone counts only when the ledger links that
|
|
15
|
-
// branch to this slice
|
|
16
|
-
//
|
|
15
|
+
// branch to this slice: a worktree row made under the slice's current hash,
|
|
16
|
+
// or a row recorded on its merged head since that hash was planned. A reused
|
|
17
|
+
// branch name from older work, an older plan of the same name, or an earlier
|
|
18
|
+
// definition of this slice never links; editing a sibling changes nothing.
|
|
17
19
|
// A landing whose changed paths read on the trunk exactly as before it (a
|
|
18
20
|
// revert) is not landed; the note says so.
|
|
19
21
|
// The forge is asked through a breaker: after the first unavailable answer
|
|
@@ -184,24 +186,44 @@ function trunkPatchIds(ctx: LandingContext, fork: string): Set<string> {
|
|
|
184
186
|
return ids;
|
|
185
187
|
}
|
|
186
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Does `row` belong to the slice's current run? A worktree row made under its
|
|
191
|
+
* hash does; any other row only when recorded since that hash was planned.
|
|
192
|
+
*/
|
|
193
|
+
export function inSliceRun(
|
|
194
|
+
row: Record<string, unknown>,
|
|
195
|
+
slice: Pick<Slice, "hash" | "hashSince">,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (row.type === "fanout.worktree.created" && typeof row.sliceHash === "string" && slice.hash)
|
|
198
|
+
return row.sliceHash === slice.hash;
|
|
199
|
+
const since = Date.parse(slice.hashSince);
|
|
200
|
+
return Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
201
|
+
}
|
|
202
|
+
|
|
187
203
|
/** The ledger links `branch` to this slice: a worktree row, or a row recorded on `head`. */
|
|
188
204
|
function linked(
|
|
189
205
|
rows: readonly Record<string, unknown>[],
|
|
190
|
-
input: Pick<LandingInput, "fanout" | "since">,
|
|
206
|
+
input: Pick<LandingInput, "fanout" | "since" | "sliceHash">,
|
|
191
207
|
slice: Pick<Slice, "id" | "branch">,
|
|
192
208
|
head: string | null,
|
|
193
209
|
): boolean {
|
|
194
|
-
// Only rows from this run of the
|
|
195
|
-
// earlier
|
|
210
|
+
// Only rows from this run of the slice: a plan of the same name made
|
|
211
|
+
// earlier, or this slice re-planned with another definition, lends nothing.
|
|
196
212
|
const since = input.since ? Date.parse(input.since) : Number.NaN;
|
|
197
|
-
|
|
198
|
-
(row)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
const recent = (row: Record<string, unknown>) =>
|
|
214
|
+
Number.isNaN(since) || Date.parse(String(row.at)) >= since;
|
|
215
|
+
return rows.some((row) => {
|
|
216
|
+
if (
|
|
217
|
+
row.type === "fanout.worktree.created" &&
|
|
218
|
+
row.fanout === input.fanout &&
|
|
219
|
+
row.slice === slice.id
|
|
220
|
+
)
|
|
221
|
+
// A row from before slice hashes falls back to its time.
|
|
222
|
+
return typeof row.sliceHash === "string" && input.sliceHash
|
|
223
|
+
? row.sliceHash === input.sliceHash
|
|
224
|
+
: recent(row);
|
|
225
|
+
return recent(row) && head !== null && row.branch === slice.branch && row.head === head;
|
|
226
|
+
});
|
|
205
227
|
}
|
|
206
228
|
|
|
207
229
|
export type LandingInput = {
|
|
@@ -212,8 +234,10 @@ export type LandingInput = {
|
|
|
212
234
|
/** Its dependency branch's tip (or merged head) for a stacked slice. */
|
|
213
235
|
parentHead: string | null;
|
|
214
236
|
fanout: string;
|
|
215
|
-
/** When this
|
|
237
|
+
/** When this slice definition was first planned (Slice.hashSince): older rows do not link. */
|
|
216
238
|
since: string | null;
|
|
239
|
+
/** The slice's definition hash: worktree rows link only under the same one. */
|
|
240
|
+
sliceHash: string | null;
|
|
217
241
|
rows: readonly Record<string, unknown>[];
|
|
218
242
|
};
|
|
219
243
|
|
|
@@ -298,7 +322,7 @@ export function sliceStart(
|
|
|
298
322
|
cwd: string,
|
|
299
323
|
rows: readonly Record<string, unknown>[],
|
|
300
324
|
fanout: string,
|
|
301
|
-
slice: Pick<Slice, "id" | "branch">,
|
|
325
|
+
slice: Pick<Slice, "id" | "branch" | "hash" | "hashSince">,
|
|
302
326
|
): string | null {
|
|
303
327
|
const reflog = branchCreatedAt(cwd, slice.branch);
|
|
304
328
|
if (reflog) return reflog;
|
|
@@ -307,7 +331,8 @@ export function sliceStart(
|
|
|
307
331
|
candidate.type === "fanout.worktree.created" &&
|
|
308
332
|
candidate.fanout === fanout &&
|
|
309
333
|
candidate.slice === slice.id &&
|
|
310
|
-
typeof candidate.baseSha === "string"
|
|
334
|
+
typeof candidate.baseSha === "string" &&
|
|
335
|
+
inSliceRun(candidate, slice),
|
|
311
336
|
);
|
|
312
337
|
return (row?.baseSha as string | undefined) ?? null;
|
|
313
338
|
}
|
package/src/fanout-status.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { checksState, gatingChecks, missingRequired, type ChecksState } from "./
|
|
|
20
20
|
import type { ResolvedForge } from "./forge/resolve";
|
|
21
21
|
import { checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
22
22
|
import { sliceLandings } from "./fanout-check";
|
|
23
|
-
import { forgeBreaker } from "./fanout-landed";
|
|
23
|
+
import { forgeBreaker, inSliceRun } from "./fanout-landed";
|
|
24
24
|
import {
|
|
25
25
|
branchRef,
|
|
26
26
|
fanoutFail,
|
|
@@ -123,6 +123,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
|
|
|
123
123
|
]).stdout,
|
|
124
124
|
);
|
|
125
125
|
const reflog = moved ? isoOf(moved[1]) : null;
|
|
126
|
+
// Liveness reads every row on the branch or slice: re-planning a slice
|
|
127
|
+
// never makes a live worker look idle.
|
|
126
128
|
const ledger = rows
|
|
127
129
|
.filter(
|
|
128
130
|
(row) => row.branch === slice.branch || (row.fanout === plan.name && row.slice === slice.id),
|
|
@@ -136,7 +138,8 @@ function activity(cwd: string, plan: FanoutFile, slice: Slice, rows: readonly Re
|
|
|
136
138
|
(row) =>
|
|
137
139
|
row.type === "fanout.worktree.created" &&
|
|
138
140
|
row.fanout === plan.name &&
|
|
139
|
-
row.slice === slice.id
|
|
141
|
+
row.slice === slice.id &&
|
|
142
|
+
inSliceRun(row, slice),
|
|
140
143
|
),
|
|
141
144
|
};
|
|
142
145
|
}
|
|
@@ -267,7 +270,8 @@ export function fanoutStatus(
|
|
|
267
270
|
(candidate) =>
|
|
268
271
|
candidate.type === "fanout.worktree.created" &&
|
|
269
272
|
candidate.fanout === plan.name &&
|
|
270
|
-
candidate.slice === id
|
|
273
|
+
candidate.slice === id &&
|
|
274
|
+
inSliceRun(candidate, byId.get(id) as Slice),
|
|
271
275
|
);
|
|
272
276
|
row.state = created ? "active" : "not_started";
|
|
273
277
|
if (!created) spawnable.push(id);
|
package/src/fanout-worktree.ts
CHANGED
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
// (`fanout.worktree.released`), refuses while it has uncommitted changes
|
|
13
13
|
// unless forced, then runs `git worktree remove` (which takes the scratch dir
|
|
14
14
|
// with it). The branch is kept. It only removes a worktree that `create` made
|
|
15
|
-
// for this fanout, slice and path
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// for this fanout, slice and path, on the slice's branch or detached; never
|
|
16
|
+
// the main checkout, the one the command runs in, or a worktree someone else
|
|
17
|
+
// made there, not even with --force. "Made by create" is exact: create writes
|
|
18
|
+
// a random token into git's admin dir for the worktree
|
|
19
|
+
// (`.git/worktrees/<id>/workit-fanout-token`) and records it with the admin
|
|
20
|
+
// dir's id and inode; release and adopt need all three to match. A worktree
|
|
21
|
+
// removed by hand and re-added at the path gets a new admin dir without the
|
|
22
|
+
// token, on any filesystem (no birth time needed). A row from before the
|
|
23
|
+
// token is matched by time only, so release refuses --force for it. An empty
|
|
18
24
|
// directory that existed before `create` is left in place.
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
19
26
|
import fs from "node:fs";
|
|
20
27
|
import path from "node:path";
|
|
21
28
|
import { gitBranch } from "./git/ops";
|
|
@@ -68,7 +75,7 @@ const canonical = (target: string): string => {
|
|
|
68
75
|
}
|
|
69
76
|
};
|
|
70
77
|
|
|
71
|
-
const samePath = (a: string, b: string): boolean => {
|
|
78
|
+
export const samePath = (a: string, b: string): boolean => {
|
|
72
79
|
const left = canonical(a);
|
|
73
80
|
const right = canonical(b);
|
|
74
81
|
return process.platform === "win32" || process.platform === "darwin"
|
|
@@ -82,7 +89,7 @@ const inside = (child: string, parent: string): boolean => {
|
|
|
82
89
|
};
|
|
83
90
|
|
|
84
91
|
/** `git worktree list --porcelain -z`; the first entry is the main worktree. */
|
|
85
|
-
function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
92
|
+
export function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
86
93
|
const out: WorktreeEntry[] = [];
|
|
87
94
|
let current: WorktreeEntry | null = null;
|
|
88
95
|
for (const field of gitRun(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\0")) {
|
|
@@ -98,7 +105,7 @@ function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
/** The slice's worktree path: absolute since plan time; an older relative one is the main checkout's. */
|
|
101
|
-
const slicePath = (cwd: string, slice: Slice): string =>
|
|
108
|
+
export const slicePath = (cwd: string, slice: Slice): string =>
|
|
102
109
|
path.isAbsolute(slice.worktree)
|
|
103
110
|
? slice.worktree
|
|
104
111
|
: path.resolve(mainCheckout(cwd), slice.worktree);
|
|
@@ -110,8 +117,10 @@ const CLOCK_SLACK_MS = 2000;
|
|
|
110
117
|
* The live `fanout.worktree.created` row for this fanout, slice and path:
|
|
111
118
|
* the newest one, unless a later release removed that worktree (a row is
|
|
112
119
|
* void once its worktree is gone), and only while the worktree at the path
|
|
113
|
-
* is the one it recorded
|
|
114
|
-
*
|
|
120
|
+
* is the one it recorded. `exact`: the admin dir's token, id and inode match
|
|
121
|
+
* the row. A row without a token (written before it) matches when the admin
|
|
122
|
+
* dir is no older than the row, which a re-added worktree also passes, so it
|
|
123
|
+
* is never exact. Anything else is someone else's worktree.
|
|
115
124
|
*/
|
|
116
125
|
function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string) {
|
|
117
126
|
const ledger = readLedger(cwd);
|
|
@@ -134,24 +143,75 @@ function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string)
|
|
|
134
143
|
atPath(candidate),
|
|
135
144
|
);
|
|
136
145
|
if (removedSince) return null;
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
|
|
146
|
+
const admin = adminDir(target);
|
|
147
|
+
if (!admin) return null;
|
|
148
|
+
if (typeof row.adminToken === "string")
|
|
149
|
+
return row.adminToken === admin.token && row.adminId === admin.id && row.adminIno === admin.ino
|
|
150
|
+
? { row, exact: true }
|
|
151
|
+
: null;
|
|
152
|
+
const made = admin.birthMs ?? admin.mtimeMs;
|
|
153
|
+
if (made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
|
|
154
|
+
return { row, exact: false };
|
|
140
155
|
}
|
|
141
156
|
|
|
142
|
-
|
|
143
|
-
|
|
157
|
+
const TOKEN_FILE = "workit-fanout-token";
|
|
158
|
+
|
|
159
|
+
/** The filesystem calls identity relies on; tests stub them. */
|
|
160
|
+
export const worktreeDeps = {
|
|
161
|
+
stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
type AdminDir = {
|
|
165
|
+
path: string;
|
|
166
|
+
/** `<id>` of `.git/worktrees/<id>`. */
|
|
167
|
+
id: string;
|
|
168
|
+
ino: string;
|
|
169
|
+
/** The token create wrote there, or null. */
|
|
170
|
+
token: string | null;
|
|
171
|
+
birthMs: number | null;
|
|
172
|
+
mtimeMs: number;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/** Git's admin dir for the worktree: what tells this worktree from a later one at the path. */
|
|
176
|
+
function adminDir(worktree: string): AdminDir | null {
|
|
144
177
|
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
145
178
|
if (!dir.ok) return null;
|
|
179
|
+
const file = dir.stdout.trim();
|
|
180
|
+
let token: string | null = null;
|
|
181
|
+
try {
|
|
182
|
+
token = fs.readFileSync(path.join(file, TOKEN_FILE), "utf8").trim() || null;
|
|
183
|
+
} catch {
|
|
184
|
+
token = null;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const stat = worktreeDeps.stat(file);
|
|
188
|
+
return {
|
|
189
|
+
path: file,
|
|
190
|
+
id: path.basename(file),
|
|
191
|
+
ino: stat.ino.toString(),
|
|
192
|
+
token,
|
|
193
|
+
birthMs: stat.birthtimeNs > 0n ? Number(stat.birthtimeMs) : null,
|
|
194
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
195
|
+
};
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Mark the admin dir of a worktree create just made; null when it cannot. */
|
|
202
|
+
function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
|
|
203
|
+
const admin = adminDir(worktree);
|
|
204
|
+
if (!admin) return null;
|
|
205
|
+
const token = randomBytes(16).toString("hex");
|
|
146
206
|
try {
|
|
147
|
-
|
|
148
|
-
return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
|
|
207
|
+
fs.writeFileSync(path.join(admin.path, TOKEN_FILE), `${token}\n`);
|
|
149
208
|
} catch {
|
|
150
209
|
return null;
|
|
151
210
|
}
|
|
211
|
+
return { ...admin, token };
|
|
152
212
|
}
|
|
153
213
|
|
|
154
|
-
function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
214
|
+
export function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
155
215
|
const slice = plan.slices.find((candidate) => candidate.id === id);
|
|
156
216
|
return slice
|
|
157
217
|
? { ok: true, data: slice }
|
|
@@ -163,7 +223,7 @@ function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
|
163
223
|
}
|
|
164
224
|
|
|
165
225
|
/** Hide the scratch dir from git status and `git add -A` in every worktree. */
|
|
166
|
-
function excludeScratch(cwd: string): void {
|
|
226
|
+
export function excludeScratch(cwd: string): void {
|
|
167
227
|
const common = gitCommonDir(cwd);
|
|
168
228
|
if (!common) return;
|
|
169
229
|
const file = path.join(common, "info", "exclude");
|
|
@@ -325,6 +385,7 @@ export function createSliceWorktree(
|
|
|
325
385
|
const scratch = ensureScratch(target);
|
|
326
386
|
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
327
387
|
const baseSha = mode === "new" ? head : null;
|
|
388
|
+
const admin = markAdminDir(target);
|
|
328
389
|
appendObserved(cwd, {
|
|
329
390
|
type: "fanout.worktree.created",
|
|
330
391
|
actor: input.actor,
|
|
@@ -337,6 +398,8 @@ export function createSliceWorktree(
|
|
|
337
398
|
head,
|
|
338
399
|
dirExisted,
|
|
339
400
|
planCreatedAt: plan.createdAt,
|
|
401
|
+
sliceHash: slice.hash,
|
|
402
|
+
...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
|
|
340
403
|
...(baseSha ? { baseSha } : {}),
|
|
341
404
|
});
|
|
342
405
|
return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
|
|
@@ -410,13 +473,20 @@ export function releaseSliceWorktree(
|
|
|
410
473
|
"blocked",
|
|
411
474
|
`${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`,
|
|
412
475
|
);
|
|
413
|
-
const
|
|
414
|
-
if (!
|
|
476
|
+
const owned = createdRow(cwd, plan, slice, entry.path);
|
|
477
|
+
if (!owned)
|
|
415
478
|
return fanoutFail(
|
|
416
479
|
"blocked",
|
|
417
480
|
`${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`,
|
|
418
481
|
`remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
419
482
|
);
|
|
483
|
+
if (input.force && !owned.exact)
|
|
484
|
+
return fanoutFail(
|
|
485
|
+
"blocked",
|
|
486
|
+
`${entry.path} was created before workit recorded worktree identity, so it cannot be told from a worktree someone re-added there; --force is refused and nothing was removed`,
|
|
487
|
+
`release it without --force, or remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
488
|
+
);
|
|
489
|
+
const made = owned.row;
|
|
420
490
|
out.head = entry.head;
|
|
421
491
|
const status = gitRun(entry.path, [
|
|
422
492
|
"status",
|