@north-light/crouter 0.3.255 → 0.3.256
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/dist/api/client.d.ts +13 -1
- package/dist/api/client.js +17 -0
- package/dist/api/dto/worktree.d.ts +17 -0
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/clients/attach/viewer.js +936 -797
- package/dist/commands/api-client.js +4 -0
- package/dist/commands/sys/worktrees.d.ts +1 -0
- package/dist/commands/sys/worktrees.js +53 -0
- package/dist/commands/sys.js +2 -1
- package/dist/core/__tests__/integration/worktree-land.test.js +71 -1
- package/dist/core/__tests__/integration/worktree-reap.test.js +101 -0
- package/dist/core/__tests__/worktree-landing.test.d.ts +1 -0
- package/dist/core/__tests__/worktree-landing.test.js +11 -0
- package/dist/core/canvas/canvas.js +24 -8
- package/dist/core/canvas/pid.d.ts +6 -0
- package/dist/core/canvas/pid.js +48 -3
- package/dist/core/canvas/types.d.ts +41 -1
- package/dist/core/exclusive-lock.d.ts +6 -0
- package/dist/core/exclusive-lock.js +12 -0
- package/dist/core/git.d.ts +1 -1
- package/dist/core/git.js +5 -1
- package/dist/core/runtime/fleet.d.ts +6 -0
- package/dist/core/worktree-landing.d.ts +44 -0
- package/dist/core/worktree-landing.js +56 -0
- package/dist/core/worktree-quarantine.d.ts +19 -0
- package/dist/core/worktree-quarantine.js +54 -0
- package/dist/core/worktree-sweep.d.ts +60 -0
- package/dist/core/worktree-sweep.js +493 -0
- package/dist/core/worktree.d.ts +37 -0
- package/dist/core/worktree.js +149 -23
- package/dist/daemon/__tests__/startup-block-marker.test.d.ts +1 -0
- package/dist/daemon/__tests__/startup-block-marker.test.js +86 -0
- package/dist/daemon/api/handlers/worktree.js +5 -0
- package/dist/daemon/crtrd-cli.js +12 -7
- package/dist/daemon/crtrd.js +6 -1
- package/dist/daemon/fleet.d.ts +1 -0
- package/dist/daemon/fleet.js +3 -0
- package/dist/daemon/manage.js +22 -1
- package/dist/daemon/reconcilers/managed-worktree-sweep.d.ts +24 -0
- package/dist/daemon/reconcilers/managed-worktree-sweep.js +190 -0
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +11 -2
- package/dist/daemon/reconcilers/storage-maintenance.js +7 -2
- package/dist/daemon/startup-block-marker.d.ts +25 -0
- package/dist/daemon/startup-block-marker.js +99 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// The ONE authority that physically disposes of a managed worktree after its
|
|
2
|
+
// owning node is gone.
|
|
3
|
+
//
|
|
4
|
+
// Close, cancel, crash, and prune do not each grow a cleanup hook: they only
|
|
5
|
+
// change durable state, and this primitive derives everything it does from that
|
|
6
|
+
// state. It is idempotent and restartable, because Git effects and canvas
|
|
7
|
+
// metadata are not one transaction — a crash between any two steps is resumed
|
|
8
|
+
// by the next pass rather than repaired by hand.
|
|
9
|
+
//
|
|
10
|
+
// EVERY Git call here is `gitAsync`. This runs inside crtrd, whose event loop
|
|
11
|
+
// also drives broker supervision and the API socket; a `spawnSync` in this path
|
|
12
|
+
// would stall the whole daemon for the duration of every Git call, and a Git
|
|
13
|
+
// call on a large repository is not short.
|
|
14
|
+
//
|
|
15
|
+
// The synchronous twin of the landing sequence lives in `worktree.ts` (`close`,
|
|
16
|
+
// which runs in an API handler). The judgment both forms share — what is safe
|
|
17
|
+
// to stash, what must be refused — lives once in `worktree-landing.ts`.
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { resolve } from 'node:path';
|
|
21
|
+
import { getNode, updateNode } from './canvas/index.js';
|
|
22
|
+
import { withExclusiveDirectoryLockAsync } from './exclusive-lock.js';
|
|
23
|
+
import { gitAsync } from './git.js';
|
|
24
|
+
import { parseStatusPorcelain, planLandIntoCheckout, refDeleteTransactionStdin } from './worktree-landing.js';
|
|
25
|
+
import { WORKTREE_LOCK_WAIT_MS, checkoutPathForBranchInList, persistClosedWorktreeState, samePath, worktreeLockPathForCommonDir } from './worktree.js';
|
|
26
|
+
/** Escalating recheck interval, in minutes, indexed by consecutive refusal. The
|
|
27
|
+
* last tier is the cap AND the quarantine threshold: at that point the record
|
|
28
|
+
* needs a human, so it is listed by `crtr sys worktrees` and costs one cheap
|
|
29
|
+
* fingerprint read an hour until something about it actually changes. */
|
|
30
|
+
const BACKOFF_MINUTES = [2, 5, 15, 30, 60];
|
|
31
|
+
const QUARANTINE_ATTEMPTS = BACKOFF_MINUTES.length;
|
|
32
|
+
/** A record this sweep still owns: open, or closed without proven cleanup. An
|
|
33
|
+
* abandoned record is deliberately out of scope — abandon already removed the
|
|
34
|
+
* checkout and retains its branch for a person on purpose. */
|
|
35
|
+
export function isReconcilable(wt) {
|
|
36
|
+
if (wt == null || wt.state === 'abandoned')
|
|
37
|
+
return false;
|
|
38
|
+
return wt.state === 'open' || wt.cleanup !== 'complete';
|
|
39
|
+
}
|
|
40
|
+
/** Whether the sweep should examine this record on a pass starting at `now`. A
|
|
41
|
+
* record with no recorded refusal is always due. */
|
|
42
|
+
export function isDueForSweep(wt, now) {
|
|
43
|
+
const next = wt.sweep?.next_attempt;
|
|
44
|
+
if (next === undefined)
|
|
45
|
+
return true;
|
|
46
|
+
const at = Date.parse(next);
|
|
47
|
+
return Number.isNaN(at) || at <= now;
|
|
48
|
+
}
|
|
49
|
+
// Git plumbing, all async
|
|
50
|
+
async function revParseRef(cwd, ref) {
|
|
51
|
+
const res = await gitAsync(['rev-parse', '--verify', '--quiet', ref], cwd);
|
|
52
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
53
|
+
}
|
|
54
|
+
function detailOf(res) {
|
|
55
|
+
return (res.stderr.trim() || res.stdout.trim()).trim();
|
|
56
|
+
}
|
|
57
|
+
async function commonGitDirAsync(cwd) {
|
|
58
|
+
const res = await gitAsync(['rev-parse', '--git-common-dir'], cwd);
|
|
59
|
+
if (res.status !== 0)
|
|
60
|
+
return null;
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync(resolve(cwd, res.stdout.trim()));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function isRebaseInProgressAsync(cwd) {
|
|
69
|
+
for (const logical of ['rebase-merge', 'rebase-apply']) {
|
|
70
|
+
const res = await gitAsync(['rev-parse', '--git-path', logical], cwd);
|
|
71
|
+
if (res.status !== 0)
|
|
72
|
+
return null;
|
|
73
|
+
if (existsSync(resolve(cwd, res.stdout.trim())))
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
/** The repository's main checkout, from a `git worktree list --porcelain` blob:
|
|
79
|
+
* Git always lists it first. Every administrative command runs from there, so
|
|
80
|
+
* removing a linked checkout never runs Git from inside the directory it is
|
|
81
|
+
* deleting. */
|
|
82
|
+
function mainWorktreePath(porcelain) {
|
|
83
|
+
for (const line of porcelain.split('\n')) {
|
|
84
|
+
if (line.startsWith('worktree '))
|
|
85
|
+
return line.slice('worktree '.length).trim();
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** Git's own spelling of the recorded checkout when it is registered to the
|
|
90
|
+
* recorded branch, else null. Ownership is a path IDENTITY question, not a
|
|
91
|
+
* string question — `samePath` resolves both spellings — and the answer is
|
|
92
|
+
* Git's canonical path so every later Git call names the checkout the way the
|
|
93
|
+
* repository's administrative metadata does. */
|
|
94
|
+
function registeredCheckoutPath(porcelain, path, branch) {
|
|
95
|
+
let current = null;
|
|
96
|
+
for (const line of porcelain.split('\n')) {
|
|
97
|
+
if (line.startsWith('worktree '))
|
|
98
|
+
current = line.slice('worktree '.length).trim();
|
|
99
|
+
else if (current !== null && line === `branch refs/heads/${branch}` && samePath(current, path))
|
|
100
|
+
return current;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Refs that could prove delivery: the recorded local base, and the base's
|
|
105
|
+
* upstream (a branch delivered by push rather than by a local land). */
|
|
106
|
+
async function containmentCandidates(cwd, wt) {
|
|
107
|
+
const candidates = [];
|
|
108
|
+
const localBaseRef = `refs/heads/${wt.base_ref}`;
|
|
109
|
+
const localBase = await revParseRef(cwd, localBaseRef);
|
|
110
|
+
if (localBase !== null)
|
|
111
|
+
candidates.push({ ref: localBaseRef, sha: localBase });
|
|
112
|
+
const upstream = await gitAsync(['rev-parse', '--symbolic-full-name', '--verify', `${wt.base_ref}@{upstream}`], cwd);
|
|
113
|
+
const upstreamRef = upstream.status === 0 && upstream.stdout.trim().startsWith('refs/')
|
|
114
|
+
? upstream.stdout.trim()
|
|
115
|
+
: `refs/remotes/origin/${wt.base_ref}`;
|
|
116
|
+
const upstreamSha = await revParseRef(cwd, upstreamRef);
|
|
117
|
+
if (upstreamSha !== null)
|
|
118
|
+
candidates.push({ ref: upstreamRef, sha: upstreamSha });
|
|
119
|
+
return candidates;
|
|
120
|
+
}
|
|
121
|
+
/** Does removing this branch lose content the candidate does not already have?
|
|
122
|
+
*
|
|
123
|
+
* The invariant is about CONTENT, not history. A rebase rewrites every SHA, so
|
|
124
|
+
* a fully-landed branch can look diverged; a squash-merge leaves no matching
|
|
125
|
+
* commit at all. An ancestry-only proof would refuse exactly the cases this
|
|
126
|
+
* sweep exists to clean up, so three layers run in increasing cost, and any one
|
|
127
|
+
* of them is sufficient:
|
|
128
|
+
*
|
|
129
|
+
* 1. ancestry — the tip is already reachable from the candidate.
|
|
130
|
+
* 2. patch identity — every commit not in the candidate by SHA has a
|
|
131
|
+
* patch-equivalent commit there (`git cherry`). The rebase case.
|
|
132
|
+
* 3. content no-op — merging the branch in would change nothing. The
|
|
133
|
+
* squash-merge case, and most remaining "looks bad but isn't" states.
|
|
134
|
+
*
|
|
135
|
+
* Only when all three fail does the branch genuinely carry unlanded content.
|
|
136
|
+
* Every layer fails CLOSED: a Git version without `merge-tree --write-tree`, an
|
|
137
|
+
* unreadable object, any non-zero exit — none of them prove anything, so the
|
|
138
|
+
* branch survives. */
|
|
139
|
+
async function proveContained(cwd, wt, branchSha) {
|
|
140
|
+
const candidates = await containmentCandidates(cwd, wt);
|
|
141
|
+
if (candidates.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
if ((await gitAsync(['merge-base', '--is-ancestor', branchSha, candidate.sha], cwd)).status === 0) {
|
|
145
|
+
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'ancestry' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const candidate of candidates) {
|
|
149
|
+
const cherry = await gitAsync(['cherry', candidate.sha, branchSha], cwd);
|
|
150
|
+
if (cherry.status !== 0)
|
|
151
|
+
continue;
|
|
152
|
+
const lines = cherry.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
153
|
+
if (lines.every((line) => line.startsWith('-'))) {
|
|
154
|
+
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'patch_identity' };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const candidate of candidates) {
|
|
158
|
+
const candidateTree = await revParseRef(cwd, `${candidate.sha}^{tree}`);
|
|
159
|
+
if (candidateTree === null)
|
|
160
|
+
continue;
|
|
161
|
+
const merged = await gitAsync(['merge-tree', '--write-tree', candidate.sha, branchSha], cwd);
|
|
162
|
+
if (merged.status !== 0)
|
|
163
|
+
continue; // conflict, or a Git too old for this flag
|
|
164
|
+
if (merged.stdout.split('\n')[0]?.trim() === candidateTree) {
|
|
165
|
+
return { containingRef: candidate.ref, containingSha: candidate.sha, layer: 'content_noop' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
// Change detection
|
|
171
|
+
/** Base tip, branch tip, and checkout state as they are right now. A pass that
|
|
172
|
+
* reads the same three values as the refusal that preceded it can stop there:
|
|
173
|
+
* nothing that decided the refusal has moved. An unreadable checkout records
|
|
174
|
+
* `null`, which never compares equal, so uncertainty always re-proves. */
|
|
175
|
+
async function observe(wt) {
|
|
176
|
+
const present = existsSync(wt.path);
|
|
177
|
+
const cwd = present ? wt.path : wt.repo_root;
|
|
178
|
+
const base = await revParseRef(cwd, `refs/heads/${wt.base_ref}`);
|
|
179
|
+
const branch = await revParseRef(cwd, `refs/heads/${wt.branch}`);
|
|
180
|
+
if (!present)
|
|
181
|
+
return { base, branch, checkout: 'absent' };
|
|
182
|
+
const head = await gitAsync(['rev-parse', 'HEAD'], wt.path);
|
|
183
|
+
const status = await gitAsync(['status', '--porcelain'], wt.path);
|
|
184
|
+
const checkout = head.status !== 0 || status.status !== 0
|
|
185
|
+
? null
|
|
186
|
+
: createHash('sha256').update(`${head.stdout.trim()}\n${status.stdout}`).digest('hex');
|
|
187
|
+
return { base, branch, checkout };
|
|
188
|
+
}
|
|
189
|
+
function sameObservation(a, b) {
|
|
190
|
+
if (a.checkout === null || b.checkout === null)
|
|
191
|
+
return false;
|
|
192
|
+
return a.base === b.base && a.branch === b.branch && a.checkout === b.checkout;
|
|
193
|
+
}
|
|
194
|
+
// Recording an unfinished pass
|
|
195
|
+
/** Persist why this pass did not finish, with the fingerprint it decided
|
|
196
|
+
* against and an escalated recheck time. This NEVER writes completion and
|
|
197
|
+
* never drops the authoritative record: the checkout, the branch, and the node
|
|
198
|
+
* metadata that proves who owns them all survive a refusal. */
|
|
199
|
+
function recordUnfinished(nodeId, wt, outcome, observed, now, guard) {
|
|
200
|
+
if (outcome.status !== 'refused' && outcome.status !== 'failed')
|
|
201
|
+
return;
|
|
202
|
+
// A node that came back to life owns its own record again: backoff written
|
|
203
|
+
// from a pass it invalidated would sit on the revived node's metadata.
|
|
204
|
+
if (!guard.stillEligible())
|
|
205
|
+
return;
|
|
206
|
+
const attempts = (wt.sweep?.attempts ?? 0) + 1;
|
|
207
|
+
const minutes = BACKOFF_MINUTES[Math.min(attempts, BACKOFF_MINUTES.length) - 1] ?? BACKOFF_MINUTES[0];
|
|
208
|
+
const current = getNode(nodeId)?.managed_worktree;
|
|
209
|
+
if (current == null || !isReconcilable(current))
|
|
210
|
+
return;
|
|
211
|
+
updateNode(nodeId, {
|
|
212
|
+
managed_worktree: {
|
|
213
|
+
...current,
|
|
214
|
+
sweep: {
|
|
215
|
+
reason: outcome.reason,
|
|
216
|
+
...(outcome.detail === undefined ? {} : { detail: outcome.detail }),
|
|
217
|
+
observed,
|
|
218
|
+
attempts,
|
|
219
|
+
last_attempt: new Date(now).toISOString(),
|
|
220
|
+
next_attempt: new Date(now + minutes * 60_000).toISOString(),
|
|
221
|
+
...(attempts >= QUARANTINE_ATTEMPTS ? { quarantined: true } : {}),
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
// Landing a blocked close the sweep is allowed to finish
|
|
227
|
+
/** The asynchronous twin of `worktree.ts`'s `landIntoBaseCheckout`. Same
|
|
228
|
+
* sequence, same guarantees: stash only what overlaps, `apply` rather than
|
|
229
|
+
* `pop` so a conflict cannot consume the stash, and never leave a checkout
|
|
230
|
+
* other nodes share in a conflicted state. */
|
|
231
|
+
async function landIntoBaseCheckoutAsync(baseCheckout, branch, baseSha, landedSha, guard) {
|
|
232
|
+
const status = await gitAsync(['status', '--porcelain'], baseCheckout);
|
|
233
|
+
if (status.status !== 0)
|
|
234
|
+
return { ok: false, detail: detailOf(status) };
|
|
235
|
+
const diff = await gitAsync(['diff', '--name-only', baseSha, landedSha], baseCheckout);
|
|
236
|
+
if (diff.status !== 0)
|
|
237
|
+
return { ok: false, detail: detailOf(diff) };
|
|
238
|
+
const incoming = diff.stdout.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
239
|
+
const plan = planLandIntoCheckout(parseStatusPorcelain(status.stdout), incoming);
|
|
240
|
+
if (plan.kind === 'refuse') {
|
|
241
|
+
return { ok: false, detail: `untracked files in ${baseCheckout} would be overwritten by the incoming commits: ${plan.collisions.join(', ')}` };
|
|
242
|
+
}
|
|
243
|
+
// The last recheck before this sequence starts. Once a stash exists the
|
|
244
|
+
// remaining steps MUST run to completion — abandoning a stashed checkout
|
|
245
|
+
// halfway is worse than finishing a land the node no longer needs.
|
|
246
|
+
if (!guard.stillEligible())
|
|
247
|
+
return { ok: false, detail: 'node is no longer terminal', ineligible: true };
|
|
248
|
+
const merge = async () => {
|
|
249
|
+
const res = await gitAsync(['merge', '--ff-only', landedSha], baseCheckout);
|
|
250
|
+
return res.status === 0 ? { ok: true } : { ok: false, detail: detailOf(res) };
|
|
251
|
+
};
|
|
252
|
+
if (plan.kind === 'direct')
|
|
253
|
+
return merge();
|
|
254
|
+
const beforeSha = await revParseRef(baseCheckout, 'refs/stash');
|
|
255
|
+
const push = await gitAsync(['stash', 'push', '--message', `crtr landing ${branch}`], baseCheckout);
|
|
256
|
+
if (push.status !== 0)
|
|
257
|
+
return { ok: false, detail: detailOf(push) };
|
|
258
|
+
const stashSha = await revParseRef(baseCheckout, 'refs/stash');
|
|
259
|
+
if (stashSha === null || stashSha === beforeSha)
|
|
260
|
+
return merge(); // nothing was saved
|
|
261
|
+
const landed = await merge();
|
|
262
|
+
if (!landed.ok) {
|
|
263
|
+
if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0)
|
|
264
|
+
await dropStashIfStillTip(baseCheckout, stashSha);
|
|
265
|
+
return landed;
|
|
266
|
+
}
|
|
267
|
+
if ((await gitAsync(['stash', 'apply', stashSha], baseCheckout)).status === 0) {
|
|
268
|
+
await dropStashIfStillTip(baseCheckout, stashSha);
|
|
269
|
+
return { ok: true };
|
|
270
|
+
}
|
|
271
|
+
await gitAsync(['reset', '--hard', landedSha], baseCheckout);
|
|
272
|
+
return { ok: true, stash: `local changes in ${baseCheckout} conflicted with the landed commits and were PRESERVED as stash ${stashSha}; re-apply them with \`git -C ${baseCheckout} stash apply ${stashSha}\`.` };
|
|
273
|
+
}
|
|
274
|
+
async function dropStashIfStillTip(cwd, stashSha) {
|
|
275
|
+
if ((await revParseRef(cwd, 'refs/stash')) === stashSha)
|
|
276
|
+
await gitAsync(['stash', 'drop'], cwd);
|
|
277
|
+
}
|
|
278
|
+
/** Finish the land this node already asked for and was blocked on. The sweep
|
|
279
|
+
* reaches here ONLY for a record carrying `land_intent`: a node that crashed
|
|
280
|
+
* mid-work and never called close does not get its half-finished commits
|
|
281
|
+
* fast-forwarded onto a shared base branch. */
|
|
282
|
+
async function finishBlockedLand(wt, porcelain, guard) {
|
|
283
|
+
const baseSha = await revParseRef(wt.path, `refs/heads/${wt.base_ref}`);
|
|
284
|
+
if (baseSha === null)
|
|
285
|
+
return { ok: false, detail: `local base branch '${wt.base_ref}' does not resolve` };
|
|
286
|
+
if (!guard.stillEligible())
|
|
287
|
+
return { ok: false, detail: 'node is no longer terminal', ineligible: true };
|
|
288
|
+
const rebase = await gitAsync(['rebase', baseSha], wt.path);
|
|
289
|
+
if (rebase.status !== 0) {
|
|
290
|
+
// A conflicted rebase must never be left in progress: the next pass would
|
|
291
|
+
// read a mid-operation checkout and refuse forever.
|
|
292
|
+
await gitAsync(['rebase', '--abort'], wt.path);
|
|
293
|
+
return { ok: false, detail: detailOf(rebase) };
|
|
294
|
+
}
|
|
295
|
+
const landedSha = await revParseRef(wt.path, 'HEAD');
|
|
296
|
+
if (landedSha === null)
|
|
297
|
+
return { ok: false, detail: 'could not read the rebased worktree tip' };
|
|
298
|
+
if ((await gitAsync(['merge-base', '--is-ancestor', baseSha, landedSha], wt.path)).status !== 0) {
|
|
299
|
+
return { ok: false, detail: `the rebased branch is not a fast-forward of '${wt.base_ref}'` };
|
|
300
|
+
}
|
|
301
|
+
const baseCheckout = checkoutPathForBranchInList(porcelain, wt.base_ref);
|
|
302
|
+
if (baseCheckout === null) {
|
|
303
|
+
if (!guard.stillEligible())
|
|
304
|
+
return { ok: false, detail: 'node is no longer terminal', ineligible: true };
|
|
305
|
+
const update = await gitAsync(['update-ref', `refs/heads/${wt.base_ref}`, landedSha, baseSha], wt.path);
|
|
306
|
+
return update.status === 0 ? { ok: true, landedSha } : { ok: false, detail: detailOf(update) };
|
|
307
|
+
}
|
|
308
|
+
const head = await gitAsync(['rev-parse', 'HEAD'], baseCheckout);
|
|
309
|
+
if (head.status !== 0 || head.stdout.trim() !== baseSha) {
|
|
310
|
+
return { ok: false, detail: `base checkout ${baseCheckout} is not at ${baseSha}` };
|
|
311
|
+
}
|
|
312
|
+
const landed = await landIntoBaseCheckoutAsync(baseCheckout, wt.branch, baseSha, landedSha, guard);
|
|
313
|
+
return landed.ok ? { ok: true, landedSha, ...(landed.stash === undefined ? {} : { stash: landed.stash }) } : landed;
|
|
314
|
+
}
|
|
315
|
+
/** The one outcome for "the node came back" — never a failure, never recorded as
|
|
316
|
+
* a refusal, because nothing about the worktree is wrong. */
|
|
317
|
+
const NODE_REVIVED = { status: 'skipped', reason: 'node_no_longer_terminal' };
|
|
318
|
+
/** Dispose of one node's managed worktree, or explain why it must survive.
|
|
319
|
+
*
|
|
320
|
+
* Ownership is proved from crouter's own node record, never from
|
|
321
|
+
* `git worktree list`: a checkout the user or another tool created has no node
|
|
322
|
+
* record and is never reachable from here. Under the repository lock the
|
|
323
|
+
* recorded path is confirmed REGISTERED to the recorded branch — a path match
|
|
324
|
+
* alone is not ownership — and removal is `git worktree remove <recorded
|
|
325
|
+
* path>` without `--force`. `git worktree prune` is never run: it is
|
|
326
|
+
* repo-wide and would touch administrative entries for worktrees crouter does
|
|
327
|
+
* not own.
|
|
328
|
+
*
|
|
329
|
+
* Effects are ordered checkout → branch → metadata so every interruption is
|
|
330
|
+
* resumable: a crash before removal retries from pending; after removal but
|
|
331
|
+
* before the branch delete, the branch is still an intact ref the next pass
|
|
332
|
+
* finds; after both, the next pass observes both absent and marks complete. */
|
|
333
|
+
export async function reconcileManagedWorktree(nodeId, guard, now = Date.now()) {
|
|
334
|
+
const wt = getNode(nodeId)?.managed_worktree;
|
|
335
|
+
if (!isReconcilable(wt) || wt == null)
|
|
336
|
+
return { status: 'skipped', reason: 'already_reconciled' };
|
|
337
|
+
const cwd = existsSync(wt.path) ? wt.path : wt.repo_root;
|
|
338
|
+
if (!existsSync(cwd)) {
|
|
339
|
+
return finish(nodeId, wt, { status: 'refused', reason: 'repo_unavailable', detail: `neither the managed checkout (${wt.path}) nor its repository root (${wt.repo_root}) is present` }, { base: null, branch: null, checkout: null }, now, guard);
|
|
340
|
+
}
|
|
341
|
+
const commonDir = await commonGitDirAsync(cwd);
|
|
342
|
+
if (commonDir === null) {
|
|
343
|
+
return finish(nodeId, wt, { status: 'failed', reason: 'git_dir_unreadable', detail: `could not resolve the git common dir from ${cwd}` }, { base: null, branch: null, checkout: null }, now, guard);
|
|
344
|
+
}
|
|
345
|
+
let observed = { base: null, branch: null, checkout: null };
|
|
346
|
+
let outcome;
|
|
347
|
+
try {
|
|
348
|
+
outcome = await withExclusiveDirectoryLockAsync(worktreeLockPathForCommonDir(commonDir), async () => {
|
|
349
|
+
if (!guard.stillEligible())
|
|
350
|
+
return NODE_REVIVED;
|
|
351
|
+
const locked = getNode(nodeId)?.managed_worktree;
|
|
352
|
+
if (!isReconcilable(locked) || locked == null)
|
|
353
|
+
return { status: 'skipped', reason: 'already_reconciled' };
|
|
354
|
+
observed = await observe(locked);
|
|
355
|
+
if (locked.sweep !== undefined && sameObservation(locked.sweep.observed, observed)) {
|
|
356
|
+
return { status: 'refused', reason: locked.sweep.reason, ...(locked.sweep.detail === undefined ? {} : { detail: locked.sweep.detail }), unchanged: true };
|
|
357
|
+
}
|
|
358
|
+
return existsSync(locked.path)
|
|
359
|
+
? await reconcilePresentCheckout(nodeId, locked, guard)
|
|
360
|
+
: await concludeAbsentCheckout(nodeId, locked, observed, guard);
|
|
361
|
+
}, {
|
|
362
|
+
timeoutMs: WORKTREE_LOCK_WAIT_MS,
|
|
363
|
+
timeoutError: () => new Error(`timed out waiting for the managed-worktree lock on ${commonDir}`),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
outcome = { status: 'failed', reason: 'lock_unavailable', detail: err instanceof Error ? err.message : String(err) };
|
|
368
|
+
}
|
|
369
|
+
return finish(nodeId, wt, outcome, observed, now, guard);
|
|
370
|
+
}
|
|
371
|
+
function finish(nodeId, wt, outcome, observed, now, guard) {
|
|
372
|
+
recordUnfinished(nodeId, wt, outcome, observed, now, guard);
|
|
373
|
+
return outcome;
|
|
374
|
+
}
|
|
375
|
+
/** Record completion against the record as it stands NOW, never the copy this
|
|
376
|
+
* pass began with: the fresh read is what keeps a concurrently-rewritten record
|
|
377
|
+
* (a revived node's, or one a human edited) from being overwritten with this
|
|
378
|
+
* pass's stale fields. A record that moved out from under the pass is left
|
|
379
|
+
* alone — its checkout and branch are already gone, so the next pass observes
|
|
380
|
+
* both absent and concludes it cleanly. */
|
|
381
|
+
function persistComplete(nodeId, wt, guard) {
|
|
382
|
+
if (!guard.stillEligible())
|
|
383
|
+
return NODE_REVIVED;
|
|
384
|
+
const current = getNode(nodeId)?.managed_worktree;
|
|
385
|
+
if (current == null || current.path !== wt.path || current.branch !== wt.branch) {
|
|
386
|
+
return { status: 'skipped', reason: 'record_changed' };
|
|
387
|
+
}
|
|
388
|
+
persistClosedWorktreeState(nodeId, current, 'complete');
|
|
389
|
+
return { status: 'complete' };
|
|
390
|
+
}
|
|
391
|
+
/** Resume a record whose checkout is already gone — the crash-after-removal
|
|
392
|
+
* case, and the case where the checkout was removed by hand. Filesystem
|
|
393
|
+
* absence proves nothing about commits, so the branch is still proved before
|
|
394
|
+
* it is deleted. */
|
|
395
|
+
async function concludeAbsentCheckout(nodeId, wt, observed, guard) {
|
|
396
|
+
const admin = wt.repo_root;
|
|
397
|
+
if (observed.branch === null) {
|
|
398
|
+
// Distinguish "the ref is gone" from "the read failed"; only the first
|
|
399
|
+
// authorizes concluding this record.
|
|
400
|
+
const exists = await gitAsync(['show-ref', '--verify', '--quiet', `refs/heads/${wt.branch}`], admin);
|
|
401
|
+
if (exists.status !== 1)
|
|
402
|
+
return { status: 'failed', reason: 'branch_inspection_failed', detail: detailOf(exists) };
|
|
403
|
+
return persistComplete(nodeId, wt, guard);
|
|
404
|
+
}
|
|
405
|
+
const list = await gitAsync(['worktree', 'list', '--porcelain'], admin);
|
|
406
|
+
if (list.status !== 0)
|
|
407
|
+
return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
|
|
408
|
+
// The recorded path being absent does NOT mean the branch has no checkout:
|
|
409
|
+
// `git worktree move` relocates one. Deleting a branch checked out elsewhere
|
|
410
|
+
// would bypass Git's own protection and could abandon uncommitted work there.
|
|
411
|
+
const elsewhere = checkoutPathForBranchInList(list.stdout, wt.branch);
|
|
412
|
+
if (elsewhere !== null) {
|
|
413
|
+
return { status: 'refused', reason: 'branch_checked_out_elsewhere', detail: `${wt.branch} is checked out at ${elsewhere}` };
|
|
414
|
+
}
|
|
415
|
+
const proof = await proveContained(admin, wt, observed.branch);
|
|
416
|
+
if (proof === null) {
|
|
417
|
+
return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries content no base or upstream ref contains; its checkout is already gone, so the branch is the only copy` };
|
|
418
|
+
}
|
|
419
|
+
return deleteBranchAndComplete(nodeId, wt, admin, observed.branch, proof, guard);
|
|
420
|
+
}
|
|
421
|
+
/** The ordinary case: a checkout that still exists and whose owner is gone. */
|
|
422
|
+
async function reconcilePresentCheckout(nodeId, wt, guard) {
|
|
423
|
+
const list = await gitAsync(['worktree', 'list', '--porcelain'], wt.path);
|
|
424
|
+
if (list.status !== 0)
|
|
425
|
+
return { status: 'failed', reason: 'worktree_list_failed', detail: detailOf(list) };
|
|
426
|
+
const registeredPath = registeredCheckoutPath(list.stdout, wt.path, wt.branch);
|
|
427
|
+
if (registeredPath === null) {
|
|
428
|
+
return { status: 'refused', reason: 'not_registered', detail: `${wt.path} is not registered to refs/heads/${wt.branch}; crtr will not act on a path it cannot prove it owns` };
|
|
429
|
+
}
|
|
430
|
+
const main = mainWorktreePath(list.stdout);
|
|
431
|
+
if (main === null || samePath(main, registeredPath) || !existsSync(main)) {
|
|
432
|
+
return { status: 'refused', reason: 'repo_unavailable', detail: `the repository's main checkout is unavailable, so ${wt.path} cannot be removed from outside itself` };
|
|
433
|
+
}
|
|
434
|
+
const midOperation = await isRebaseInProgressAsync(wt.path);
|
|
435
|
+
if (midOperation === null)
|
|
436
|
+
return { status: 'failed', reason: 'git_path_failed', detail: `could not inspect in-progress operations in ${wt.path}` };
|
|
437
|
+
if (midOperation)
|
|
438
|
+
return { status: 'refused', reason: 'rebase_in_progress', detail: `${wt.path} is mid-rebase` };
|
|
439
|
+
const head = await gitAsync(['rev-parse', '--abbrev-ref', 'HEAD'], wt.path);
|
|
440
|
+
if (head.status !== 0)
|
|
441
|
+
return { status: 'failed', reason: 'branch_check_failed', detail: detailOf(head) };
|
|
442
|
+
if (head.stdout.trim() !== wt.branch) {
|
|
443
|
+
return { status: 'refused', reason: 'wrong_branch', detail: `expected ${wt.branch}, found ${head.stdout.trim() === 'HEAD' ? 'detached HEAD' : head.stdout.trim()}` };
|
|
444
|
+
}
|
|
445
|
+
// Uncommitted work blocks removal unconditionally. No containment proof
|
|
446
|
+
// overrides this: those reason about committed content, and nothing here has
|
|
447
|
+
// any claim on changes a person or a dead node never committed.
|
|
448
|
+
const status = await gitAsync(['status', '--porcelain'], wt.path);
|
|
449
|
+
if (status.status !== 0)
|
|
450
|
+
return { status: 'failed', reason: 'status_failed', detail: detailOf(status) };
|
|
451
|
+
if (status.stdout.trim() !== '') {
|
|
452
|
+
return { status: 'refused', reason: 'dirty_worktree', detail: `${wt.path} has uncommitted changes` };
|
|
453
|
+
}
|
|
454
|
+
let branchSha = await revParseRef(wt.path, 'HEAD');
|
|
455
|
+
if (branchSha === null)
|
|
456
|
+
return { status: 'failed', reason: 'head_unreadable', detail: `could not read HEAD in ${wt.path}` };
|
|
457
|
+
let proof = await proveContained(wt.path, wt, branchSha);
|
|
458
|
+
let stash;
|
|
459
|
+
if (proof === null && wt.land_intent !== undefined) {
|
|
460
|
+
const landed = await finishBlockedLand(wt, list.stdout, guard);
|
|
461
|
+
if (!landed.ok)
|
|
462
|
+
return landed.ineligible === true ? NODE_REVIVED : { status: 'refused', reason: 'land_blocked', detail: landed.detail };
|
|
463
|
+
branchSha = landed.landedSha;
|
|
464
|
+
stash = landed.stash;
|
|
465
|
+
proof = await proveContained(wt.path, wt, branchSha);
|
|
466
|
+
}
|
|
467
|
+
if (proof === null) {
|
|
468
|
+
return { status: 'refused', reason: 'unlanded_work', detail: `${wt.branch} carries commits its base does not contain, and this node never asked to land them` };
|
|
469
|
+
}
|
|
470
|
+
// The last read-only moment. Everything past here destroys something, so the
|
|
471
|
+
// guard answers immediately before each step rather than once at the top.
|
|
472
|
+
if (!guard.stillEligible())
|
|
473
|
+
return NODE_REVIVED;
|
|
474
|
+
const removed = await gitAsync(['worktree', 'remove', registeredPath], main);
|
|
475
|
+
if (removed.status !== 0)
|
|
476
|
+
return { status: 'refused', reason: 'worktree_remove_failed', detail: detailOf(removed) };
|
|
477
|
+
const completed = await deleteBranchAndComplete(nodeId, wt, main, branchSha, proof, guard);
|
|
478
|
+
return completed.status === 'complete' && stash !== undefined ? { status: 'complete', stash } : completed;
|
|
479
|
+
}
|
|
480
|
+
/** Compare-and-delete the branch against the EXACT SHAs the proof inspected,
|
|
481
|
+
* then record completion. A lost race leaves the branch intact, so the work
|
|
482
|
+
* stays reachable — and leaves the record incomplete, so its node metadata is
|
|
483
|
+
* still protected from prune and the next pass retries. */
|
|
484
|
+
async function deleteBranchAndComplete(nodeId, wt, admin, branchSha, proof, guard) {
|
|
485
|
+
if (!guard.stillEligible())
|
|
486
|
+
return NODE_REVIVED;
|
|
487
|
+
const stdin = refDeleteTransactionStdin(wt.branch, branchSha, proof.containingRef, proof.containingSha);
|
|
488
|
+
const transaction = await gitAsync(['update-ref', '--stdin'], admin, stdin);
|
|
489
|
+
if (transaction.status !== 0) {
|
|
490
|
+
return { status: 'refused', reason: 'branch_delete_failed', detail: detailOf(transaction) };
|
|
491
|
+
}
|
|
492
|
+
return persistComplete(nodeId, wt, guard);
|
|
493
|
+
}
|
package/dist/core/worktree.d.ts
CHANGED
|
@@ -14,6 +14,27 @@ export declare function managedWorktreesRoot(): string;
|
|
|
14
14
|
export declare function managedWorktreePath(nodeId: string): string;
|
|
15
15
|
export declare function hasOpenManagedWorktree(node: NodeMeta | null | undefined): boolean;
|
|
16
16
|
export declare function openManagedWorktreeForNode(nodeId: string): ManagedWorktree | null;
|
|
17
|
+
/** Shared parse for a `git worktree list --porcelain` blob already in hand —
|
|
18
|
+
* callers that must not throw on a read failure (an absent-path proof that
|
|
19
|
+
* needs to fail closed rather than error) call `gitSync` themselves and pass
|
|
20
|
+
* the output here instead of going through `checkoutPathForBranch`. */
|
|
21
|
+
export declare function checkoutPathForBranchInList(porcelain: string, branch: string): string | null;
|
|
22
|
+
/** Are these two spellings the same directory?
|
|
23
|
+
*
|
|
24
|
+
* Git canonicalizes every path it prints in `worktree list --porcelain`, while
|
|
25
|
+
* a recorded managed-worktree path preserves the spelling of `CRTR_HOME`. When
|
|
26
|
+
* the canvas home is reached through a symlink (macOS's `/var` → `/private/var`
|
|
27
|
+
* is the everyday case) the two forms name one directory but differ as
|
|
28
|
+
* strings, so a raw `===` reads a checkout crtr created as unowned. Resolution
|
|
29
|
+
* failures fall back to `resolve`, which is the pre-canonical comparison — no
|
|
30
|
+
* worse than the string compare it replaces. */
|
|
31
|
+
export declare function samePath(a: string, b: string): boolean;
|
|
32
|
+
export declare const WORKTREE_LOCK_WAIT_MS = 30000;
|
|
33
|
+
/** The lock directory naming one repository's common Git dir. Every process
|
|
34
|
+
* that mutates a managed worktree — the synchronous commands here and the
|
|
35
|
+
* daemon's asynchronous sweep — derives its lock path through this function,
|
|
36
|
+
* so the two locking styles contend for the SAME name. */
|
|
37
|
+
export declare function worktreeLockPathForCommonDir(commonDir: string): string;
|
|
17
38
|
export declare function rollbackManagedWorktree(wt: ManagedWorktree): void;
|
|
18
39
|
export declare function createManagedWorktree(cwd: string, nodeId: string, baseRef?: string): ManagedWorktree;
|
|
19
40
|
export declare function isRebaseInProgress(cwd: string): boolean;
|
|
@@ -29,7 +50,23 @@ export interface CloseManagedWorktreeResult {
|
|
|
29
50
|
/** Always false while the pending-cleanup checkout has this branch checked out. */
|
|
30
51
|
branch_deleted: boolean;
|
|
31
52
|
branch_delete_error?: string;
|
|
53
|
+
/** Present ONLY when landing had to stash local changes in the base checkout
|
|
54
|
+
* and re-applying them conflicted: names the preserved stash and how to
|
|
55
|
+
* re-apply it. Its absence means nothing local was left over. */
|
|
56
|
+
base_checkout_stash?: string;
|
|
32
57
|
}
|
|
58
|
+
/** The ONE authoritative closed-worktree state transition, shared by every
|
|
59
|
+
* closing path (explicit `closeManagedWorktreeLocked` land, the clean
|
|
60
|
+
* `autoDropCleanManagedWorktreeLocked` push-final drop, and the daemon sweep's
|
|
61
|
+
* `reconcileManagedWorktree`): persist
|
|
62
|
+
* `managed_worktree.state: 'closed'`, name its cleanup state, and repoint the
|
|
63
|
+
* node's launch `cwd` to the durable base checkout. The caller broker stays
|
|
64
|
+
* alive and is Pi's sole transcript writer, so its session header cannot be
|
|
65
|
+
* safely rewritten here; clear the session coordinates and let the later
|
|
66
|
+
* revive launch fresh instead. Both closing paths MUST go through this helper
|
|
67
|
+
* instead of hand-rolling the `updateNode` call, so neither can drift out of
|
|
68
|
+
* sync. */
|
|
69
|
+
export declare function persistClosedWorktreeState(nodeId: string, wt: ManagedWorktree, cleanup?: 'pending' | 'complete'): void;
|
|
33
70
|
export declare function closeManagedWorktree(nodeId: string): CloseManagedWorktreeResult;
|
|
34
71
|
export interface AbandonManagedWorktreeResult {
|
|
35
72
|
node_id: string;
|