@telora/daemon 0.20.96 → 0.20.104
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/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/state-cascade.d.ts +23 -3
- package/dist/state-cascade.d.ts.map +1 -1
- package/dist/state-cascade.js +33 -9
- package/dist/state-cascade.js.map +1 -1
- 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,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worktree identity assertion.
|
|
3
|
+
*
|
|
4
|
+
* "The daemon owns the boundary of every path it creates." The daemon hands a
|
|
5
|
+
* worktree path to git only as a working directory -- it never checks that git
|
|
6
|
+
* agrees the directory IS that worktree. When a checkout has lost its `.git`
|
|
7
|
+
* file, git resolves the command against the ENCLOSING repository instead of
|
|
8
|
+
* failing: `git status` reports the parent's tree, `git add -A` stages it, and
|
|
9
|
+
* a "safety commit" for one agent's worktree lands in the parent repo's tree
|
|
10
|
+
* with a hash that belongs to a commit the agent never made. That silent
|
|
11
|
+
* redirection is the shared root beneath both the 15,684-file sweep (a blind
|
|
12
|
+
* stage becomes repo-wide) and the un-reapable residue (the pre-removal
|
|
13
|
+
* dirty-check sees the parent's untracked `.telora/` and refuses to reap).
|
|
14
|
+
*
|
|
15
|
+
* assertWorktreeIdentity is the single primitive every daemon git WRITE that
|
|
16
|
+
* names a worktree consults first: it confirms `git rev-parse --show-toplevel`
|
|
17
|
+
* (run inside the directory) resolves to the worktree the daemon named. When it
|
|
18
|
+
* does not, the caller refuses the operation rather than performing it against
|
|
19
|
+
* a different repository.
|
|
20
|
+
*/
|
|
21
|
+
import { realpathSync } from 'node:fs';
|
|
22
|
+
import { resolve } from 'node:path';
|
|
23
|
+
import { runGitSync } from '../git/index.js';
|
|
24
|
+
import { createLogger } from '../log.js';
|
|
25
|
+
const log = createLogger({ module: 'worktree' });
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalize a path for comparison: resolve symlinks via realpath so a
|
|
28
|
+
* symlinked or non-canonical worktree path does not false-positive against
|
|
29
|
+
* git's already-canonical `--show-toplevel`. Falls back to `path.resolve` when
|
|
30
|
+
* the path cannot be realpath'd (e.g. it does not exist).
|
|
31
|
+
*/
|
|
32
|
+
function canonicalize(p) {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(p);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return resolve(p);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Confirm that `worktreePath` is the git worktree it names: run
|
|
42
|
+
* `git rev-parse --show-toplevel` with the directory as cwd and compare
|
|
43
|
+
* (canonical/realpath form) against the directory itself.
|
|
44
|
+
*
|
|
45
|
+
* A healthy linked worktree reports its OWN root as the toplevel, so it passes.
|
|
46
|
+
* A `.git`-less checkout resolves to the enclosing repository -- ok is false and
|
|
47
|
+
* resolvedRepo carries that enclosing repo, so the caller can refuse and name
|
|
48
|
+
* exactly where the operation would otherwise have landed.
|
|
49
|
+
*/
|
|
50
|
+
export function assertWorktreeIdentity(worktreePath) {
|
|
51
|
+
const top = runGitSync(['rev-parse', '--show-toplevel'], worktreePath);
|
|
52
|
+
if (!top.success || top.output.trim().length === 0) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
resolvedRepo: null,
|
|
56
|
+
reason: `git could not resolve a repository at ${worktreePath}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const resolvedRepo = canonicalize(top.output.trim());
|
|
60
|
+
const intended = canonicalize(worktreePath);
|
|
61
|
+
if (resolvedRepo === intended) {
|
|
62
|
+
return { ok: true, resolvedRepo };
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
ok: false,
|
|
66
|
+
resolvedRepo,
|
|
67
|
+
reason: `worktree path ${worktreePath} resolves to a different git toplevel ` +
|
|
68
|
+
`(${resolvedRepo}); the checkout may have lost its .git file, so a git ` +
|
|
69
|
+
`command run here would act on the enclosing repository`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Default reporter: loudly name the worktree, the operation, and the repo git resolved. */
|
|
73
|
+
export const defaultWorktreeIdentityViolationReporter = (v) => {
|
|
74
|
+
log.error(`REFUSING daemon git ${v.operation} at ${v.worktreePath}: it is not its own ` +
|
|
75
|
+
`worktree (git resolves it to ${v.resolvedRepo ?? 'no repository'}). A checkout ` +
|
|
76
|
+
`that lost its .git file would redirect this operation at the enclosing ` +
|
|
77
|
+
`repository. Operation refused; no git write performed.`);
|
|
78
|
+
};
|
|
79
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.js","sourceRoot":"","sources":["../../src/worktree/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAEzC,MAAM,GAAG,GAAG,YAAY,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC;AAEjD;;;;;GAKG;AACH,SAAS,YAAY,CAAC,CAAS;IAC7B,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC,CAAC,CAAC,CAAC;IACpB,CAAC;AACH,CAAC;AAgBD;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CAAC,YAAoB;IACzD,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,WAAW,EAAE,iBAAiB,CAAC,EAAE,YAAY,CAAC,CAAC;IACvE,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACnD,OAAO;YACL,EAAE,EAAE,KAAK;YACT,YAAY,EAAE,IAAI;YAClB,MAAM,EAAE,yCAAyC,YAAY,EAAE;SAChE,CAAC;IACJ,CAAC;IAED,MAAM,YAAY,GAAG,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACrD,MAAM,QAAQ,GAAG,YAAY,CAAC,YAAY,CAAC,CAAC;IAE5C,IAAI,YAAY,KAAK,QAAQ,EAAE,CAAC;QAC9B,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;IACpC,CAAC;IAED,OAAO;QACL,EAAE,EAAE,KAAK;QACT,YAAY;QACZ,MAAM,EACJ,iBAAiB,YAAY,wCAAwC;YACrE,IAAI,YAAY,wDAAwD;YACxE,wDAAwD;KAC3D,CAAC;AACJ,CAAC;AAoBD,4FAA4F;AAC5F,MAAM,CAAC,MAAM,wCAAwC,GAAsC,CAAC,CAAC,EAAE,EAAE;IAC/F,GAAG,CAAC,KAAK,CACP,uBAAuB,CAAC,CAAC,SAAS,OAAO,CAAC,CAAC,YAAY,sBAAsB;QAC7E,gCAAgC,CAAC,CAAC,YAAY,IAAI,eAAe,gBAAgB;QACjF,yEAAyE;QACzE,wDAAwD,CACzD,CAAC;AACJ,CAAC,CAAC"}
|
package/dist/worktree/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/worktree/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/worktree/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC"}
|
package/dist/worktree/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/worktree/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/worktree/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC;AAC3B,cAAc,YAAY,CAAC;AAC3B,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,eAAe,CAAC"}
|
|
@@ -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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"}
|