@telora/daemon 0.20.95 → 0.20.103
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/build-info.json +2 -2
- package/dist/audit-prompt.d.ts.map +1 -1
- package/dist/audit-prompt.js +6 -1
- package/dist/audit-prompt.js.map +1 -1
- package/dist/cli/connect.d.ts.map +1 -1
- package/dist/cli/connect.js +12 -12
- package/dist/cli/connect.js.map +1 -1
- package/dist/crash-recovery.d.ts +12 -2
- package/dist/crash-recovery.d.ts.map +1 -1
- package/dist/crash-recovery.js +43 -4
- package/dist/crash-recovery.js.map +1 -1
- package/dist/delivery-advance-block.d.ts.map +1 -1
- package/dist/delivery-advance-block.js +21 -4
- package/dist/delivery-advance-block.js.map +1 -1
- package/dist/gitignore-convergence.d.ts +47 -0
- package/dist/gitignore-convergence.d.ts.map +1 -0
- package/dist/gitignore-convergence.js +75 -0
- package/dist/gitignore-convergence.js.map +1 -0
- package/dist/unified-init.d.ts.map +1 -1
- package/dist/unified-init.js +11 -19
- package/dist/unified-init.js.map +1 -1
- package/dist/unified-shell-provisioning.d.ts +22 -0
- package/dist/unified-shell-provisioning.d.ts.map +1 -1
- package/dist/unified-shell-provisioning.js +55 -0
- package/dist/unified-shell-provisioning.js.map +1 -1
- package/dist/unified-shell.d.ts.map +1 -1
- package/dist/unified-shell.js +9 -1
- package/dist/unified-shell.js.map +1 -1
- package/dist/worktree/identity.d.ts +63 -0
- package/dist/worktree/identity.d.ts.map +1 -0
- package/dist/worktree/identity.js +79 -0
- package/dist/worktree/identity.js.map +1 -0
- package/dist/worktree/index.d.ts +3 -0
- package/dist/worktree/index.d.ts.map +1 -1
- package/dist/worktree/index.js +3 -0
- package/dist/worktree/index.js.map +1 -1
- package/dist/worktree/reap.d.ts +60 -0
- package/dist/worktree/reap.d.ts.map +1 -0
- package/dist/worktree/reap.js +175 -0
- package/dist/worktree/reap.js.map +1 -0
- package/dist/worktree/safety.d.ts +15 -4
- package/dist/worktree/safety.d.ts.map +1 -1
- package/dist/worktree/safety.js +72 -39
- package/dist/worktree/safety.js.map +1 -1
- package/dist/worktree/scratch-guard.d.ts +126 -0
- package/dist/worktree/scratch-guard.d.ts.map +1 -0
- package/dist/worktree/scratch-guard.js +196 -0
- package/dist/worktree/scratch-guard.js.map +1 -0
- package/dist/worktree.d.ts +7 -39
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +8 -103
- package/dist/worktree.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic worktree reaping.
|
|
3
|
+
*
|
|
4
|
+
* "The daemon owns the boundary of every path it creates." A worktree checkout
|
|
5
|
+
* on disk and its `.git/worktrees/<id>` registration must live and die together
|
|
6
|
+
* -- a directory left behind WITHOUT its registration is invisible to
|
|
7
|
+
* `git worktree list`/`prune` and accumulates as unbounded residue (the 4.9 GB
|
|
8
|
+
* BELL leak). This module wraps daemon-core's base removal with an explicit
|
|
9
|
+
* post-condition check so a reap can never silently leave that residue.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Cap on the number of files we blob-verify for an UNREGISTERED, .git-less
|
|
13
|
+
* residue checkout. Above this we cannot cheaply prove reachability, so we
|
|
14
|
+
* preserve (safe) rather than stall crash recovery hashing a huge tree.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_RESIDUE_FILES_TO_VERIFY = 2000;
|
|
17
|
+
/**
|
|
18
|
+
* True iff `worktreePath` is still listed in `repoPath`'s
|
|
19
|
+
* `git worktree list --porcelain` (i.e. git still has a registration for it).
|
|
20
|
+
*/
|
|
21
|
+
export declare function worktreeIsRegistered(repoPath: string, worktreePath: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Remove a worktree ATOMICALLY: after this returns `true`, the directory is
|
|
24
|
+
* gone AND no `git worktree` registration for it remains.
|
|
25
|
+
*
|
|
26
|
+
* Delegates the removal to daemon-core's `removeWorktreeBase` (which runs
|
|
27
|
+
* `git worktree remove --force`, or an `rmSync` fallback + `git worktree
|
|
28
|
+
* prune`), then VERIFIES the post-condition. If a stale registration lingers
|
|
29
|
+
* (the fallback path can leave one until prune), it prunes and re-checks. Any
|
|
30
|
+
* remaining inconsistency -- a surviving directory or registration -- is logged
|
|
31
|
+
* loudly and returns `false`, so an unregistered leftover can never pass
|
|
32
|
+
* silently as a clean reap.
|
|
33
|
+
*
|
|
34
|
+
* Signature matches daemon-core `removeWorktreeBase` so it is a drop-in.
|
|
35
|
+
*/
|
|
36
|
+
export declare function reapWorktreeAtomically(repoPath: string, worktreePath: string): boolean;
|
|
37
|
+
/** Verdict on whether an unregistered residue checkout is safe to reap. */
|
|
38
|
+
export interface ResidueClassification {
|
|
39
|
+
/** True iff the residue's content is already reachable from integration. */
|
|
40
|
+
reapSafe: boolean;
|
|
41
|
+
/** Human-readable justification (for the crash-recovery log). */
|
|
42
|
+
reason: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Classify whether an UNREGISTERED residue checkout (a directory under the
|
|
46
|
+
* worktree dir with no `git worktree list` entry) is safe to reap. Because
|
|
47
|
+
* there is no branch ref to diff, safety is judged by content reachability from
|
|
48
|
+
* integration:
|
|
49
|
+
*
|
|
50
|
+
* - Functional checkout (its HEAD still resolves): reap-safe iff it has no
|
|
51
|
+
* uncommitted changes AND its HEAD commit is an ancestor of integration.
|
|
52
|
+
* - Broken/.git-less checkout: reap-safe iff every file matches the same
|
|
53
|
+
* path's blob in integration ({@link classifyResidueByContent}).
|
|
54
|
+
*
|
|
55
|
+
* Every uncertain case resolves to `reapSafe: false` (preserve) so unmerged
|
|
56
|
+
* work is never destroyed -- the atomic-reap half of this focus ensures no NEW
|
|
57
|
+
* residue accumulates while preserved residue awaits manual review.
|
|
58
|
+
*/
|
|
59
|
+
export declare function classifyUnregisteredResidue(residuePath: string, repoPath: string, integrationBranch: string): ResidueClassification;
|
|
60
|
+
//# sourceMappingURL=reap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap.d.ts","sourceRoot":"","sources":["../../src/worktree/reap.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAUH;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,OAAO,CAAC;AAEhD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAUpF;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,OAAO,CAuBtF;AAED,2EAA2E;AAC3E,MAAM,WAAW,qBAAqB;IACpC,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;IAClB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CAChB;AAwED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,2BAA2B,CACzC,WAAW,EAAE,MAAM,EACnB,QAAQ,EAAE,MAAM,EAChB,iBAAiB,EAAE,MAAM,GACxB,qBAAqB,CAsBvB"}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic worktree reaping.
|
|
3
|
+
*
|
|
4
|
+
* "The daemon owns the boundary of every path it creates." A worktree checkout
|
|
5
|
+
* on disk and its `.git/worktrees/<id>` registration must live and die together
|
|
6
|
+
* -- a directory left behind WITHOUT its registration is invisible to
|
|
7
|
+
* `git worktree list`/`prune` and accumulates as unbounded residue (the 4.9 GB
|
|
8
|
+
* BELL leak). This module wraps daemon-core's base removal with an explicit
|
|
9
|
+
* post-condition check so a reap can never silently leave that residue.
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { resolve, join } from 'node:path';
|
|
13
|
+
import { runGitSync } from '../git/index.js';
|
|
14
|
+
import { removeWorktreeBase } from '@telora/daemon-core';
|
|
15
|
+
import { createLogger } from '../log.js';
|
|
16
|
+
const log = createLogger({ module: 'worktree' });
|
|
17
|
+
/**
|
|
18
|
+
* Cap on the number of files we blob-verify for an UNREGISTERED, .git-less
|
|
19
|
+
* residue checkout. Above this we cannot cheaply prove reachability, so we
|
|
20
|
+
* preserve (safe) rather than stall crash recovery hashing a huge tree.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_RESIDUE_FILES_TO_VERIFY = 2000;
|
|
23
|
+
/**
|
|
24
|
+
* True iff `worktreePath` is still listed in `repoPath`'s
|
|
25
|
+
* `git worktree list --porcelain` (i.e. git still has a registration for it).
|
|
26
|
+
*/
|
|
27
|
+
export function worktreeIsRegistered(repoPath, worktreePath) {
|
|
28
|
+
const result = runGitSync(['worktree', 'list', '--porcelain'], repoPath);
|
|
29
|
+
if (!result.success)
|
|
30
|
+
return false;
|
|
31
|
+
const target = resolve(worktreePath);
|
|
32
|
+
for (const line of result.output.split('\n')) {
|
|
33
|
+
if (line.startsWith('worktree ')) {
|
|
34
|
+
if (resolve(line.slice('worktree '.length).trim()) === target)
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Remove a worktree ATOMICALLY: after this returns `true`, the directory is
|
|
42
|
+
* gone AND no `git worktree` registration for it remains.
|
|
43
|
+
*
|
|
44
|
+
* Delegates the removal to daemon-core's `removeWorktreeBase` (which runs
|
|
45
|
+
* `git worktree remove --force`, or an `rmSync` fallback + `git worktree
|
|
46
|
+
* prune`), then VERIFIES the post-condition. If a stale registration lingers
|
|
47
|
+
* (the fallback path can leave one until prune), it prunes and re-checks. Any
|
|
48
|
+
* remaining inconsistency -- a surviving directory or registration -- is logged
|
|
49
|
+
* loudly and returns `false`, so an unregistered leftover can never pass
|
|
50
|
+
* silently as a clean reap.
|
|
51
|
+
*
|
|
52
|
+
* Signature matches daemon-core `removeWorktreeBase` so it is a drop-in.
|
|
53
|
+
*/
|
|
54
|
+
export function reapWorktreeAtomically(repoPath, worktreePath) {
|
|
55
|
+
const removed = removeWorktreeBase(repoPath, worktreePath);
|
|
56
|
+
if (!removed)
|
|
57
|
+
return false;
|
|
58
|
+
let dirGone = !existsSync(worktreePath);
|
|
59
|
+
let registered = worktreeIsRegistered(repoPath, worktreePath);
|
|
60
|
+
if (registered) {
|
|
61
|
+
// The rmSync-fallback path drops the directory but leaves the registration
|
|
62
|
+
// until a prune; converge it here so the two never diverge.
|
|
63
|
+
runGitSync(['worktree', 'prune'], repoPath);
|
|
64
|
+
registered = worktreeIsRegistered(repoPath, worktreePath);
|
|
65
|
+
dirGone = !existsSync(worktreePath);
|
|
66
|
+
}
|
|
67
|
+
if (!dirGone || registered) {
|
|
68
|
+
log.error(`Worktree reap left an inconsistent state at ${worktreePath}: ` +
|
|
69
|
+
`dirGone=${dirGone}, stillRegistered=${registered}. Manual cleanup may be required.`);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Recursively list repo-relative file paths under `dir`, EXCLUDING any `.git`
|
|
76
|
+
* entry (the residue's dangling gitdir pointer). Returns null on a read error,
|
|
77
|
+
* or once the count exceeds {@link MAX_RESIDUE_FILES_TO_VERIFY} (caller then
|
|
78
|
+
* preserves rather than verify an unbounded tree).
|
|
79
|
+
*/
|
|
80
|
+
function listResidueFiles(dir) {
|
|
81
|
+
const out = [];
|
|
82
|
+
const walk = (abs, rel) => {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
for (const e of entries) {
|
|
91
|
+
if (e.name === '.git')
|
|
92
|
+
continue;
|
|
93
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
94
|
+
if (e.isDirectory()) {
|
|
95
|
+
if (!walk(join(abs, e.name), childRel))
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
out.push(childRel);
|
|
100
|
+
if (out.length > MAX_RESIDUE_FILES_TO_VERIFY)
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
return walk(dir, '') ? out : null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Blob-level reachability check for a broken (.git-less) residue checkout: it is
|
|
110
|
+
* reap-safe ONLY when EVERY file it holds matches the same path's blob in the
|
|
111
|
+
* integration branch (content already reachable). Any new or modified file --
|
|
112
|
+
* i.e. potential unmerged work -- makes it unsafe (preserve). An unreadable or
|
|
113
|
+
* over-large tree is likewise preserved. Errs strictly toward preserve so a
|
|
114
|
+
* reap never loses work.
|
|
115
|
+
*/
|
|
116
|
+
function classifyResidueByContent(residuePath, repoPath, integrationBranch) {
|
|
117
|
+
const files = listResidueFiles(residuePath);
|
|
118
|
+
if (files === null) {
|
|
119
|
+
return { reapSafe: false, reason: 'residue too large or unreadable to verify; preserved for manual review' };
|
|
120
|
+
}
|
|
121
|
+
if (files.length === 0) {
|
|
122
|
+
return { reapSafe: true, reason: 'residue holds no files' };
|
|
123
|
+
}
|
|
124
|
+
for (const rel of files) {
|
|
125
|
+
const abs = join(residuePath, rel);
|
|
126
|
+
const blob = runGitSync(['hash-object', abs], repoPath);
|
|
127
|
+
if (!blob.success || blob.output.trim().length === 0) {
|
|
128
|
+
return { reapSafe: false, reason: `could not hash residue file ${rel}; preserved` };
|
|
129
|
+
}
|
|
130
|
+
const intBlob = runGitSync(['rev-parse', '--verify', '--quiet', `${integrationBranch}:${rel}`], repoPath);
|
|
131
|
+
if (!intBlob.success || intBlob.output.trim().length === 0) {
|
|
132
|
+
return { reapSafe: false, reason: `${rel} is absent from ${integrationBranch} (potential unmerged work)` };
|
|
133
|
+
}
|
|
134
|
+
if (blob.output.trim() !== intBlob.output.trim()) {
|
|
135
|
+
return { reapSafe: false, reason: `${rel} differs from ${integrationBranch} (potential unmerged work)` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { reapSafe: true, reason: `all ${files.length} residue file(s) match ${integrationBranch}` };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Classify whether an UNREGISTERED residue checkout (a directory under the
|
|
142
|
+
* worktree dir with no `git worktree list` entry) is safe to reap. Because
|
|
143
|
+
* there is no branch ref to diff, safety is judged by content reachability from
|
|
144
|
+
* integration:
|
|
145
|
+
*
|
|
146
|
+
* - Functional checkout (its HEAD still resolves): reap-safe iff it has no
|
|
147
|
+
* uncommitted changes AND its HEAD commit is an ancestor of integration.
|
|
148
|
+
* - Broken/.git-less checkout: reap-safe iff every file matches the same
|
|
149
|
+
* path's blob in integration ({@link classifyResidueByContent}).
|
|
150
|
+
*
|
|
151
|
+
* Every uncertain case resolves to `reapSafe: false` (preserve) so unmerged
|
|
152
|
+
* work is never destroyed -- the atomic-reap half of this focus ensures no NEW
|
|
153
|
+
* residue accumulates while preserved residue awaits manual review.
|
|
154
|
+
*/
|
|
155
|
+
export function classifyUnregisteredResidue(residuePath, repoPath, integrationBranch) {
|
|
156
|
+
const head = runGitSync(['rev-parse', '--verify', 'HEAD'], residuePath);
|
|
157
|
+
if (head.success && head.output.trim().length > 0) {
|
|
158
|
+
const sha = head.output.trim();
|
|
159
|
+
const status = runGitSync(['status', '--porcelain'], residuePath);
|
|
160
|
+
if (status.success && status.output.trim().length > 0) {
|
|
161
|
+
return { reapSafe: false, reason: 'functional checkout has uncommitted changes (potential unmerged work)' };
|
|
162
|
+
}
|
|
163
|
+
const ancestor = runGitSync(['merge-base', '--is-ancestor', sha, integrationBranch], repoPath);
|
|
164
|
+
if (ancestor.success) {
|
|
165
|
+
return { reapSafe: true, reason: `HEAD ${sha.slice(0, 8)} is reachable from ${integrationBranch}` };
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
reapSafe: false,
|
|
169
|
+
reason: `HEAD ${sha.slice(0, 8)} holds commit(s) not reachable from ${integrationBranch}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
// No resolvable HEAD -- the classic .git-less residue. Fall back to blobs.
|
|
173
|
+
return classifyResidueByContent(residuePath, repoPath, integrationBranch);
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=reap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap.js","sourceRoot":"","sources":["../../src/worktree/reap.ts"],"names":[],"mappings":"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"}
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
* Uncommitted change detection, unmerged commit checks, safety commits,
|
|
5
5
|
* guarded worktree removal, and WIP commit after agent exit.
|
|
6
6
|
*/
|
|
7
|
+
import { type SweepRefusalReporter } from './scratch-guard.js';
|
|
8
|
+
import { type WorktreeIdentityViolationReporter } from './identity.js';
|
|
7
9
|
/**
|
|
8
10
|
* Check if a worktree has uncommitted changes (dirty working tree).
|
|
11
|
+
*
|
|
12
|
+
* Identity-guarded: if the directory is not the worktree it names (a `.git`-less
|
|
13
|
+
* checkout whose `git status` would report the ENCLOSING repo's tree), the
|
|
14
|
+
* enclosing repo's state is never attributed to this worktree -- the mismatch is
|
|
15
|
+
* logged loudly and the function returns false. The write paths (guardedCommit,
|
|
16
|
+
* removeWorktree) are the hard gate; this keeps the read from lying.
|
|
9
17
|
*/
|
|
10
18
|
export declare function worktreeHasUncommittedChanges(worktreePath: string): boolean;
|
|
11
19
|
/**
|
|
@@ -16,7 +24,7 @@ export declare function branchHasUnmergedCommits(branchName: string, targetBranc
|
|
|
16
24
|
* Commit all uncommitted changes in a worktree as a safety net.
|
|
17
25
|
* Returns the commit hash if successful, null otherwise.
|
|
18
26
|
*/
|
|
19
|
-
export declare function safetyCommitWorktree(worktreePath: string, deliveryName: string): string | null;
|
|
27
|
+
export declare function safetyCommitWorktree(worktreePath: string, deliveryName: string, reporter?: SweepRefusalReporter): string | null;
|
|
20
28
|
/**
|
|
21
29
|
* Generated lockfile basenames the daemon may safely auto-commit. These are
|
|
22
30
|
* machine-regenerated (never hand-authored), so a worktree whose ONLY dirt is
|
|
@@ -52,7 +60,7 @@ export declare function dirtyChangesAreGeneratedLockfilesOnly(worktreePath: stri
|
|
|
52
60
|
* agent work is never silently committed here. Returns the commit hash, or
|
|
53
61
|
* null if staging/committing failed (e.g. nothing staged).
|
|
54
62
|
*/
|
|
55
|
-
export declare function commitGeneratedLockfiles(worktreePath: string): string | null;
|
|
63
|
+
export declare function commitGeneratedLockfiles(worktreePath: string, reporter?: SweepRefusalReporter): string | null;
|
|
56
64
|
/**
|
|
57
65
|
* Remove a worktree when an agent session ends.
|
|
58
66
|
* The branch is preserved for PR creation.
|
|
@@ -68,6 +76,7 @@ export declare function removeWorktree(repoPath: string, worktreePath: string, o
|
|
|
68
76
|
integrationBranch?: string;
|
|
69
77
|
deliveryName?: string;
|
|
70
78
|
mergeSucceeded?: boolean;
|
|
79
|
+
identityReporter?: WorktreeIdentityViolationReporter;
|
|
71
80
|
}): boolean;
|
|
72
81
|
/**
|
|
73
82
|
* Commit any uncommitted changes in a worktree as a WIP commit.
|
|
@@ -76,9 +85,11 @@ export declare function removeWorktree(repoPath: string, worktreePath: string, o
|
|
|
76
85
|
* removal. Captures in-flight work that the agent didn't commit before
|
|
77
86
|
* being terminated.
|
|
78
87
|
*
|
|
79
|
-
*
|
|
88
|
+
* Stages via the scoped-staging guard (scratch-excluded, sweep-refused) and
|
|
89
|
+
* commits in the daemon package -- it no longer delegates to daemon-core's
|
|
90
|
+
* blind-`git add -A` commitWip, so the WIP path is guarded like every other.
|
|
80
91
|
*
|
|
81
92
|
* @returns The commit hash if a WIP commit was created, null if no changes or failure.
|
|
82
93
|
*/
|
|
83
|
-
export declare function commitWipChanges(worktreePath: string, contextName: string): string | null;
|
|
94
|
+
export declare function commitWipChanges(worktreePath: string, contextName: string, reporter?: SweepRefusalReporter): string | null;
|
|
84
95
|
//# sourceMappingURL=safety.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"safety.d.ts","sourceRoot":"","sources":["../../src/worktree/safety.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"safety.d.ts","sourceRoot":"","sources":["../../src/worktree/safety.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,oBAAoB,CAAC;AAE5B,OAAO,EAGL,KAAK,iCAAiC,EACvC,MAAM,eAAe,CAAC;AAMvB;;;;;;;;GAQG;AACH,wBAAgB,6BAA6B,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAa3E;AAED;;GAEG;AACH,wBAAgB,wBAAwB,CACtC,UAAU,EAAE,MAAM,EAClB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,GACf,OAAO,CAMT;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,MAAM,EACpB,QAAQ,CAAC,EAAE,oBAAoB,GAC9B,MAAM,GAAG,IAAI,CAUf;AAED;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,EAAE,SAAS,MAAM,EAMzD,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,qCAAqC,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAWnF;AAED;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,MAAM,EACpB,QAAQ,CAAC,EAAE,oBAAoB,GAC9B,MAAM,GAAG,IAAI,CAUf;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,MAAM,EAChB,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE;IACP,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gBAAgB,CAAC,EAAE,iCAAiC,CAAC;CACjD,GACL,OAAO,CAsET;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,YAAY,EAAE,MAAM,EACpB,WAAW,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,oBAAoB,GAC9B,MAAM,GAAG,IAAI,CAQf"}
|
package/dist/worktree/safety.js
CHANGED
|
@@ -6,15 +6,30 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync } from 'node:fs';
|
|
8
8
|
import { runGitSync } from '../git/index.js';
|
|
9
|
-
import {
|
|
9
|
+
import { guardedCommit, worktreeHasNonScratchChanges, } from './scratch-guard.js';
|
|
10
|
+
import { reapWorktreeAtomically } from './reap.js';
|
|
11
|
+
import { assertWorktreeIdentity, defaultWorktreeIdentityViolationReporter, } from './identity.js';
|
|
10
12
|
import { createLogger } from '../log.js';
|
|
11
13
|
const log = createLogger({ module: 'worktree' });
|
|
12
14
|
/**
|
|
13
15
|
* Check if a worktree has uncommitted changes (dirty working tree).
|
|
16
|
+
*
|
|
17
|
+
* Identity-guarded: if the directory is not the worktree it names (a `.git`-less
|
|
18
|
+
* checkout whose `git status` would report the ENCLOSING repo's tree), the
|
|
19
|
+
* enclosing repo's state is never attributed to this worktree -- the mismatch is
|
|
20
|
+
* logged loudly and the function returns false. The write paths (guardedCommit,
|
|
21
|
+
* removeWorktree) are the hard gate; this keeps the read from lying.
|
|
14
22
|
*/
|
|
15
23
|
export function worktreeHasUncommittedChanges(worktreePath) {
|
|
16
24
|
if (!existsSync(worktreePath))
|
|
17
25
|
return false;
|
|
26
|
+
const identity = assertWorktreeIdentity(worktreePath);
|
|
27
|
+
if (!identity.ok) {
|
|
28
|
+
log.error(`worktreeHasUncommittedChanges: ${worktreePath} is not its own worktree ` +
|
|
29
|
+
`(git resolves it to ${identity.resolvedRepo ?? 'no repository'}); refusing ` +
|
|
30
|
+
`to attribute the enclosing repo's status to it.`);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
18
33
|
const result = runGitSync(['status', '--porcelain'], worktreePath);
|
|
19
34
|
return result.success && result.output.trim().length > 0;
|
|
20
35
|
}
|
|
@@ -29,22 +44,16 @@ export function branchHasUnmergedCommits(branchName, targetBranch, repoPath) {
|
|
|
29
44
|
* Commit all uncommitted changes in a worktree as a safety net.
|
|
30
45
|
* Returns the commit hash if successful, null otherwise.
|
|
31
46
|
*/
|
|
32
|
-
export function safetyCommitWorktree(worktreePath, deliveryName) {
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
log.warn(`Safety commit: commit failed (may be empty): ${commitResult.error}`);
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
45
|
-
// Return the new commit hash
|
|
46
|
-
const hashResult = runGitSync(['rev-parse', 'HEAD'], worktreePath);
|
|
47
|
-
return hashResult.success ? hashResult.output : null;
|
|
47
|
+
export function safetyCommitWorktree(worktreePath, deliveryName, reporter) {
|
|
48
|
+
// Routes through the scoped-staging guard: daemon scratch (`.telora/`) is
|
|
49
|
+
// never staged, and a mass sweep is refused (working tree left untouched,
|
|
50
|
+
// reporter invoked) rather than committed.
|
|
51
|
+
return guardedCommit({
|
|
52
|
+
worktreePath,
|
|
53
|
+
message: `wip: uncommitted agent work for "${deliveryName}" (safety commit)`,
|
|
54
|
+
context: deliveryName,
|
|
55
|
+
reporter,
|
|
56
|
+
});
|
|
48
57
|
}
|
|
49
58
|
/**
|
|
50
59
|
* Generated lockfile basenames the daemon may safely auto-commit. These are
|
|
@@ -101,19 +110,16 @@ export function dirtyChangesAreGeneratedLockfilesOnly(worktreePath) {
|
|
|
101
110
|
* agent work is never silently committed here. Returns the commit hash, or
|
|
102
111
|
* null if staging/committing failed (e.g. nothing staged).
|
|
103
112
|
*/
|
|
104
|
-
export function commitGeneratedLockfiles(worktreePath) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
const hashResult = runGitSync(['rev-parse', 'HEAD'], worktreePath);
|
|
116
|
-
return hashResult.success ? hashResult.output : null;
|
|
113
|
+
export function commitGeneratedLockfiles(worktreePath, reporter) {
|
|
114
|
+
// Callers gate this behind dirtyChangesAreGeneratedLockfilesOnly, so the
|
|
115
|
+
// staged set is a handful of lockfiles; the guard is pure defense-in-depth
|
|
116
|
+
// (scratch exclusion + sweep refusal) shared with every other commit path.
|
|
117
|
+
return guardedCommit({
|
|
118
|
+
worktreePath,
|
|
119
|
+
message: 'chore: auto-commit generated lockfile churn (daemon remediation)',
|
|
120
|
+
context: 'generated-lockfile churn',
|
|
121
|
+
reporter,
|
|
122
|
+
});
|
|
117
123
|
}
|
|
118
124
|
/**
|
|
119
125
|
* Remove a worktree when an agent session ends.
|
|
@@ -133,8 +139,27 @@ export function removeWorktree(repoPath, worktreePath, options = {}) {
|
|
|
133
139
|
runGitSync(['worktree', 'prune'], repoPath);
|
|
134
140
|
return true;
|
|
135
141
|
}
|
|
136
|
-
// Guard
|
|
137
|
-
|
|
142
|
+
// Guard 0: Identity. If the directory is not the worktree it names (a
|
|
143
|
+
// `.git`-less checkout whose git commands redirect at the enclosing repo),
|
|
144
|
+
// refuse before the dirty-check/safety-commit guards run -- otherwise the
|
|
145
|
+
// dirty-check would read the parent's tree and the safety commit would stage
|
|
146
|
+
// it. Unregistered residue is reaped via the crash-recovery residue path,
|
|
147
|
+
// not here.
|
|
148
|
+
const identity = assertWorktreeIdentity(worktreePath);
|
|
149
|
+
if (!identity.ok) {
|
|
150
|
+
const identityReporter = options.identityReporter ?? defaultWorktreeIdentityViolationReporter;
|
|
151
|
+
identityReporter({
|
|
152
|
+
worktreePath,
|
|
153
|
+
resolvedRepo: identity.resolvedRepo,
|
|
154
|
+
operation: 'remove',
|
|
155
|
+
});
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
// Guard 1: Safety-commit real (non-scratch) uncommitted work. Daemon
|
|
159
|
+
// scratch (`.telora/`) is disposable, so a worktree dirty only with scratch
|
|
160
|
+
// is treated as clean here -- it is discarded with the worktree, never
|
|
161
|
+
// committed and never a reason to refuse removal.
|
|
162
|
+
if (worktreeHasNonScratchChanges(worktreePath)) {
|
|
138
163
|
log.warn(`Worktree has uncommitted changes: ${worktreePath}. ` +
|
|
139
164
|
`Creating safety commit to preserve work...`);
|
|
140
165
|
const hash = safetyCommitWorktree(worktreePath, deliveryName ?? 'unknown');
|
|
@@ -142,9 +167,10 @@ export function removeWorktree(repoPath, worktreePath, options = {}) {
|
|
|
142
167
|
log.info(`Safety commit created: ${hash.slice(0, 8)}`);
|
|
143
168
|
}
|
|
144
169
|
else {
|
|
145
|
-
// Could not commit
|
|
170
|
+
// Could not commit (guard refused a mass sweep, or the commit failed) --
|
|
171
|
+
// refuse to delete so the work stays recoverable.
|
|
146
172
|
log.error(`REFUSING to remove worktree ${worktreePath}: ` +
|
|
147
|
-
`has uncommitted changes and safety commit failed. ` +
|
|
173
|
+
`has uncommitted changes and safety commit failed or was refused. ` +
|
|
148
174
|
`Manual cleanup required.`);
|
|
149
175
|
return false;
|
|
150
176
|
}
|
|
@@ -158,8 +184,8 @@ export function removeWorktree(repoPath, worktreePath, options = {}) {
|
|
|
158
184
|
return false;
|
|
159
185
|
}
|
|
160
186
|
}
|
|
161
|
-
// Safe to remove --
|
|
162
|
-
return
|
|
187
|
+
// Safe to remove -- atomic reap (dir + registration gone together, verified)
|
|
188
|
+
return reapWorktreeAtomically(repoPath, worktreePath);
|
|
163
189
|
}
|
|
164
190
|
catch (err) {
|
|
165
191
|
log.error('Error removing worktree:', err instanceof Error ? err.message : String(err));
|
|
@@ -173,12 +199,19 @@ export function removeWorktree(repoPath, worktreePath, options = {}) {
|
|
|
173
199
|
* removal. Captures in-flight work that the agent didn't commit before
|
|
174
200
|
* being terminated.
|
|
175
201
|
*
|
|
176
|
-
*
|
|
202
|
+
* Stages via the scoped-staging guard (scratch-excluded, sweep-refused) and
|
|
203
|
+
* commits in the daemon package -- it no longer delegates to daemon-core's
|
|
204
|
+
* blind-`git add -A` commitWip, so the WIP path is guarded like every other.
|
|
177
205
|
*
|
|
178
206
|
* @returns The commit hash if a WIP commit was created, null if no changes or failure.
|
|
179
207
|
*/
|
|
180
|
-
export function commitWipChanges(worktreePath, contextName) {
|
|
208
|
+
export function commitWipChanges(worktreePath, contextName, reporter) {
|
|
181
209
|
log.info(`Committing WIP changes in ${worktreePath} for "${contextName}"`);
|
|
182
|
-
return
|
|
210
|
+
return guardedCommit({
|
|
211
|
+
worktreePath,
|
|
212
|
+
message: `WIP: Uncommitted work captured by daemon on agent exit (${contextName})`,
|
|
213
|
+
context: contextName,
|
|
214
|
+
reporter,
|
|
215
|
+
});
|
|
183
216
|
}
|
|
184
217
|
//# sourceMappingURL=safety.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"safety.js","sourceRoot":"","sources":["../../src/worktree/safety.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EACL,
|
|
1
|
+
{"version":3,"file":"safety.js","sourceRoot":"","sources":["../../src/worktree/safety.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped-staging guard for daemon-authored safety / WIP / lockfile commits.
|
|
3
|
+
*
|
|
4
|
+
* "The daemon owns the boundary of every path it creates." A blind `git add -A`
|
|
5
|
+
* in a repo whose `.gitignore` is missing or broken can sweep the entire
|
|
6
|
+
* worktree tree (nested agent checkouts under `.telora/`) into history -- the
|
|
7
|
+
* 15,684-file BELL-CSNv5 incident. Every daemon-authored commit MUST route its
|
|
8
|
+
* staging through this guard so that:
|
|
9
|
+
*
|
|
10
|
+
* 1. Daemon scratch (`.telora/` and, optionally, the configured worktree dir)
|
|
11
|
+
* is NEVER staged, regardless of the repo's ignore state -- enforced by a
|
|
12
|
+
* git `:(exclude)` pathspec, not by trusting `.gitignore`.
|
|
13
|
+
* 2. A commit whose (post-exclusion) staged file count exceeds a sane
|
|
14
|
+
* threshold is REFUSED -- the guard stages nothing and leaves the working
|
|
15
|
+
* tree untouched so the caller can escalate and the real work stays
|
|
16
|
+
* recoverable.
|
|
17
|
+
*
|
|
18
|
+
* This is defense-in-depth BEHIND the boot-time `.gitignore` convergence: even
|
|
19
|
+
* with a correct ignore, the sweep must be structurally impossible here.
|
|
20
|
+
*/
|
|
21
|
+
import { type WorktreeIdentityViolationReporter } from './identity.js';
|
|
22
|
+
/** Conservative default sweep ceiling for a single automated commit. */
|
|
23
|
+
export declare const DEFAULT_SAFETY_COMMIT_MAX_FILES = 200;
|
|
24
|
+
/**
|
|
25
|
+
* Daemon scratch path prefixes that must never be staged by an automated
|
|
26
|
+
* commit. `.telora/` is the root of everything the daemon writes into a repo
|
|
27
|
+
* (config, worktrees, session logs); the daemon's worktree dir is nested under
|
|
28
|
+
* it, so this single prefix covers the sweep vector. Extra prefixes (e.g. a
|
|
29
|
+
* worktree dir configured OUTSIDE `.telora`) can be supplied per call.
|
|
30
|
+
*/
|
|
31
|
+
export declare const DEFAULT_SCRATCH_PREFIXES: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Resolve the max-file sweep threshold via the canonical env-config index
|
|
34
|
+
* (`TELORA_SAFETY_COMMIT_MAX_FILES` in `@telora/daemon-core` `env-config.ts`).
|
|
35
|
+
* An invalid value fails loudly at parse time, per the env-config contract --
|
|
36
|
+
* there is no silent fallback. The daemon-core default must match
|
|
37
|
+
* {@link DEFAULT_SAFETY_COMMIT_MAX_FILES}; the scratch-guard test pins the two
|
|
38
|
+
* together.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveSafetyCommitMaxFiles(env?: NodeJS.ProcessEnv): number;
|
|
41
|
+
export interface ScopedStageOptions {
|
|
42
|
+
/** Scratch prefixes to hard-exclude (defaults to {@link DEFAULT_SCRATCH_PREFIXES}). */
|
|
43
|
+
scratchPrefixes?: readonly string[];
|
|
44
|
+
/** Max post-exclusion staged files before refusal (defaults to env / {@link DEFAULT_SAFETY_COMMIT_MAX_FILES}). */
|
|
45
|
+
maxFiles?: number;
|
|
46
|
+
}
|
|
47
|
+
/** Outcome of a scoped staging attempt. */
|
|
48
|
+
export type ScopedStageResult = {
|
|
49
|
+
kind: 'staged';
|
|
50
|
+
staged: string[];
|
|
51
|
+
denied: string[];
|
|
52
|
+
} | {
|
|
53
|
+
kind: 'refused_threshold';
|
|
54
|
+
/** Post-exclusion count that tripped the guard. */
|
|
55
|
+
count: number;
|
|
56
|
+
/** Bounded sample of the tripping paths for the escalation. */
|
|
57
|
+
sample: string[];
|
|
58
|
+
denied: string[];
|
|
59
|
+
maxFiles: number;
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'error';
|
|
62
|
+
message: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Stage the worktree's changes for a daemon-authored commit, HARD-excluding
|
|
66
|
+
* daemon scratch and REFUSING a mass sweep.
|
|
67
|
+
*
|
|
68
|
+
* On success stages everything except the scratch prefixes (via a git
|
|
69
|
+
* `:(exclude)` pathspec, so exclusion holds even when scratch is not
|
|
70
|
+
* gitignored). On threshold refusal stages NOTHING and returns the tripping
|
|
71
|
+
* count + a bounded sample so the caller can preserve the tree and escalate.
|
|
72
|
+
*
|
|
73
|
+
* Does not commit -- callers commit after a `staged` result.
|
|
74
|
+
*/
|
|
75
|
+
export declare function stageExcludingDaemonScratch(worktreePath: string, opts?: ScopedStageOptions): ScopedStageResult;
|
|
76
|
+
/**
|
|
77
|
+
* True iff the worktree has uncommitted changes OUTSIDE daemon scratch. Daemon
|
|
78
|
+
* scratch (`.telora/`) is disposable, so a worktree dirty only with scratch has
|
|
79
|
+
* no agent work to preserve -- callers use this to decide whether a safety
|
|
80
|
+
* commit is even needed before removal.
|
|
81
|
+
*/
|
|
82
|
+
export declare function worktreeHasNonScratchChanges(worktreePath: string, scratchPrefixes?: readonly string[]): boolean;
|
|
83
|
+
/** Details of a refused mass-sweep, handed to a {@link SweepRefusalReporter}. */
|
|
84
|
+
export interface SweepRefusalReport {
|
|
85
|
+
worktreePath: string;
|
|
86
|
+
/** Human label for the commit context (delivery/focus name). */
|
|
87
|
+
context: string;
|
|
88
|
+
/** Post-exclusion staged count that tripped the guard. */
|
|
89
|
+
count: number;
|
|
90
|
+
/** Bounded sample of the tripping paths. */
|
|
91
|
+
sample: string[];
|
|
92
|
+
maxFiles: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Callback invoked when the guard refuses a mass sweep. Injected by a
|
|
96
|
+
* context-holding caller to file an escalation; defaults to a loud log so every
|
|
97
|
+
* refusal is at least named in the daemon log.
|
|
98
|
+
*/
|
|
99
|
+
export type SweepRefusalReporter = (report: SweepRefusalReport) => void;
|
|
100
|
+
/** Default reporter: loudly name the worktree, count, and a path sample. */
|
|
101
|
+
export declare const defaultSweepRefusalReporter: SweepRefusalReporter;
|
|
102
|
+
/**
|
|
103
|
+
* Stage (scratch-excluded, sweep-guarded) and commit a daemon-authored commit.
|
|
104
|
+
* The ONE implementation every daemon safety/WIP/lockfile commit routes
|
|
105
|
+
* through, so none can stage `.telora/` scratch or sweep the tree.
|
|
106
|
+
*
|
|
107
|
+
* Returns the commit sha on success, or null when there was nothing to commit
|
|
108
|
+
* (clean or scratch-only tree), the worktree failed the identity check, the
|
|
109
|
+
* sweep was refused, or a git op failed. On a threshold refusal the working
|
|
110
|
+
* tree is left untouched and the reporter is invoked exactly once.
|
|
111
|
+
*
|
|
112
|
+
* Identity is asserted BEFORE any staging: if the directory is not the worktree
|
|
113
|
+
* it names (e.g. a `.git`-less checkout whose git commands resolve at the
|
|
114
|
+
* enclosing repo), nothing is staged or committed and the identity reporter is
|
|
115
|
+
* invoked once -- so a daemon safety/WIP/lockfile commit can never land in the
|
|
116
|
+
* parent repository's tree.
|
|
117
|
+
*/
|
|
118
|
+
export declare function guardedCommit(params: {
|
|
119
|
+
worktreePath: string;
|
|
120
|
+
message: string;
|
|
121
|
+
context: string;
|
|
122
|
+
reporter?: SweepRefusalReporter;
|
|
123
|
+
identityReporter?: WorktreeIdentityViolationReporter;
|
|
124
|
+
options?: ScopedStageOptions;
|
|
125
|
+
}): string | null;
|
|
126
|
+
//# sourceMappingURL=scratch-guard.d.ts.map
|