@brainervirus/workit-core 7.4.0 → 7.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +4 -3
- package/skills/workit-continue/SKILL.md +1 -1
- package/skills/workit-deslop/SKILL.md +3 -5
- package/skills/workit-fanout/SKILL.md +27 -27
- package/skills/workit-fanout/references/brief.md +7 -2
- package/skills/workit-implement/SKILL.md +10 -11
- package/skills/workit-retro/SKILL.md +1 -1
- package/skills/workit-review/SKILL.md +6 -3
- package/skills/workit-shape/SKILL.md +5 -5
- package/skills/workit-shape/references/knowledge.md +1 -1
- package/skills/workit-shape/references/mockups.md +1 -1
- package/skills/workit-shape/references/slicing.md +3 -1
- package/skills/workit-ship/SKILL.md +27 -27
- package/skills/workit-ship/references/pr-body.md +30 -0
- package/skills/workit-verify-app/SKILL.md +1 -2
- package/src/core/skill-manifests.ts +47 -1
- package/src/duration.ts +27 -0
- package/src/fanout-check.ts +178 -27
- package/src/fanout-landed.ts +313 -0
- package/src/fanout-status.ts +321 -0
- package/src/fanout-worktree.ts +520 -0
- package/src/fanout.ts +131 -17
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// `workit fanout worktree create|release <slice>` (G4): deterministic worktrees
|
|
2
|
+
// for hosts without native worktree isolation (OpenCode, Codex, Cursor, Pi).
|
|
3
|
+
//
|
|
4
|
+
// create: the slice's worktree at its planned path. A new slice starts
|
|
5
|
+
// detached at its planned base and gets its branch through `workit git
|
|
6
|
+
// branch` (core gitBranch: branch policy, release tracks); an existing branch
|
|
7
|
+
// (MODE: resume) is checked out as it is. It also makes the per-worker
|
|
8
|
+
// scratch dir `<worktree>/.workit-scratch`, hidden from git through the
|
|
9
|
+
// repository's info/exclude, and records `fanout.worktree.created`.
|
|
10
|
+
//
|
|
11
|
+
// release: records `git status` of the worktree in the ledger first
|
|
12
|
+
// (`fanout.worktree.released`), refuses while it has uncommitted changes
|
|
13
|
+
// unless forced, then runs `git worktree remove` (which takes the scratch dir
|
|
14
|
+
// with it). The branch is kept. It only removes a worktree that `create` made
|
|
15
|
+
// for this fanout, slice and path (its ledger row says so), on the slice's
|
|
16
|
+
// branch or detached; never the main checkout, the one the command runs in,
|
|
17
|
+
// or a worktree someone else made there, not even with --force. An empty
|
|
18
|
+
// directory that existed before `create` is left in place.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { gitBranch } from "./git/ops";
|
|
22
|
+
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
23
|
+
import { appendObserved, readLedger, type LedgerActor } from "./ledger";
|
|
24
|
+
import {
|
|
25
|
+
branchRef,
|
|
26
|
+
fanoutFail,
|
|
27
|
+
gitRun,
|
|
28
|
+
mainCheckout,
|
|
29
|
+
nulList,
|
|
30
|
+
trunkRef,
|
|
31
|
+
type FanoutFile,
|
|
32
|
+
type FanoutResult,
|
|
33
|
+
type Slice,
|
|
34
|
+
} from "./fanout";
|
|
35
|
+
|
|
36
|
+
const SLOW = GIT_TIMEOUTS.worktree;
|
|
37
|
+
export const SCRATCH_DIR = ".workit-scratch";
|
|
38
|
+
const EXCLUDE_LINE = `/${SCRATCH_DIR}/`;
|
|
39
|
+
/** Dirty paths kept in the ledger row: it has a 4 KB line limit. */
|
|
40
|
+
const DIRTY_ROW_BYTES = 2000;
|
|
41
|
+
|
|
42
|
+
/** The first dirty entries that fit the row; dirtyCount keeps the total. */
|
|
43
|
+
function rowSample(lines: readonly string[]): string[] {
|
|
44
|
+
const out: string[] = [];
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const entry = line.slice(0, 200);
|
|
48
|
+
bytes += Buffer.byteLength(entry) + 3;
|
|
49
|
+
if (bytes > DIRTY_ROW_BYTES) break;
|
|
50
|
+
out.push(entry);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type WorktreeEntry = {
|
|
56
|
+
path: string;
|
|
57
|
+
head: string | null;
|
|
58
|
+
/** refs/heads/<name>, or null when detached. */
|
|
59
|
+
branch: string | null;
|
|
60
|
+
locked: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const canonical = (target: string): string => {
|
|
64
|
+
try {
|
|
65
|
+
return fs.realpathSync.native(target);
|
|
66
|
+
} catch {
|
|
67
|
+
return path.resolve(target);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const samePath = (a: string, b: string): boolean => {
|
|
72
|
+
const left = canonical(a);
|
|
73
|
+
const right = canonical(b);
|
|
74
|
+
return process.platform === "win32" || process.platform === "darwin"
|
|
75
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
76
|
+
: left === right;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const inside = (child: string, parent: string): boolean => {
|
|
80
|
+
const relative = path.relative(canonical(parent), canonical(child));
|
|
81
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** `git worktree list --porcelain -z`; the first entry is the main worktree. */
|
|
85
|
+
function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
86
|
+
const out: WorktreeEntry[] = [];
|
|
87
|
+
let current: WorktreeEntry | null = null;
|
|
88
|
+
for (const field of gitRun(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\0")) {
|
|
89
|
+
if (field.startsWith("worktree ")) {
|
|
90
|
+
current = { path: field.slice(9), head: null, branch: null, locked: false };
|
|
91
|
+
out.push(current);
|
|
92
|
+
} else if (!current) continue;
|
|
93
|
+
else if (field.startsWith("HEAD ")) current.head = field.slice(5);
|
|
94
|
+
else if (field.startsWith("branch ")) current.branch = field.slice(7);
|
|
95
|
+
else if (field === "locked" || field.startsWith("locked ")) current.locked = true;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 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 =>
|
|
102
|
+
path.isAbsolute(slice.worktree)
|
|
103
|
+
? slice.worktree
|
|
104
|
+
: path.resolve(mainCheckout(cwd), slice.worktree);
|
|
105
|
+
|
|
106
|
+
/** Timestamps of the admin dir and the ledger have second granularity on some filesystems. */
|
|
107
|
+
const CLOCK_SLACK_MS = 2000;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The live `fanout.worktree.created` row for this fanout, slice and path:
|
|
111
|
+
* the newest one, unless a later release removed that worktree (a row is
|
|
112
|
+
* void once its worktree is gone), and only while the worktree at the path
|
|
113
|
+
* is the one it recorded: git's admin dir for it (`.git/worktrees/<id>`) was
|
|
114
|
+
* made no earlier than the row. Anything else is someone else's worktree.
|
|
115
|
+
*/
|
|
116
|
+
function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string) {
|
|
117
|
+
const ledger = readLedger(cwd);
|
|
118
|
+
if (!ledger.ok) return null;
|
|
119
|
+
const atPath = (row: Record<string, unknown>) =>
|
|
120
|
+
row.fanout === plan.name &&
|
|
121
|
+
row.slice === slice.id &&
|
|
122
|
+
typeof row.path === "string" &&
|
|
123
|
+
samePath(row.path, target);
|
|
124
|
+
const rows = ledger.value.rows;
|
|
125
|
+
const row = rows.findLast(
|
|
126
|
+
(candidate) => candidate.type === "fanout.worktree.created" && atPath(candidate),
|
|
127
|
+
);
|
|
128
|
+
if (!row) return null;
|
|
129
|
+
const removedSince = rows.some(
|
|
130
|
+
(candidate) =>
|
|
131
|
+
candidate.seq > row.seq &&
|
|
132
|
+
candidate.type === "fanout.worktree.released" &&
|
|
133
|
+
candidate.outcome === "removed" &&
|
|
134
|
+
atPath(candidate),
|
|
135
|
+
);
|
|
136
|
+
if (removedSince) return null;
|
|
137
|
+
const made = adminDirTime(target);
|
|
138
|
+
if (made === null || made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
|
|
139
|
+
return row;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** When git made the worktree's admin dir (birth time, else mtime); null when unknown. */
|
|
143
|
+
function adminDirTime(worktree: string): number | null {
|
|
144
|
+
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
145
|
+
if (!dir.ok) return null;
|
|
146
|
+
try {
|
|
147
|
+
const stat = fs.statSync(dir.stdout.trim());
|
|
148
|
+
return stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
155
|
+
const slice = plan.slices.find((candidate) => candidate.id === id);
|
|
156
|
+
return slice
|
|
157
|
+
? { ok: true, data: slice }
|
|
158
|
+
: fanoutFail(
|
|
159
|
+
"invalid_input",
|
|
160
|
+
`${id}: not a slice of fanout ${plan.name}`,
|
|
161
|
+
`slices: ${plan.slices.map((candidate) => candidate.id).join(", ")}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Hide the scratch dir from git status and `git add -A` in every worktree. */
|
|
166
|
+
function excludeScratch(cwd: string): void {
|
|
167
|
+
const common = gitCommonDir(cwd);
|
|
168
|
+
if (!common) return;
|
|
169
|
+
const file = path.join(common, "info", "exclude");
|
|
170
|
+
let text = "";
|
|
171
|
+
try {
|
|
172
|
+
text = fs.readFileSync(file, "utf8");
|
|
173
|
+
} catch {
|
|
174
|
+
text = "";
|
|
175
|
+
}
|
|
176
|
+
if (text.split(/\r?\n/u).includes(EXCLUDE_LINE)) return;
|
|
177
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
178
|
+
fs.appendFileSync(
|
|
179
|
+
file,
|
|
180
|
+
`${text && !text.endsWith("\n") ? "\n" : ""}# workit fanout: per-worker scratch dirs\n${EXCLUDE_LINE}\n`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type CreateOutcome = {
|
|
185
|
+
fanout: string;
|
|
186
|
+
slice: string;
|
|
187
|
+
branch: string;
|
|
188
|
+
base: string;
|
|
189
|
+
path: string;
|
|
190
|
+
scratch: string;
|
|
191
|
+
head: string;
|
|
192
|
+
/** new: the branch was created from its base; resume: the existing branch was checked out. */
|
|
193
|
+
mode: "new" | "resume";
|
|
194
|
+
/** False when the worktree already existed on the slice's branch. */
|
|
195
|
+
created: boolean;
|
|
196
|
+
notes: string[];
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function ensureScratch(worktree: string): string {
|
|
200
|
+
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
201
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
202
|
+
return scratch;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function createSliceWorktree(
|
|
206
|
+
cwd: string,
|
|
207
|
+
plan: FanoutFile,
|
|
208
|
+
input: { slice: string; actor: LedgerActor },
|
|
209
|
+
): FanoutResult<CreateOutcome> {
|
|
210
|
+
const found = findSlice(plan, input.slice);
|
|
211
|
+
if (!found.ok) return found;
|
|
212
|
+
const slice = found.data;
|
|
213
|
+
if (!slice.worktree)
|
|
214
|
+
return fanoutFail(
|
|
215
|
+
"invalid_input",
|
|
216
|
+
`slice ${slice.id} has no worktree path`,
|
|
217
|
+
"re-plan it with workit fanout plan",
|
|
218
|
+
);
|
|
219
|
+
const target = slicePath(cwd, slice);
|
|
220
|
+
const worktrees = listWorktrees(cwd);
|
|
221
|
+
const localRef = `refs/heads/${slice.branch}`;
|
|
222
|
+
const existing = worktrees.find((entry) => samePath(entry.path, target));
|
|
223
|
+
const base = {
|
|
224
|
+
fanout: plan.name,
|
|
225
|
+
slice: slice.id,
|
|
226
|
+
branch: slice.branch,
|
|
227
|
+
base: slice.base,
|
|
228
|
+
path: target,
|
|
229
|
+
};
|
|
230
|
+
if (existing) {
|
|
231
|
+
if (existing.branch !== null && existing.branch !== localRef)
|
|
232
|
+
return fanoutFail(
|
|
233
|
+
"blocked",
|
|
234
|
+
`${target} is a worktree on ${existing.branch.replace(/^refs\/heads\//u, "")}, not ${slice.branch}`,
|
|
235
|
+
`release it first, or set slice ${slice.id}'s worktree to another path`,
|
|
236
|
+
);
|
|
237
|
+
if (!createdRow(cwd, plan, slice, target))
|
|
238
|
+
return fanoutFail(
|
|
239
|
+
"blocked",
|
|
240
|
+
`${target} is a worktree that workit fanout worktree create did not make for slice ${slice.id}`,
|
|
241
|
+
`remove it yourself (git worktree remove ${target}) once you know what it holds`,
|
|
242
|
+
);
|
|
243
|
+
excludeScratch(cwd);
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
data: {
|
|
247
|
+
...base,
|
|
248
|
+
scratch: ensureScratch(target),
|
|
249
|
+
head: existing.head ?? "",
|
|
250
|
+
mode: resolveRef(cwd, localRef) ? "resume" : "new",
|
|
251
|
+
created: false,
|
|
252
|
+
notes: ["the worktree already exists; nothing was changed"],
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const elsewhere = worktrees.find((entry) => entry.branch === localRef);
|
|
257
|
+
if (elsewhere)
|
|
258
|
+
return fanoutFail(
|
|
259
|
+
"blocked",
|
|
260
|
+
`${slice.branch} is checked out at ${elsewhere.path}: never two live workers on one branch`,
|
|
261
|
+
`stop that worker, then workit fanout worktree release ${slice.id} (or git worktree remove ${elsewhere.path})`,
|
|
262
|
+
);
|
|
263
|
+
let dirExisted = false;
|
|
264
|
+
try {
|
|
265
|
+
const stat = fs.statSync(target);
|
|
266
|
+
if (!stat.isDirectory() || fs.readdirSync(target).length > 0)
|
|
267
|
+
return fanoutFail(
|
|
268
|
+
"blocked",
|
|
269
|
+
`${target} exists and is ${stat.isDirectory() ? "not empty" : "not a directory"} (not a worktree of this repository)`,
|
|
270
|
+
`move it away, or set slice ${slice.id}'s worktree to another path`,
|
|
271
|
+
);
|
|
272
|
+
dirExisted = true;
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
275
|
+
return fanoutFail("failed", `cannot read ${target}: ${(error as Error).message}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const notes: string[] = [];
|
|
279
|
+
let mode: CreateOutcome["mode"] = "resume";
|
|
280
|
+
if (resolveRef(cwd, localRef)) {
|
|
281
|
+
const added = gitRun(cwd, ["worktree", "add", target, slice.branch], SLOW);
|
|
282
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
283
|
+
} else {
|
|
284
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
285
|
+
const remoteRef = `refs/remotes/${remote}/${slice.branch}`;
|
|
286
|
+
if (resolveRef(cwd, remoteRef)) {
|
|
287
|
+
// The branch exists on the remote only: resume it from there.
|
|
288
|
+
const added = gitRun(cwd, ["worktree", "add", "-b", slice.branch, target, remoteRef], SLOW);
|
|
289
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
290
|
+
notes.push(`resumed ${slice.branch} from ${remote}/${slice.branch}`);
|
|
291
|
+
} else {
|
|
292
|
+
mode = "new";
|
|
293
|
+
const parent = plan.slices.find((other) => other.branch === slice.base);
|
|
294
|
+
const from =
|
|
295
|
+
slice.base === plan.trunk
|
|
296
|
+
? trunkRef(cwd, plan.trunk)
|
|
297
|
+
: parent
|
|
298
|
+
? branchRef(cwd, parent.branch)
|
|
299
|
+
: branchRef(cwd, slice.base);
|
|
300
|
+
if (!from)
|
|
301
|
+
return fanoutFail(
|
|
302
|
+
"not_found",
|
|
303
|
+
`base ${slice.base} of slice ${slice.id} does not resolve here`,
|
|
304
|
+
parent
|
|
305
|
+
? `start slice ${parent.id} first (its branch ${parent.branch} is the base)`
|
|
306
|
+
: `git fetch origin ${slice.base}`,
|
|
307
|
+
);
|
|
308
|
+
const added = gitRun(cwd, ["worktree", "add", "--detach", target, from], SLOW);
|
|
309
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
310
|
+
const branched = gitBranch(target, { name: slice.branch, base: slice.base });
|
|
311
|
+
if (!branched.ok) {
|
|
312
|
+
// The worktree was made by this call a moment ago and is clean.
|
|
313
|
+
gitRun(cwd, ["worktree", "remove", target], SLOW);
|
|
314
|
+
return fanoutFail(
|
|
315
|
+
branched.code,
|
|
316
|
+
`workit git branch ${slice.branch} refused: ${branched.error}`,
|
|
317
|
+
branched.unblock ??
|
|
318
|
+
`fix the branch name in the plan, then workit fanout worktree create ${slice.id}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
notes.push(...branched.data.notes);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
excludeScratch(cwd);
|
|
325
|
+
const scratch = ensureScratch(target);
|
|
326
|
+
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
327
|
+
const baseSha = mode === "new" ? head : null;
|
|
328
|
+
appendObserved(cwd, {
|
|
329
|
+
type: "fanout.worktree.created",
|
|
330
|
+
actor: input.actor,
|
|
331
|
+
fanout: plan.name,
|
|
332
|
+
slice: slice.id,
|
|
333
|
+
branch: slice.branch,
|
|
334
|
+
path: target,
|
|
335
|
+
scratch,
|
|
336
|
+
mode,
|
|
337
|
+
head,
|
|
338
|
+
dirExisted,
|
|
339
|
+
planCreatedAt: plan.createdAt,
|
|
340
|
+
...(baseSha ? { baseSha } : {}),
|
|
341
|
+
});
|
|
342
|
+
return { ok: true, data: { ...base, scratch, head, mode, created: true, notes } };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const worktreeFailed = (stderr: string) =>
|
|
346
|
+
fanoutFail(
|
|
347
|
+
"failed",
|
|
348
|
+
`git worktree add failed: ${stderr.trim().split("\n")[0] || "unknown error"}`,
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
export type ReleaseOutcome = {
|
|
352
|
+
fanout: string;
|
|
353
|
+
slice: string;
|
|
354
|
+
branch: string;
|
|
355
|
+
path: string;
|
|
356
|
+
head: string | null;
|
|
357
|
+
/** `git status --porcelain` lines recorded before removal (the scratch dir excluded). */
|
|
358
|
+
dirty: string[];
|
|
359
|
+
forced: boolean;
|
|
360
|
+
removed: boolean;
|
|
361
|
+
/** Local commits not on the remote branch; null when it cannot tell. */
|
|
362
|
+
unpushed: number | null;
|
|
363
|
+
notes: string[];
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
export function releaseSliceWorktree(
|
|
367
|
+
cwd: string,
|
|
368
|
+
plan: FanoutFile,
|
|
369
|
+
input: { slice: string; force: boolean; actor: LedgerActor },
|
|
370
|
+
): FanoutResult<ReleaseOutcome> {
|
|
371
|
+
const found = findSlice(plan, input.slice);
|
|
372
|
+
if (!found.ok) return found;
|
|
373
|
+
const slice = found.data;
|
|
374
|
+
const target = slicePath(cwd, slice);
|
|
375
|
+
const worktrees = listWorktrees(cwd);
|
|
376
|
+
const index = worktrees.findIndex((entry) => samePath(entry.path, target));
|
|
377
|
+
const out: ReleaseOutcome = {
|
|
378
|
+
fanout: plan.name,
|
|
379
|
+
slice: slice.id,
|
|
380
|
+
branch: slice.branch,
|
|
381
|
+
path: target,
|
|
382
|
+
head: null,
|
|
383
|
+
dirty: [],
|
|
384
|
+
forced: input.force,
|
|
385
|
+
removed: false,
|
|
386
|
+
unpushed: null,
|
|
387
|
+
notes: [],
|
|
388
|
+
};
|
|
389
|
+
if (index < 0) {
|
|
390
|
+
if (fs.existsSync(target))
|
|
391
|
+
return fanoutFail(
|
|
392
|
+
"blocked",
|
|
393
|
+
`${target} is not a worktree of this repository; nothing was removed`,
|
|
394
|
+
"remove it yourself once you know what it holds",
|
|
395
|
+
);
|
|
396
|
+
out.notes.push(`no worktree at ${target}; nothing to release`);
|
|
397
|
+
return { ok: true, data: out };
|
|
398
|
+
}
|
|
399
|
+
const entry = worktrees[index];
|
|
400
|
+
if (index === 0)
|
|
401
|
+
return fanoutFail("blocked", `${target} is the main checkout; it is never released`);
|
|
402
|
+
if (inside(cwd, entry.path))
|
|
403
|
+
return fanoutFail(
|
|
404
|
+
"blocked",
|
|
405
|
+
`this command runs inside ${entry.path}`,
|
|
406
|
+
`run it from the main checkout: workit --cwd <main checkout> fanout worktree release ${slice.id}`,
|
|
407
|
+
);
|
|
408
|
+
if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
|
|
409
|
+
return fanoutFail(
|
|
410
|
+
"blocked",
|
|
411
|
+
`${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`,
|
|
412
|
+
);
|
|
413
|
+
const made = createdRow(cwd, plan, slice, entry.path);
|
|
414
|
+
if (!made)
|
|
415
|
+
return fanoutFail(
|
|
416
|
+
"blocked",
|
|
417
|
+
`${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`,
|
|
418
|
+
`remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
419
|
+
);
|
|
420
|
+
out.head = entry.head;
|
|
421
|
+
const status = gitRun(entry.path, [
|
|
422
|
+
"status",
|
|
423
|
+
"--porcelain=v1",
|
|
424
|
+
"-z",
|
|
425
|
+
"--untracked-files=all",
|
|
426
|
+
"--ignore-submodules=none",
|
|
427
|
+
]);
|
|
428
|
+
if (!status.ok)
|
|
429
|
+
return fanoutFail(
|
|
430
|
+
"failed",
|
|
431
|
+
`git status in ${entry.path} failed: ${status.stderr.trim().split("\n")[0]}`,
|
|
432
|
+
"nothing was removed; inspect the worktree by hand",
|
|
433
|
+
);
|
|
434
|
+
const entries = porcelainEntries(status.stdout);
|
|
435
|
+
const inScratch = (line: string) => line.slice(3).startsWith(`${SCRATCH_DIR}/`);
|
|
436
|
+
out.dirty = entries.filter((line) => !inScratch(line));
|
|
437
|
+
out.unpushed = unpushedCount(cwd, slice.branch);
|
|
438
|
+
const refused = out.dirty.length > 0 && !input.force;
|
|
439
|
+
// Record first: once the worktree is gone its state cannot be read back.
|
|
440
|
+
const recorded = appendObserved(cwd, {
|
|
441
|
+
type: "fanout.worktree.released",
|
|
442
|
+
actor: input.actor,
|
|
443
|
+
fanout: plan.name,
|
|
444
|
+
slice: slice.id,
|
|
445
|
+
branch: slice.branch,
|
|
446
|
+
path: entry.path,
|
|
447
|
+
head: entry.head,
|
|
448
|
+
uncommittedCount: out.dirty.length,
|
|
449
|
+
uncommitted: rowSample(out.dirty),
|
|
450
|
+
forced: input.force,
|
|
451
|
+
outcome: refused ? "refused" : "removed",
|
|
452
|
+
});
|
|
453
|
+
if (!recorded.ok)
|
|
454
|
+
return fanoutFail(
|
|
455
|
+
"unavailable",
|
|
456
|
+
`cannot record the worktree state in the ledger (${recorded.error}); nothing was removed`,
|
|
457
|
+
);
|
|
458
|
+
if (refused)
|
|
459
|
+
return fanoutFail(
|
|
460
|
+
"blocked",
|
|
461
|
+
`${entry.path} has ${out.dirty.length} uncommitted change${out.dirty.length === 1 ? "" : "s"} (${out.dirty.slice(0, 3).join(", ")}${out.dirty.length > 3 ? ", …" : ""}); recorded in the ledger, nothing removed`,
|
|
462
|
+
`commit them on ${slice.branch} (workit git commit), or pass --force to drop them`,
|
|
463
|
+
{ ...out },
|
|
464
|
+
);
|
|
465
|
+
const args = ["worktree", "remove", ...(input.force ? ["--force"] : []), entry.path];
|
|
466
|
+
let removed = gitRun(cwd, args, SLOW);
|
|
467
|
+
if (!removed.ok && entries.some(inScratch)) {
|
|
468
|
+
// The scratch dir shows as untracked (its info/exclude line was removed):
|
|
469
|
+
// clear it, the only thing in the way, and try once more.
|
|
470
|
+
removeScratch(entry.path);
|
|
471
|
+
removed = gitRun(cwd, args, SLOW);
|
|
472
|
+
}
|
|
473
|
+
if (!removed.ok)
|
|
474
|
+
return fanoutFail(
|
|
475
|
+
"failed",
|
|
476
|
+
`git worktree remove failed: ${removed.stderr.trim().split("\n")[0]}`,
|
|
477
|
+
entry.locked ? `git worktree unlock ${entry.path}, then release again` : undefined,
|
|
478
|
+
{ ...out },
|
|
479
|
+
);
|
|
480
|
+
out.removed = true;
|
|
481
|
+
if (made.dirExisted === true) fs.mkdirSync(entry.path, { recursive: true });
|
|
482
|
+
if (out.unpushed)
|
|
483
|
+
out.notes.push(`${slice.branch} is kept with ${out.unpushed} commit(s) not on the remote`);
|
|
484
|
+
return { ok: true, data: out };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** `XY path` entries from porcelain v1 -z (a rename's source path is folded in). */
|
|
488
|
+
function porcelainEntries(stdout: string): string[] {
|
|
489
|
+
const fields = nulList(stdout);
|
|
490
|
+
const out: string[] = [];
|
|
491
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
492
|
+
const entry = fields[index];
|
|
493
|
+
out.push(entry);
|
|
494
|
+
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function unpushedCount(cwd: string, branch: string): number | null {
|
|
500
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
501
|
+
const local = resolveRef(cwd, `refs/heads/${branch}`);
|
|
502
|
+
if (!local) return null;
|
|
503
|
+
const tracking = resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
|
|
504
|
+
if (!tracking) return null;
|
|
505
|
+
const count = gitRun(cwd, ["rev-list", "--count", `${tracking}..${local}`]);
|
|
506
|
+
return count.ok ? Number(count.stdout.trim()) : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Remove `<worktree>/.workit-scratch` only: a symlink is unlinked, never followed. */
|
|
510
|
+
function removeScratch(worktree: string): void {
|
|
511
|
+
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
512
|
+
let stat: fs.Stats;
|
|
513
|
+
try {
|
|
514
|
+
stat = fs.lstatSync(scratch);
|
|
515
|
+
} catch {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fs.unlinkSync(scratch);
|
|
519
|
+
else fs.rmSync(scratch, { recursive: true, force: true, maxRetries: 2 });
|
|
520
|
+
}
|