@workit-dev/core 0.0.1 → 1.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,893 @@
|
|
|
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, 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
|
|
24
|
+
// directory that existed before `create` is left in place.
|
|
25
|
+
import { randomBytes } from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { gitBranch } from "./git/ops";
|
|
29
|
+
import { GIT_TIMEOUTS, gitCommonDir, pushRemoteName, resolveRef } from "./git/rev";
|
|
30
|
+
import type { ResolvedForge } from "./forge/resolve";
|
|
31
|
+
import { appendObserved, readLedger, type LedgerActor } from "./ledger";
|
|
32
|
+
import { detectLanding, forgeBreaker, sliceStart } from "./fanout-landed";
|
|
33
|
+
import { recordSliceStack } from "./fanout-stack";
|
|
34
|
+
import { FANOUT_TOKEN_FILE } from "./hooks/worktree-confine";
|
|
35
|
+
import { linkWorktreeDependencies, type LinkedDependencies } from "./worktree-deps";
|
|
36
|
+
import {
|
|
37
|
+
branchRef,
|
|
38
|
+
fanoutFail,
|
|
39
|
+
gitRun,
|
|
40
|
+
mainCheckout,
|
|
41
|
+
nulList,
|
|
42
|
+
trunkRef,
|
|
43
|
+
type FanoutFile,
|
|
44
|
+
type FanoutResult,
|
|
45
|
+
type Slice,
|
|
46
|
+
} from "./fanout";
|
|
47
|
+
|
|
48
|
+
const SLOW = GIT_TIMEOUTS.worktree;
|
|
49
|
+
export const SCRATCH_DIR = ".workit-scratch";
|
|
50
|
+
const EXCLUDE_LINE = `/${SCRATCH_DIR}/`;
|
|
51
|
+
/** Dirty paths kept in the ledger row: it has a 4 KB line limit. */
|
|
52
|
+
const DIRTY_ROW_BYTES = 2000;
|
|
53
|
+
|
|
54
|
+
/** The first dirty entries that fit the row; dirtyCount keeps the total. */
|
|
55
|
+
function rowSample(lines: readonly string[]): string[] {
|
|
56
|
+
const out: string[] = [];
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
const entry = line.slice(0, 200);
|
|
60
|
+
bytes += Buffer.byteLength(entry) + 3;
|
|
61
|
+
if (bytes > DIRTY_ROW_BYTES) break;
|
|
62
|
+
out.push(entry);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type WorktreeEntry = {
|
|
68
|
+
path: string;
|
|
69
|
+
head: string | null;
|
|
70
|
+
/** refs/heads/<name>, or null when detached. */
|
|
71
|
+
branch: string | null;
|
|
72
|
+
locked: boolean;
|
|
73
|
+
/** git would prune it: its directory is gone. */
|
|
74
|
+
prunable: boolean;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const canonical = (target: string): string => {
|
|
78
|
+
try {
|
|
79
|
+
return fs.realpathSync.native(target);
|
|
80
|
+
} catch {
|
|
81
|
+
return path.resolve(target);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const samePath = (a: string, b: string): boolean => {
|
|
86
|
+
const left = canonical(a);
|
|
87
|
+
const right = canonical(b);
|
|
88
|
+
return process.platform === "win32" || process.platform === "darwin"
|
|
89
|
+
? left.toLowerCase() === right.toLowerCase()
|
|
90
|
+
: left === right;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const inside = (child: string, parent: string): boolean => {
|
|
94
|
+
const relative = path.relative(canonical(parent), canonical(child));
|
|
95
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** `git worktree list --porcelain -z`; the first entry is the main worktree. */
|
|
99
|
+
export function listWorktrees(cwd: string): WorktreeEntry[] {
|
|
100
|
+
const out: WorktreeEntry[] = [];
|
|
101
|
+
let current: WorktreeEntry | null = null;
|
|
102
|
+
for (const field of gitRun(cwd, ["worktree", "list", "--porcelain", "-z"]).stdout.split("\0")) {
|
|
103
|
+
if (field.startsWith("worktree ")) {
|
|
104
|
+
current = {
|
|
105
|
+
path: field.slice(9),
|
|
106
|
+
head: null,
|
|
107
|
+
branch: null,
|
|
108
|
+
locked: false,
|
|
109
|
+
prunable: false,
|
|
110
|
+
};
|
|
111
|
+
out.push(current);
|
|
112
|
+
} else if (!current) continue;
|
|
113
|
+
else if (field.startsWith("HEAD ")) current.head = field.slice(5);
|
|
114
|
+
else if (field.startsWith("branch ")) current.branch = field.slice(7);
|
|
115
|
+
else if (field === "locked" || field.startsWith("locked ")) current.locked = true;
|
|
116
|
+
else if (field === "prunable" || field.startsWith("prunable ")) current.prunable = true;
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The slice's worktree path: absolute since plan time; an older relative one is the main checkout's. */
|
|
122
|
+
export const slicePath = (cwd: string, slice: Slice): string =>
|
|
123
|
+
path.isAbsolute(slice.worktree)
|
|
124
|
+
? slice.worktree
|
|
125
|
+
: path.resolve(mainCheckout(cwd), slice.worktree);
|
|
126
|
+
|
|
127
|
+
/** Timestamps of the admin dir and the ledger have second granularity on some filesystems. */
|
|
128
|
+
const CLOCK_SLACK_MS = 2000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The live `fanout.worktree.created` row for this fanout, slice and path:
|
|
132
|
+
* the newest one, unless a later release removed that worktree (a row is
|
|
133
|
+
* void once its worktree is gone), and only while the worktree at the path
|
|
134
|
+
* is the one it recorded. `exact`: the admin dir's token, id and inode match
|
|
135
|
+
* the row. A row without a token (written before it) matches when the admin
|
|
136
|
+
* dir is no older than the row, which a re-added worktree also passes, so it
|
|
137
|
+
* is never exact. Anything else is someone else's worktree.
|
|
138
|
+
*/
|
|
139
|
+
function createdRow(cwd: string, plan: FanoutFile, slice: Slice, target: string) {
|
|
140
|
+
const ledger = readLedger(cwd);
|
|
141
|
+
if (!ledger.ok) return null;
|
|
142
|
+
const atPath = (row: Record<string, unknown>) =>
|
|
143
|
+
row.fanout === plan.name &&
|
|
144
|
+
row.slice === slice.id &&
|
|
145
|
+
typeof row.path === "string" &&
|
|
146
|
+
samePath(row.path, target);
|
|
147
|
+
const rows = ledger.value.rows;
|
|
148
|
+
const row = rows.findLast(
|
|
149
|
+
(candidate) => candidate.type === "fanout.worktree.created" && atPath(candidate),
|
|
150
|
+
);
|
|
151
|
+
if (!row) return null;
|
|
152
|
+
const removedSince = rows.some(
|
|
153
|
+
(candidate) =>
|
|
154
|
+
candidate.seq > row.seq &&
|
|
155
|
+
candidate.type === "fanout.worktree.released" &&
|
|
156
|
+
candidate.outcome === "removed" &&
|
|
157
|
+
atPath(candidate),
|
|
158
|
+
);
|
|
159
|
+
if (removedSince) return null;
|
|
160
|
+
const admin = adminDir(target);
|
|
161
|
+
if (!admin) return null;
|
|
162
|
+
if (typeof row.adminToken === "string")
|
|
163
|
+
return row.adminToken === admin.token && row.adminId === admin.id && row.adminIno === admin.ino
|
|
164
|
+
? { row, exact: true }
|
|
165
|
+
: null;
|
|
166
|
+
const made = admin.birthMs ?? admin.mtimeMs;
|
|
167
|
+
if (made + CLOCK_SLACK_MS < Date.parse(row.at)) return null;
|
|
168
|
+
return { row, exact: false };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The filesystem calls identity relies on; tests stub them. */
|
|
172
|
+
export const worktreeDeps = {
|
|
173
|
+
stat: (file: string): fs.BigIntStats => fs.statSync(file, { bigint: true }),
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
type AdminDir = {
|
|
177
|
+
path: string;
|
|
178
|
+
/** `<id>` of `.git/worktrees/<id>`. */
|
|
179
|
+
id: string;
|
|
180
|
+
ino: string;
|
|
181
|
+
/** The token create wrote there, or null. */
|
|
182
|
+
token: string | null;
|
|
183
|
+
birthMs: number | null;
|
|
184
|
+
mtimeMs: number;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** Git's admin dir for the worktree: what tells this worktree from a later one at the path. */
|
|
188
|
+
function adminDir(worktree: string): AdminDir | null {
|
|
189
|
+
const dir = gitRun(worktree, ["rev-parse", "--absolute-git-dir"]);
|
|
190
|
+
if (!dir.ok) return null;
|
|
191
|
+
const file = dir.stdout.trim();
|
|
192
|
+
let token: string | null = null;
|
|
193
|
+
try {
|
|
194
|
+
token = fs.readFileSync(path.join(file, FANOUT_TOKEN_FILE), "utf8").trim() || null;
|
|
195
|
+
} catch {
|
|
196
|
+
token = null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const stat = worktreeDeps.stat(file);
|
|
200
|
+
return {
|
|
201
|
+
path: file,
|
|
202
|
+
id: path.basename(file),
|
|
203
|
+
ino: stat.ino.toString(),
|
|
204
|
+
token,
|
|
205
|
+
birthMs: stat.birthtimeNs > 0n ? Number(stat.birthtimeMs) : null,
|
|
206
|
+
mtimeMs: Number(stat.mtimeMs),
|
|
207
|
+
};
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Mark the admin dir of a worktree create just made; null when it cannot. */
|
|
214
|
+
function markAdminDir(worktree: string): (AdminDir & { token: string }) | null {
|
|
215
|
+
const admin = adminDir(worktree);
|
|
216
|
+
if (!admin) return null;
|
|
217
|
+
const token = randomBytes(16).toString("hex");
|
|
218
|
+
try {
|
|
219
|
+
fs.writeFileSync(path.join(admin.path, FANOUT_TOKEN_FILE), `${token}\n`);
|
|
220
|
+
} catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return { ...admin, token };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function findSlice(plan: FanoutFile, id: string): FanoutResult<Slice> {
|
|
227
|
+
const slice = plan.slices.find((candidate) => candidate.id === id);
|
|
228
|
+
return slice
|
|
229
|
+
? { ok: true, data: slice }
|
|
230
|
+
: fanoutFail(
|
|
231
|
+
"invalid_input",
|
|
232
|
+
`${id}: not a slice of fanout ${plan.name}`,
|
|
233
|
+
`slices: ${plan.slices.map((candidate) => candidate.id).join(", ")}`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Hide the scratch dir from git status and `git add -A` in every worktree. */
|
|
238
|
+
export function excludeScratch(cwd: string): void {
|
|
239
|
+
const common = gitCommonDir(cwd);
|
|
240
|
+
if (!common) return;
|
|
241
|
+
const file = path.join(common, "info", "exclude");
|
|
242
|
+
let text = "";
|
|
243
|
+
try {
|
|
244
|
+
text = fs.readFileSync(file, "utf8");
|
|
245
|
+
} catch {
|
|
246
|
+
text = "";
|
|
247
|
+
}
|
|
248
|
+
if (text.split(/\r?\n/u).includes(EXCLUDE_LINE)) return;
|
|
249
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
250
|
+
fs.appendFileSync(
|
|
251
|
+
file,
|
|
252
|
+
`${text && !text.endsWith("\n") ? "\n" : ""}# workit fanout: per-worker scratch dirs\n${EXCLUDE_LINE}\n`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export type CreateOutcome = {
|
|
257
|
+
fanout: string;
|
|
258
|
+
slice: string;
|
|
259
|
+
branch: string;
|
|
260
|
+
base: string;
|
|
261
|
+
path: string;
|
|
262
|
+
scratch: string;
|
|
263
|
+
head: string;
|
|
264
|
+
/** new: the branch was created from its base; resume: the existing branch was checked out. */
|
|
265
|
+
mode: "new" | "resume";
|
|
266
|
+
/** False when the worktree already existed on the slice's branch. */
|
|
267
|
+
created: boolean;
|
|
268
|
+
/** node_modules linked from the main checkout (worktree-deps.ts); null when none were. */
|
|
269
|
+
dependencies: LinkedDependencies | null;
|
|
270
|
+
/** The install command to run in the worktree when its lockfile differs from the main checkout's. */
|
|
271
|
+
install: string | null;
|
|
272
|
+
/** The `workit stack` this slice was recorded in when it started on its parent slice's branch. */
|
|
273
|
+
stack: { name: string; slices: string[] } | null;
|
|
274
|
+
notes: string[];
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** Lockfiles in the order a package manager is picked, with its frozen install for a directory. */
|
|
278
|
+
const LOCKFILES: ReadonlyArray<[string, (dir: string) => string]> = [
|
|
279
|
+
["bun.lock", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
280
|
+
["bun.lockb", (dir) => `bun install --frozen-lockfile --cwd ${dir}`],
|
|
281
|
+
["pnpm-lock.yaml", (dir) => `pnpm install --frozen-lockfile --dir ${dir}`],
|
|
282
|
+
["yarn.lock", (dir) => `yarn install --frozen-lockfile --cwd ${dir}`],
|
|
283
|
+
["package-lock.json", (dir) => `npm ci --prefix ${dir}`],
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
/** Equal content once CRLF is read as LF (git's autocrlf rewrites checkouts). */
|
|
287
|
+
const sameText = (a: Buffer, b: Buffer): boolean =>
|
|
288
|
+
a.equals(b) ||
|
|
289
|
+
a.toString("utf8").replaceAll("\r\n", "\n") === b.toString("utf8").replaceAll("\r\n", "\n");
|
|
290
|
+
|
|
291
|
+
const readBytes = (file: string): Buffer | null => {
|
|
292
|
+
try {
|
|
293
|
+
return fs.readFileSync(file);
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Link the main checkout's node_modules into `worktree` only when every
|
|
301
|
+
* lockfile is identical in both (line endings aside: a Windows checkout with
|
|
302
|
+
* core.autocrlf writes the worktree's copy with CRLF): a stale main checkout would otherwise
|
|
303
|
+
* hand the worktree the wrong dependency versions (#426). When one differs,
|
|
304
|
+
* nothing is linked and `install` is the command the worker runs instead. The
|
|
305
|
+
* same holds when the main checkout's install is older than its bun.lock.
|
|
306
|
+
*/
|
|
307
|
+
function worktreeDependencies(
|
|
308
|
+
main: string,
|
|
309
|
+
worktree: string,
|
|
310
|
+
): {
|
|
311
|
+
linked: LinkedDependencies | null;
|
|
312
|
+
install: string | null;
|
|
313
|
+
notes: string[];
|
|
314
|
+
} {
|
|
315
|
+
for (const [name, install] of LOCKFILES) {
|
|
316
|
+
const ours = readBytes(path.join(main, name));
|
|
317
|
+
const theirs = readBytes(path.join(worktree, name));
|
|
318
|
+
if (ours === null && theirs === null) continue;
|
|
319
|
+
if (ours !== null && theirs !== null && sameText(ours, theirs)) continue;
|
|
320
|
+
if (fs.existsSync(path.join(worktree, "node_modules"))) break;
|
|
321
|
+
const command = install(worktree);
|
|
322
|
+
return {
|
|
323
|
+
linked: null,
|
|
324
|
+
install: command,
|
|
325
|
+
notes: [
|
|
326
|
+
`${name} differs from the main checkout's (${main}), so its node_modules was not linked; install dependencies first: ${command}`,
|
|
327
|
+
],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const stale = staleBunInstall(main);
|
|
331
|
+
if (stale && !fs.existsSync(path.join(worktree, "node_modules"))) {
|
|
332
|
+
const command = `bun install --frozen-lockfile --cwd ${worktree}`;
|
|
333
|
+
return {
|
|
334
|
+
linked: null,
|
|
335
|
+
install: command,
|
|
336
|
+
notes: [
|
|
337
|
+
`the main checkout's node_modules (${main}) do not match its bun.lock (${stale}), so they were not linked; install dependencies first: ${command}`,
|
|
338
|
+
],
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return { ...linkWorktreeDependencies(main, worktree), install: null };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
345
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
346
|
+
|
|
347
|
+
const keysOf = (value: unknown): string[] => (isObject(value) ? Object.keys(value) : []);
|
|
348
|
+
|
|
349
|
+
/** A bun.lock `os`/`cpu` field (`"none"`, a name, a list, `!name`) excludes `current`. */
|
|
350
|
+
function excludes(field: unknown, current: string): boolean {
|
|
351
|
+
const values = typeof field === "string" ? [field] : Array.isArray(field) ? field : null;
|
|
352
|
+
if (!values?.length) return false;
|
|
353
|
+
const names = values.filter((value): value is string => typeof value === "string");
|
|
354
|
+
if (names.includes("none")) return true;
|
|
355
|
+
if (names.includes(`!${current}`)) return true;
|
|
356
|
+
const allowed = names.filter((name) => !name.startsWith("!"));
|
|
357
|
+
return allowed.length > 0 && !allowed.includes(current);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The bun.lock packages an install may leave out on this machine (W2.14):
|
|
362
|
+
* one whose `os`/`cpu` excludes this platform, and one reached only from such
|
|
363
|
+
* packages or as a peer (`@emnapi/*` under a wasm32 binding). This
|
|
364
|
+
* platform's own optional binding stays required, and so does a package
|
|
365
|
+
* nothing names, so a stale install is still caught.
|
|
366
|
+
*/
|
|
367
|
+
function optionalOnly(
|
|
368
|
+
packages: Record<string, unknown>,
|
|
369
|
+
workspaces: Record<string, unknown>,
|
|
370
|
+
): Set<string> {
|
|
371
|
+
const meta = (key: string): Record<string, unknown> => {
|
|
372
|
+
const entry = packages[key];
|
|
373
|
+
return Array.isArray(entry) && isObject(entry[2]) ? entry[2] : {};
|
|
374
|
+
};
|
|
375
|
+
const resolve = (from: string | null, name: string): string =>
|
|
376
|
+
from !== null && `${from}/${name}` in packages ? `${from}/${name}` : name;
|
|
377
|
+
const optional = new Set<string>();
|
|
378
|
+
for (const key of Object.keys(packages)) {
|
|
379
|
+
const info = meta(key);
|
|
380
|
+
if (excludes(info.os, process.platform) || excludes(info.cpu, process.arch)) optional.add(key);
|
|
381
|
+
}
|
|
382
|
+
// Required edges: what workspaces and installed packages depend on outright.
|
|
383
|
+
const required = new Set<string>();
|
|
384
|
+
for (const workspace of Object.values(workspaces))
|
|
385
|
+
for (const field of ["dependencies", "devDependencies"])
|
|
386
|
+
for (const name of keysOf(isObject(workspace) ? workspace[field] : null))
|
|
387
|
+
required.add(resolve(null, name));
|
|
388
|
+
// Walk from the roots through what bun installs here: dependencies and
|
|
389
|
+
// optional dependencies, never into a package for another platform.
|
|
390
|
+
const queue = [...required];
|
|
391
|
+
const reached = new Set<string>();
|
|
392
|
+
while (queue.length) {
|
|
393
|
+
const key = queue.pop() as string;
|
|
394
|
+
if (reached.has(key) || optional.has(key) || !(key in packages)) continue;
|
|
395
|
+
reached.add(key);
|
|
396
|
+
for (const field of ["dependencies", "optionalDependencies"])
|
|
397
|
+
for (const name of keysOf(meta(key)[field])) queue.push(resolve(key, name));
|
|
398
|
+
}
|
|
399
|
+
const named = new Set<string>();
|
|
400
|
+
for (const key of Object.keys(packages)) {
|
|
401
|
+
const info = meta(key);
|
|
402
|
+
for (const field of ["dependencies", "optionalDependencies", "peerDependencies"])
|
|
403
|
+
for (const name of keysOf(info[field])) named.add(resolve(key, name));
|
|
404
|
+
}
|
|
405
|
+
for (const workspace of Object.values(workspaces))
|
|
406
|
+
if (isObject(workspace))
|
|
407
|
+
for (const field of ["optionalDependencies", "peerDependencies"])
|
|
408
|
+
for (const name of keysOf(workspace[field])) named.add(resolve(null, name));
|
|
409
|
+
for (const key of named) if (!reached.has(key) && !required.has(key)) optional.add(key);
|
|
410
|
+
return optional;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Packages checked against node_modules at most (a lockfile is read whole once). */
|
|
414
|
+
const STALE_SCAN = 5000;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Why the main checkout's node_modules were installed for another bun.lock
|
|
418
|
+
* (#493), or null when every top-level package bun.lock pins is installed at
|
|
419
|
+
* its version. Bun keeps no install marker, so the installed package.json
|
|
420
|
+
* versions are the record: a checkout fast-forwarded without `bun install`
|
|
421
|
+
* shows here even though its lockfile equals the worktree's.
|
|
422
|
+
*/
|
|
423
|
+
function staleBunInstall(main: string): string | null {
|
|
424
|
+
if (!fs.existsSync(path.join(main, "node_modules"))) return null;
|
|
425
|
+
const text = readBytes(path.join(main, "bun.lock"))?.toString("utf8");
|
|
426
|
+
if (!text) return null;
|
|
427
|
+
let packages: Record<string, unknown>;
|
|
428
|
+
let parsedWorkspaces: Record<string, unknown>;
|
|
429
|
+
try {
|
|
430
|
+
// bun.lock is JSON with trailing commas.
|
|
431
|
+
const parsed = JSON.parse(text.replace(/,(\s*[}\]])/gu, "$1")) as {
|
|
432
|
+
packages?: unknown;
|
|
433
|
+
workspaces?: unknown;
|
|
434
|
+
};
|
|
435
|
+
if (!parsed.packages || typeof parsed.packages !== "object") return null;
|
|
436
|
+
packages = parsed.packages as Record<string, unknown>;
|
|
437
|
+
parsedWorkspaces = isObject(parsed.workspaces) ? parsed.workspaces : {};
|
|
438
|
+
} catch {
|
|
439
|
+
return null;
|
|
440
|
+
}
|
|
441
|
+
const optional = optionalOnly(packages, parsedWorkspaces);
|
|
442
|
+
const problems: string[] = [];
|
|
443
|
+
let scanned = 0;
|
|
444
|
+
for (const [key, entry] of Object.entries(packages)) {
|
|
445
|
+
if (scanned++ >= STALE_SCAN || problems.length >= 3) break;
|
|
446
|
+
const spec = Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : null;
|
|
447
|
+
if (!spec) continue;
|
|
448
|
+
const at = spec.lastIndexOf("@");
|
|
449
|
+
if (at <= 0) continue;
|
|
450
|
+
const name = spec.slice(0, at);
|
|
451
|
+
const version = spec.slice(at + 1);
|
|
452
|
+
// Only hoisted packages under their own name, at a registry version.
|
|
453
|
+
if (name !== key || version.includes(":")) continue;
|
|
454
|
+
const manifest = readBytes(path.join(main, "node_modules", key, "package.json"));
|
|
455
|
+
if (!manifest) {
|
|
456
|
+
// bun skips an optional dependency for another platform (W2.14).
|
|
457
|
+
if (!optional.has(key)) problems.push(`${key} missing`);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
let installed: unknown;
|
|
461
|
+
try {
|
|
462
|
+
installed = (JSON.parse(manifest.toString("utf8")) as { version?: unknown }).version;
|
|
463
|
+
} catch {
|
|
464
|
+
installed = null;
|
|
465
|
+
}
|
|
466
|
+
if (installed !== version)
|
|
467
|
+
problems.push(`${key} ${String(installed ?? "?")} installed, bun.lock has ${version}`);
|
|
468
|
+
}
|
|
469
|
+
return problems.length ? problems.join("; ") : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function ensureScratch(worktree: string): string {
|
|
473
|
+
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
474
|
+
fs.mkdirSync(scratch, { recursive: true });
|
|
475
|
+
return scratch;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function createSliceWorktree(
|
|
479
|
+
cwd: string,
|
|
480
|
+
plan: FanoutFile,
|
|
481
|
+
input: {
|
|
482
|
+
slice: string;
|
|
483
|
+
actor: LedgerActor;
|
|
484
|
+
/** The forge for landing lookups (a dependency merged by squash); asked only when needed. */
|
|
485
|
+
forge?: () => ResolvedForge | null;
|
|
486
|
+
},
|
|
487
|
+
): FanoutResult<CreateOutcome> {
|
|
488
|
+
const found = findSlice(plan, input.slice);
|
|
489
|
+
if (!found.ok) return found;
|
|
490
|
+
const slice = found.data;
|
|
491
|
+
if (!slice.worktree)
|
|
492
|
+
return fanoutFail(
|
|
493
|
+
"invalid_input",
|
|
494
|
+
`slice ${slice.id} has no worktree path`,
|
|
495
|
+
"re-plan it with workit fanout plan",
|
|
496
|
+
);
|
|
497
|
+
const target = slicePath(cwd, slice);
|
|
498
|
+
const worktrees = listWorktrees(cwd);
|
|
499
|
+
const localRef = `refs/heads/${slice.branch}`;
|
|
500
|
+
const existing = worktrees.find((entry) => samePath(entry.path, target));
|
|
501
|
+
const base = {
|
|
502
|
+
fanout: plan.name,
|
|
503
|
+
slice: slice.id,
|
|
504
|
+
branch: slice.branch,
|
|
505
|
+
base: slice.base,
|
|
506
|
+
path: target,
|
|
507
|
+
};
|
|
508
|
+
if (existing) {
|
|
509
|
+
if (existing.branch !== null && existing.branch !== localRef)
|
|
510
|
+
return fanoutFail(
|
|
511
|
+
"blocked",
|
|
512
|
+
`${target} is a worktree on ${existing.branch.replace(/^refs\/heads\//u, "")}, not ${slice.branch}`,
|
|
513
|
+
`release it first, or set slice ${slice.id}'s worktree to another path`,
|
|
514
|
+
);
|
|
515
|
+
if (!createdRow(cwd, plan, slice, target))
|
|
516
|
+
return fanoutFail(
|
|
517
|
+
"blocked",
|
|
518
|
+
`${target} is a worktree that workit fanout worktree create did not make for slice ${slice.id}`,
|
|
519
|
+
`remove it yourself (git worktree remove ${target}) once you know what it holds`,
|
|
520
|
+
);
|
|
521
|
+
excludeScratch(cwd);
|
|
522
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
data: {
|
|
526
|
+
...base,
|
|
527
|
+
scratch: ensureScratch(target),
|
|
528
|
+
head: existing.head ?? "",
|
|
529
|
+
mode: resolveRef(cwd, localRef) ? "resume" : "new",
|
|
530
|
+
created: false,
|
|
531
|
+
dependencies: deps.linked,
|
|
532
|
+
install: deps.install,
|
|
533
|
+
stack: null,
|
|
534
|
+
notes: [
|
|
535
|
+
deps.linked
|
|
536
|
+
? "the worktree already exists; only its missing dependencies were linked"
|
|
537
|
+
: "the worktree already exists; nothing was changed",
|
|
538
|
+
...deps.notes,
|
|
539
|
+
],
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const elsewhere = worktrees.find((entry) => entry.branch === localRef);
|
|
544
|
+
if (elsewhere)
|
|
545
|
+
return fanoutFail(
|
|
546
|
+
"blocked",
|
|
547
|
+
`${slice.branch} is checked out at ${elsewhere.path}: never two live workers on one branch`,
|
|
548
|
+
`stop that worker, then workit fanout worktree release ${slice.id} (or git worktree remove ${elsewhere.path})`,
|
|
549
|
+
);
|
|
550
|
+
let dirExisted = false;
|
|
551
|
+
try {
|
|
552
|
+
const stat = fs.statSync(target);
|
|
553
|
+
if (!stat.isDirectory() || fs.readdirSync(target).length > 0)
|
|
554
|
+
return fanoutFail(
|
|
555
|
+
"blocked",
|
|
556
|
+
`${target} exists and is ${stat.isDirectory() ? "not empty" : "not a directory"} (not a worktree of this repository)`,
|
|
557
|
+
`move it away, or set slice ${slice.id}'s worktree to another path`,
|
|
558
|
+
);
|
|
559
|
+
dirExisted = true;
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
562
|
+
return fanoutFail("failed", `cannot read ${target}: ${(error as Error).message}`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const notes: string[] = [];
|
|
566
|
+
let mode: CreateOutcome["mode"] = "resume";
|
|
567
|
+
let stack: CreateOutcome["stack"] = null;
|
|
568
|
+
if (resolveRef(cwd, localRef)) {
|
|
569
|
+
const added = gitRun(cwd, ["worktree", "add", target, slice.branch], SLOW);
|
|
570
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
571
|
+
} else {
|
|
572
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
573
|
+
const remoteRef = `refs/remotes/${remote}/${slice.branch}`;
|
|
574
|
+
if (resolveRef(cwd, remoteRef)) {
|
|
575
|
+
// The branch exists on the remote only: resume it from there.
|
|
576
|
+
const added = gitRun(cwd, ["worktree", "add", "-b", slice.branch, target, remoteRef], SLOW);
|
|
577
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
578
|
+
notes.push(`resumed ${slice.branch} from ${remote}/${slice.branch}`);
|
|
579
|
+
} else {
|
|
580
|
+
mode = "new";
|
|
581
|
+
const landedOn = dependenciesLanded(cwd, plan, slice, input.forge);
|
|
582
|
+
if (landedOn) {
|
|
583
|
+
base.base = plan.trunk;
|
|
584
|
+
notes.push(landedOn);
|
|
585
|
+
}
|
|
586
|
+
const parent = landedOn
|
|
587
|
+
? undefined
|
|
588
|
+
: plan.slices.find((other) => other.branch === slice.base);
|
|
589
|
+
const from =
|
|
590
|
+
base.base === plan.trunk
|
|
591
|
+
? trunkRef(cwd, plan.trunk)
|
|
592
|
+
: parent
|
|
593
|
+
? branchRef(cwd, parent.branch)
|
|
594
|
+
: branchRef(cwd, slice.base);
|
|
595
|
+
if (!from)
|
|
596
|
+
return fanoutFail(
|
|
597
|
+
"not_found",
|
|
598
|
+
`base ${slice.base} of slice ${slice.id} does not resolve here`,
|
|
599
|
+
parent
|
|
600
|
+
? `start slice ${parent.id} first (its branch ${parent.branch} is the base)`
|
|
601
|
+
: `git fetch origin ${slice.base}`,
|
|
602
|
+
);
|
|
603
|
+
const added = gitRun(cwd, ["worktree", "add", "--detach", target, from], SLOW);
|
|
604
|
+
if (!added.ok) return worktreeFailed(added.stderr);
|
|
605
|
+
const branched = gitBranch(target, {
|
|
606
|
+
name: slice.branch,
|
|
607
|
+
base: base.base,
|
|
608
|
+
});
|
|
609
|
+
if (!branched.ok) {
|
|
610
|
+
// The worktree was made by this call a moment ago and is clean.
|
|
611
|
+
gitRun(cwd, ["worktree", "remove", target], SLOW);
|
|
612
|
+
return fanoutFail(
|
|
613
|
+
branched.code,
|
|
614
|
+
`workit git branch ${slice.branch} refused: ${branched.error}`,
|
|
615
|
+
branched.unblock ??
|
|
616
|
+
`fix the branch name in the plan, then workit fanout worktree create ${slice.id}`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
notes.push(...branched.data.notes);
|
|
620
|
+
if (parent) {
|
|
621
|
+
const recorded = recordSliceStack(cwd, plan, slice);
|
|
622
|
+
if (recorded?.ok) {
|
|
623
|
+
stack = { name: recorded.name, slices: recorded.slices };
|
|
624
|
+
notes.push(
|
|
625
|
+
`stacked on ${parent.id}: stack ${recorded.name} is ${recorded.slices.join(" -> ")}; once ${parent.branch} merges (a squash too), workit stack sync --name ${recorded.name} rebases ${slice.branch} onto ${plan.trunk} without ${parent.id}'s commits`,
|
|
626
|
+
);
|
|
627
|
+
} else if (recorded) notes.push(`${recorded.error}; record it with: ${recorded.unblock}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
excludeScratch(cwd);
|
|
632
|
+
const scratch = ensureScratch(target);
|
|
633
|
+
const deps = worktreeDependencies(mainCheckout(cwd), target);
|
|
634
|
+
notes.push(...deps.notes);
|
|
635
|
+
const head = gitRun(target, ["rev-parse", "HEAD"]).stdout.trim();
|
|
636
|
+
const baseSha = mode === "new" ? head : null;
|
|
637
|
+
const admin = markAdminDir(target);
|
|
638
|
+
appendObserved(cwd, {
|
|
639
|
+
type: "fanout.worktree.created",
|
|
640
|
+
actor: input.actor,
|
|
641
|
+
fanout: plan.name,
|
|
642
|
+
slice: slice.id,
|
|
643
|
+
branch: slice.branch,
|
|
644
|
+
path: target,
|
|
645
|
+
scratch,
|
|
646
|
+
mode,
|
|
647
|
+
head,
|
|
648
|
+
dirExisted,
|
|
649
|
+
planCreatedAt: plan.createdAt,
|
|
650
|
+
sliceHash: slice.hash,
|
|
651
|
+
...(admin ? { adminId: admin.id, adminIno: admin.ino, adminToken: admin.token } : {}),
|
|
652
|
+
...(baseSha ? { baseSha } : {}),
|
|
653
|
+
...(base.base === slice.base ? {} : { startBase: base.base }),
|
|
654
|
+
...(deps.install ? { install: deps.install } : {}),
|
|
655
|
+
...(stack ? { stack: stack.name } : {}),
|
|
656
|
+
});
|
|
657
|
+
return {
|
|
658
|
+
ok: true,
|
|
659
|
+
data: {
|
|
660
|
+
...base,
|
|
661
|
+
scratch,
|
|
662
|
+
head,
|
|
663
|
+
mode,
|
|
664
|
+
created: true,
|
|
665
|
+
dependencies: deps.linked,
|
|
666
|
+
install: deps.install,
|
|
667
|
+
stack,
|
|
668
|
+
notes,
|
|
669
|
+
},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* When every slice this one depends on has landed (a squash merge leaves the
|
|
675
|
+
* dependency branch at its pre-squash tip), the note saying so: the new
|
|
676
|
+
* branch then starts from the trunk. Null while any dependency is open.
|
|
677
|
+
*/
|
|
678
|
+
function dependenciesLanded(
|
|
679
|
+
cwd: string,
|
|
680
|
+
plan: FanoutFile,
|
|
681
|
+
slice: Slice,
|
|
682
|
+
forge: (() => ResolvedForge | null) | undefined,
|
|
683
|
+
): string | null {
|
|
684
|
+
if (slice.base === plan.trunk || slice.dependsOn.length === 0) return null;
|
|
685
|
+
const deps = slice.dependsOn.map((id) => plan.slices.find((other) => other.id === id));
|
|
686
|
+
if (deps.some((dep) => !dep)) return null;
|
|
687
|
+
const trunk = trunkRef(cwd, plan.trunk);
|
|
688
|
+
if (!trunk) return null;
|
|
689
|
+
const ledger = readLedger(cwd);
|
|
690
|
+
const rows = ledger.ok ? ledger.value.rows : [];
|
|
691
|
+
const ctx = { cwd, trunk, forge: forgeBreaker(forge?.() ?? null).forge };
|
|
692
|
+
const landed: string[] = [];
|
|
693
|
+
for (const dep of deps as Slice[]) {
|
|
694
|
+
const landing = detectLanding(ctx, dep, {
|
|
695
|
+
head: branchRef(cwd, dep.branch),
|
|
696
|
+
createdFrom: sliceStart(cwd, rows, plan.name, dep),
|
|
697
|
+
parentHead: null,
|
|
698
|
+
fanout: plan.name,
|
|
699
|
+
since: dep.hashSince || plan.createdAt || null,
|
|
700
|
+
sliceHash: dep.hash || null,
|
|
701
|
+
rows,
|
|
702
|
+
});
|
|
703
|
+
if (!landing.landed) return null;
|
|
704
|
+
landed.push(
|
|
705
|
+
`${dep.branch} landed (${landing.how ?? "pr_merged"}${landing.pr ? ` #${landing.pr.number}` : ""})`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return `${landed.join(", ")}: started from ${plan.trunk}, not from ${slice.base}'s pre-merge tip`;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const worktreeFailed = (stderr: string) =>
|
|
712
|
+
fanoutFail(
|
|
713
|
+
"failed",
|
|
714
|
+
`git worktree add failed: ${stderr.trim().split("\n")[0] || "unknown error"}`,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
export type ReleaseOutcome = {
|
|
718
|
+
fanout: string;
|
|
719
|
+
slice: string;
|
|
720
|
+
branch: string;
|
|
721
|
+
path: string;
|
|
722
|
+
head: string | null;
|
|
723
|
+
/** `git status --porcelain` lines recorded before removal (the scratch dir excluded). */
|
|
724
|
+
dirty: string[];
|
|
725
|
+
forced: boolean;
|
|
726
|
+
removed: boolean;
|
|
727
|
+
/** Local commits not on the remote branch; null when it cannot tell. */
|
|
728
|
+
unpushed: number | null;
|
|
729
|
+
notes: string[];
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
export function releaseSliceWorktree(
|
|
733
|
+
cwd: string,
|
|
734
|
+
plan: FanoutFile,
|
|
735
|
+
input: { slice: string; force: boolean; actor: LedgerActor },
|
|
736
|
+
): FanoutResult<ReleaseOutcome> {
|
|
737
|
+
const found = findSlice(plan, input.slice);
|
|
738
|
+
if (!found.ok) return found;
|
|
739
|
+
const slice = found.data;
|
|
740
|
+
const target = slicePath(cwd, slice);
|
|
741
|
+
const worktrees = listWorktrees(cwd);
|
|
742
|
+
const index = worktrees.findIndex((entry) => samePath(entry.path, target));
|
|
743
|
+
const out: ReleaseOutcome = {
|
|
744
|
+
fanout: plan.name,
|
|
745
|
+
slice: slice.id,
|
|
746
|
+
branch: slice.branch,
|
|
747
|
+
path: target,
|
|
748
|
+
head: null,
|
|
749
|
+
dirty: [],
|
|
750
|
+
forced: input.force,
|
|
751
|
+
removed: false,
|
|
752
|
+
unpushed: null,
|
|
753
|
+
notes: [],
|
|
754
|
+
};
|
|
755
|
+
if (index < 0) {
|
|
756
|
+
if (fs.existsSync(target))
|
|
757
|
+
return fanoutFail(
|
|
758
|
+
"blocked",
|
|
759
|
+
`${target} is not a worktree of this repository; nothing was removed`,
|
|
760
|
+
"remove it yourself once you know what it holds",
|
|
761
|
+
);
|
|
762
|
+
out.notes.push(`no worktree at ${target}; nothing to release`);
|
|
763
|
+
return { ok: true, data: out };
|
|
764
|
+
}
|
|
765
|
+
const entry = worktrees[index];
|
|
766
|
+
if (index === 0)
|
|
767
|
+
return fanoutFail("blocked", `${target} is the main checkout; it is never released`);
|
|
768
|
+
if (inside(cwd, entry.path))
|
|
769
|
+
return fanoutFail(
|
|
770
|
+
"blocked",
|
|
771
|
+
`this command runs inside ${entry.path}`,
|
|
772
|
+
`run it from the main checkout: workit fanout --cwd <main checkout> worktree release ${slice.id}`,
|
|
773
|
+
);
|
|
774
|
+
if (entry.branch !== null && entry.branch !== `refs/heads/${slice.branch}`)
|
|
775
|
+
return fanoutFail(
|
|
776
|
+
"blocked",
|
|
777
|
+
`${entry.path} is on ${entry.branch.replace(/^refs\/heads\//u, "")}, not slice ${slice.id}'s ${slice.branch}; nothing was removed`,
|
|
778
|
+
);
|
|
779
|
+
const owned = createdRow(cwd, plan, slice, entry.path);
|
|
780
|
+
if (!owned)
|
|
781
|
+
return fanoutFail(
|
|
782
|
+
"blocked",
|
|
783
|
+
`${entry.path} was not made by workit fanout worktree create for slice ${slice.id} (no ledger row); nothing was removed`,
|
|
784
|
+
`remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
785
|
+
);
|
|
786
|
+
if (input.force && !owned.exact)
|
|
787
|
+
return fanoutFail(
|
|
788
|
+
"blocked",
|
|
789
|
+
`${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`,
|
|
790
|
+
`release it without --force, or remove it yourself (git worktree remove ${entry.path}) once you know what it holds`,
|
|
791
|
+
);
|
|
792
|
+
const made = owned.row;
|
|
793
|
+
out.head = entry.head;
|
|
794
|
+
const status = gitRun(entry.path, [
|
|
795
|
+
"status",
|
|
796
|
+
"--porcelain=v1",
|
|
797
|
+
"-z",
|
|
798
|
+
"--untracked-files=all",
|
|
799
|
+
"--ignore-submodules=none",
|
|
800
|
+
]);
|
|
801
|
+
if (!status.ok)
|
|
802
|
+
return fanoutFail(
|
|
803
|
+
"failed",
|
|
804
|
+
`git status in ${entry.path} failed: ${status.stderr.trim().split("\n")[0]}`,
|
|
805
|
+
"nothing was removed; inspect the worktree by hand",
|
|
806
|
+
);
|
|
807
|
+
const entries = porcelainEntries(status.stdout);
|
|
808
|
+
const inScratch = (line: string) => line.slice(3).startsWith(`${SCRATCH_DIR}/`);
|
|
809
|
+
out.dirty = entries.filter((line) => !inScratch(line));
|
|
810
|
+
out.unpushed = unpushedCount(cwd, slice.branch);
|
|
811
|
+
const refused = out.dirty.length > 0 && !input.force;
|
|
812
|
+
// Record first: once the worktree is gone its state cannot be read back.
|
|
813
|
+
const recorded = appendObserved(cwd, {
|
|
814
|
+
type: "fanout.worktree.released",
|
|
815
|
+
actor: input.actor,
|
|
816
|
+
fanout: plan.name,
|
|
817
|
+
slice: slice.id,
|
|
818
|
+
branch: slice.branch,
|
|
819
|
+
path: entry.path,
|
|
820
|
+
head: entry.head,
|
|
821
|
+
uncommittedCount: out.dirty.length,
|
|
822
|
+
uncommitted: rowSample(out.dirty),
|
|
823
|
+
forced: input.force,
|
|
824
|
+
outcome: refused ? "refused" : "removed",
|
|
825
|
+
});
|
|
826
|
+
if (!recorded.ok)
|
|
827
|
+
return fanoutFail(
|
|
828
|
+
"unavailable",
|
|
829
|
+
`cannot record the worktree state in the ledger (${recorded.error}); nothing was removed`,
|
|
830
|
+
);
|
|
831
|
+
if (refused)
|
|
832
|
+
return fanoutFail(
|
|
833
|
+
"blocked",
|
|
834
|
+
`${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`,
|
|
835
|
+
`commit them on ${slice.branch} (workit git commit), or pass --force to drop them`,
|
|
836
|
+
{ ...out },
|
|
837
|
+
);
|
|
838
|
+
const args = ["worktree", "remove", ...(input.force ? ["--force"] : []), entry.path];
|
|
839
|
+
let removed = gitRun(cwd, args, SLOW);
|
|
840
|
+
if (!removed.ok && entries.some(inScratch)) {
|
|
841
|
+
// The scratch dir shows as untracked (its info/exclude line was removed):
|
|
842
|
+
// clear it, the only thing in the way, and try once more.
|
|
843
|
+
removeScratch(entry.path);
|
|
844
|
+
removed = gitRun(cwd, args, SLOW);
|
|
845
|
+
}
|
|
846
|
+
if (!removed.ok)
|
|
847
|
+
return fanoutFail(
|
|
848
|
+
"failed",
|
|
849
|
+
`git worktree remove failed: ${removed.stderr.trim().split("\n")[0]}`,
|
|
850
|
+
entry.locked ? `git worktree unlock ${entry.path}, then release again` : undefined,
|
|
851
|
+
{ ...out },
|
|
852
|
+
);
|
|
853
|
+
out.removed = true;
|
|
854
|
+
if (made.dirExisted === true) fs.mkdirSync(entry.path, { recursive: true });
|
|
855
|
+
if (out.unpushed)
|
|
856
|
+
out.notes.push(`${slice.branch} is kept with ${out.unpushed} commit(s) not on the remote`);
|
|
857
|
+
return { ok: true, data: out };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** `XY path` entries from porcelain v1 -z (a rename's source path is folded in). */
|
|
861
|
+
function porcelainEntries(stdout: string): string[] {
|
|
862
|
+
const fields = nulList(stdout);
|
|
863
|
+
const out: string[] = [];
|
|
864
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
865
|
+
const entry = fields[index];
|
|
866
|
+
out.push(entry);
|
|
867
|
+
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
868
|
+
}
|
|
869
|
+
return out;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function unpushedCount(cwd: string, branch: string): number | null {
|
|
873
|
+
const remote = pushRemoteName(cwd) ?? "origin";
|
|
874
|
+
const local = resolveRef(cwd, `refs/heads/${branch}`);
|
|
875
|
+
if (!local) return null;
|
|
876
|
+
const tracking = resolveRef(cwd, `refs/remotes/${remote}/${branch}`);
|
|
877
|
+
if (!tracking) return null;
|
|
878
|
+
const count = gitRun(cwd, ["rev-list", "--count", `${tracking}..${local}`]);
|
|
879
|
+
return count.ok ? Number(count.stdout.trim()) : null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Remove `<worktree>/.workit-scratch` only: a symlink is unlinked, never followed. */
|
|
883
|
+
function removeScratch(worktree: string): void {
|
|
884
|
+
const scratch = path.join(worktree, SCRATCH_DIR);
|
|
885
|
+
let stat: fs.Stats;
|
|
886
|
+
try {
|
|
887
|
+
stat = fs.lstatSync(scratch);
|
|
888
|
+
} catch {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) fs.unlinkSync(scratch);
|
|
892
|
+
else fs.rmSync(scratch, { recursive: true, force: true, maxRetries: 2 });
|
|
893
|
+
}
|