@webpieces/rules-config 0.4.507 → 0.4.509
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +3 -1
- package/src/index.js.map +1 -1
- package/src/merged-branch-verdicts.d.ts +154 -0
- package/src/merged-branch-verdicts.js +207 -0
- package/src/merged-branch-verdicts.js.map +1 -0
- package/src/merged-branches.d.ts +26 -111
- package/src/merged-branches.js +85 -174
- package/src/merged-branches.js.map +1 -1
- package/src/rule-configs.js +2 -1
- package/src/rule-configs.js.map +1 -1
package/src/merged-branches.d.ts
CHANGED
|
@@ -1,116 +1,30 @@
|
|
|
1
1
|
import { WorktreeService } from './worktrees';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
pr: number;
|
|
5
|
-
constructor(branch: string, pr: number);
|
|
6
|
-
}
|
|
7
|
-
/**
|
|
8
|
-
* WHY a branch is dead, or why it was spared — as a STABLE token, alongside the English prose.
|
|
9
|
-
*
|
|
10
|
-
* `sha`/`commits`/`prState` (below) let a human SEE a spared branch. This adds the other half: a token
|
|
11
|
-
* saying WHICH KIND of spared it is. Every spared branch used to report the identical string
|
|
12
|
-
* `no merged PR found — a human must decide`, and in one observed repo that one string covered three
|
|
13
|
-
* genuinely different situations — a PR CLOSED UNMERGED whose work landed under a later number, a
|
|
14
|
-
* branch that NEVER had a PR and holds the only copy of its commits, and content already in main.
|
|
15
|
-
* Reporting all three identically is why nobody ever decided, and why the pile grew to 6 branches
|
|
16
|
-
* against a cap of 5. A token lets wp-cleanup GROUP them and ask a question that can be answered.
|
|
17
|
-
*/
|
|
18
|
-
export declare const CLASSIFICATION_MERGED_PR = "merged-pr";
|
|
19
|
-
export declare const CLASSIFICATION_BACKUP_OF_MERGED = "backup-of-merged";
|
|
20
|
-
export declare const CLASSIFICATION_NO_COMMITS = "no-commits";
|
|
21
|
-
export declare const CLASSIFICATION_SUPERSEDED = "superseded";
|
|
22
|
-
export declare const CLASSIFICATION_CONTENT_IN_MAIN = "content-already-in-main";
|
|
23
|
-
export declare const CLASSIFICATION_NEVER_PROPOSED = "never-proposed";
|
|
24
|
-
export declare const CLASSIFICATION_IN_USE = "in-use";
|
|
25
|
-
/**
|
|
26
|
-
* WORKTREE-only classifications. A worktree verdict borrows every token above (its branch is what is
|
|
27
|
-
* being judged), but three of its outcomes have no branch analogue at all, and lumping them under
|
|
28
|
-
* `in-use` would tell wp-cleanup to shut up about exactly the ones a human might want to act on.
|
|
29
|
-
*
|
|
30
|
-
* - PRUNABLE — the directory is already gone; `git worktree prune` is the reap, not `remove`.
|
|
31
|
-
* - LOCKED — a human ran `git worktree lock`. Explicitly "do not touch"; never promptable.
|
|
32
|
-
* - CURRENT — the worktree the command is running IN. Removing your own cwd is a self-destruct.
|
|
33
|
-
* - DETACHED — detached HEAD, so there is no branch to judge and no branch to archive.
|
|
34
|
-
*/
|
|
35
|
-
export declare const CLASSIFICATION_PRUNABLE = "prunable-worktree";
|
|
36
|
-
export declare const CLASSIFICATION_LOCKED = "locked-worktree";
|
|
37
|
-
export declare const CLASSIFICATION_CURRENT = "current-worktree";
|
|
38
|
-
export declare const CLASSIFICATION_DETACHED = "detached-worktree";
|
|
39
|
-
export declare const PROMPTABLE_CLASSIFICATIONS: readonly string[];
|
|
40
|
-
/**
|
|
41
|
-
* A local branch and the verdict on it. `pr` is 0 when no merged PR backs the verdict (a `keep`).
|
|
42
|
-
*
|
|
43
|
-
* `sha`, `commits` and `prState` exist for the SPARED branches specifically. When nothing is
|
|
44
|
-
* auto-reapable the branch cap has nothing safe to offer, and its only remaining advice was "raise
|
|
45
|
-
* maxLocalBranches" / "set turnOffRuleUntilEpoch" — both of which loosen the rule. An agent with no
|
|
46
|
-
* human present took the config edit, which is the exact failure the cap exists to prevent. To ask a
|
|
47
|
-
* human "may I delete these?" instead, the guard has to be able to SHOW what it would delete: tip SHA
|
|
48
|
-
* (so the delete is recoverable), PR state (closed? never opened?) and how much unique work is on it.
|
|
49
|
-
*
|
|
50
|
-
* All three are computed in the DETACHED refresher and read straight off merged-branches.json — the
|
|
51
|
-
* blocking hook path never recomputes them. Defaulted so a cache written by an older release (and
|
|
52
|
-
* every existing call site) revives without them.
|
|
53
|
-
*/
|
|
54
|
-
export declare class DeletableBranch {
|
|
55
|
-
branch: string;
|
|
56
|
-
reason: string;
|
|
57
|
-
pr: number;
|
|
58
|
-
/** Short tip SHA — what makes the delete undoable (`git branch <name> <sha>`). '' when unknown. */
|
|
59
|
-
sha: string;
|
|
60
|
-
/** Commits on this branch that are not on origin/main. -1 when it could not be established. */
|
|
61
|
-
commits: number;
|
|
62
|
-
/** GitHub's state for the PR whose head is this branch: MERGED / CLOSED / OPEN, or '' for none. */
|
|
63
|
-
prState: string;
|
|
64
|
-
/**
|
|
65
|
-
* One of the CLASSIFICATION_* tokens. Defaulted like the fields above so every pre-existing call
|
|
66
|
-
* site — and every cache written by an older release — still constructs; an unclassified revived
|
|
67
|
-
* entry reads as 'never-proposed', the most conservative of the spared verdicts.
|
|
68
|
-
*/
|
|
69
|
-
classification: string;
|
|
70
|
-
constructor(branch: string, reason: string, pr: number, sha?: string, commits?: number, prState?: string, classification?: string);
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* A worktree and the verdict on it. Carries `path` (what `git worktree remove` takes) AND `branch`
|
|
74
|
-
* (what `git branch -D` takes afterwards) because reaping a worktree is always those two steps, in
|
|
75
|
-
* that order — git refuses to delete a branch that is still checked out somewhere.
|
|
76
|
-
*
|
|
77
|
-
* `classification` is the same STABLE token the branch verdicts carry, plus the four worktree-only
|
|
78
|
-
* ones above. It exists for the same reason it does on DeletableBranch: `deletable` answers "may the
|
|
79
|
-
* tooling reap this unattended?", and everything false used to collapse into one undifferentiated
|
|
80
|
-
* "spared" that a human could not rule on. With a token, WorktreeReaper knows whether the reap is a
|
|
81
|
-
* `prune` or a `remove`, and wp-cleanup knows which spared worktrees are worth ASKING about.
|
|
82
|
-
* Defaulted so every pre-existing call site and every cache written by an older release still builds;
|
|
83
|
-
* an unclassified revived entry reads as 'never-proposed', the most conservative spared verdict.
|
|
84
|
-
*/
|
|
85
|
-
export declare class DeletableWorktree {
|
|
86
|
-
path: string;
|
|
87
|
-
branch: string;
|
|
88
|
-
reason: string;
|
|
89
|
-
pr: number;
|
|
90
|
-
deletable: boolean;
|
|
91
|
-
classification: string;
|
|
92
|
-
constructor(path: string, branch: string, reason: string, pr: number, deletable: boolean, classification?: string);
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* `deletable` is PRECOMPUTED so the consumer just deletes the list — no re-deriving, no judgement
|
|
96
|
-
* call at block time. `keep` carries the branches we refuse to touch (no merged PR found), each with
|
|
97
|
-
* its reason, so a human can see what was spared and why.
|
|
98
|
-
*
|
|
99
|
-
* `worktrees` is the parallel verdict list for the SECOND budget (see worktrees.ts): every linked
|
|
100
|
-
* worktree, each flagged deletable or not. Both budgets are reaped from this one cache file.
|
|
101
|
-
*/
|
|
102
|
-
export declare class MergedBranchesCache {
|
|
103
|
-
timestamp: string;
|
|
104
|
-
deletable: DeletableBranch[];
|
|
105
|
-
keep: DeletableBranch[];
|
|
106
|
-
worktrees: DeletableWorktree[];
|
|
107
|
-
constructor(timestamp: string, deletable: DeletableBranch[], keep: DeletableBranch[], worktrees?: DeletableWorktree[]);
|
|
108
|
-
}
|
|
2
|
+
import { CacheFreshness, MergedBranchesCache } from './merged-branch-verdicts';
|
|
3
|
+
export { MergedBranch, DeletableBranch, DeletableWorktree, MergedBranchesCache, CacheFreshness, CACHE_STALE_AFTER_MS, CLASSIFICATION_MERGED_PR, CLASSIFICATION_BACKUP_OF_MERGED, CLASSIFICATION_NO_COMMITS, CLASSIFICATION_SUPERSEDED, CLASSIFICATION_CONTENT_IN_MAIN, CLASSIFICATION_NEVER_PROPOSED, CLASSIFICATION_IN_USE, CLASSIFICATION_PRUNABLE, CLASSIFICATION_LOCKED, CLASSIFICATION_CURRENT, CLASSIFICATION_DETACHED, PROMPTABLE_CLASSIFICATIONS, } from './merged-branch-verdicts';
|
|
109
4
|
export declare class MergedBranchesService {
|
|
110
5
|
private readonly worktrees;
|
|
111
6
|
constructor(worktrees?: WorktreeService);
|
|
112
7
|
mergedBranchesPath(repoRoot: string): string;
|
|
113
8
|
readMergedBranches(repoRoot: string): MergedBranchesCache | null;
|
|
9
|
+
/**
|
|
10
|
+
* How old is this cache, and may its numbers be quoted as fact?
|
|
11
|
+
*
|
|
12
|
+
* `now` is a parameter purely so the specs can pin it; every caller passes nothing.
|
|
13
|
+
*/
|
|
14
|
+
freshness(cache: MergedBranchesCache, now?: number): CacheFreshness;
|
|
15
|
+
/**
|
|
16
|
+
* Drop every cached verdict whose subject no longer exists, using only LOCAL, instant git reads.
|
|
17
|
+
*
|
|
18
|
+
* The verdicts themselves are NOT re-derived here — that is the detached refresher's job and it
|
|
19
|
+
* needs the network. This answers a different and much cheaper question: does this branch / this
|
|
20
|
+
* worktree still exist at all? A cache written before a `git branch -D` or a `git worktree remove`
|
|
21
|
+
* keeps naming things that are gone, and a reader that counts those entries reports a repo that
|
|
22
|
+
* does not exist (observed: 8 parked branches asserted against an actual 1) and then blocks on it.
|
|
23
|
+
*
|
|
24
|
+
* Fails toward the cache: if git cannot answer (empty branch list), the cache is returned untouched
|
|
25
|
+
* rather than emptied, since "git failed" must never read as "everything was deleted".
|
|
26
|
+
*/
|
|
27
|
+
reconcile(repoRoot: string, cache: MergedBranchesCache): MergedBranchesCache;
|
|
114
28
|
writeMergedBranches(repoRoot: string, cache: MergedBranchesCache): void;
|
|
115
29
|
/**
|
|
116
30
|
* Every local branch except `main`. Uses `git for-each-ref`, NOT `git branch` — the latter is a
|
|
@@ -128,10 +42,11 @@ export declare class MergedBranchesService {
|
|
|
128
42
|
* clone and is not a thing you can remove.
|
|
129
43
|
*
|
|
130
44
|
* A worktree is deletable when its directory is already gone (`prunable`), or when its branch is
|
|
131
|
-
* dead by the very same
|
|
132
|
-
*
|
|
133
|
-
* are standing in right now (removing your own cwd is not a thing to suggest to an
|
|
134
|
-
* its branch
|
|
45
|
+
* dead by the very same proof the branch cap uses — a MERGED PR, its own or that of the branch it
|
|
46
|
+
* snapshots. Nothing else. It is spared when it is LOCKED (a human said "do not touch"), when it is
|
|
47
|
+
* the worktree we are standing in right now (removing your own cwd is not a thing to suggest to an
|
|
48
|
+
* agent), or when its branch is anything short of provably merged — INCLUDING a branch with no
|
|
49
|
+
* commits yet, which is what every worktree looks like while an agent is working in it.
|
|
135
50
|
*/
|
|
136
51
|
private classifyWorktrees;
|
|
137
52
|
/**
|
package/src/merged-branches.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.MergedBranchesService = exports.
|
|
3
|
+
exports.MergedBranchesService = exports.PROMPTABLE_CLASSIFICATIONS = exports.CLASSIFICATION_DETACHED = exports.CLASSIFICATION_CURRENT = exports.CLASSIFICATION_LOCKED = exports.CLASSIFICATION_PRUNABLE = exports.CLASSIFICATION_IN_USE = exports.CLASSIFICATION_NEVER_PROPOSED = exports.CLASSIFICATION_CONTENT_IN_MAIN = exports.CLASSIFICATION_SUPERSEDED = exports.CLASSIFICATION_NO_COMMITS = exports.CLASSIFICATION_BACKUP_OF_MERGED = exports.CLASSIFICATION_MERGED_PR = exports.CACHE_STALE_AFTER_MS = exports.CacheFreshness = exports.MergedBranchesCache = exports.DeletableWorktree = exports.DeletableBranch = exports.MergedBranch = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const child_process_1 = require("child_process");
|
|
6
6
|
const fs = tslib_1.__importStar(require("fs"));
|
|
@@ -9,6 +9,26 @@ const inversify_1 = require("inversify");
|
|
|
9
9
|
const constants_1 = require("./constants");
|
|
10
10
|
const to_error_1 = require("./to-error");
|
|
11
11
|
const worktrees_1 = require("./worktrees");
|
|
12
|
+
const merged_branch_verdicts_1 = require("./merged-branch-verdicts");
|
|
13
|
+
var merged_branch_verdicts_2 = require("./merged-branch-verdicts");
|
|
14
|
+
Object.defineProperty(exports, "MergedBranch", { enumerable: true, get: function () { return merged_branch_verdicts_2.MergedBranch; } });
|
|
15
|
+
Object.defineProperty(exports, "DeletableBranch", { enumerable: true, get: function () { return merged_branch_verdicts_2.DeletableBranch; } });
|
|
16
|
+
Object.defineProperty(exports, "DeletableWorktree", { enumerable: true, get: function () { return merged_branch_verdicts_2.DeletableWorktree; } });
|
|
17
|
+
Object.defineProperty(exports, "MergedBranchesCache", { enumerable: true, get: function () { return merged_branch_verdicts_2.MergedBranchesCache; } });
|
|
18
|
+
Object.defineProperty(exports, "CacheFreshness", { enumerable: true, get: function () { return merged_branch_verdicts_2.CacheFreshness; } });
|
|
19
|
+
Object.defineProperty(exports, "CACHE_STALE_AFTER_MS", { enumerable: true, get: function () { return merged_branch_verdicts_2.CACHE_STALE_AFTER_MS; } });
|
|
20
|
+
Object.defineProperty(exports, "CLASSIFICATION_MERGED_PR", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_MERGED_PR; } });
|
|
21
|
+
Object.defineProperty(exports, "CLASSIFICATION_BACKUP_OF_MERGED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_BACKUP_OF_MERGED; } });
|
|
22
|
+
Object.defineProperty(exports, "CLASSIFICATION_NO_COMMITS", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_NO_COMMITS; } });
|
|
23
|
+
Object.defineProperty(exports, "CLASSIFICATION_SUPERSEDED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_SUPERSEDED; } });
|
|
24
|
+
Object.defineProperty(exports, "CLASSIFICATION_CONTENT_IN_MAIN", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_CONTENT_IN_MAIN; } });
|
|
25
|
+
Object.defineProperty(exports, "CLASSIFICATION_NEVER_PROPOSED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_NEVER_PROPOSED; } });
|
|
26
|
+
Object.defineProperty(exports, "CLASSIFICATION_IN_USE", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_IN_USE; } });
|
|
27
|
+
Object.defineProperty(exports, "CLASSIFICATION_PRUNABLE", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_PRUNABLE; } });
|
|
28
|
+
Object.defineProperty(exports, "CLASSIFICATION_LOCKED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_LOCKED; } });
|
|
29
|
+
Object.defineProperty(exports, "CLASSIFICATION_CURRENT", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_CURRENT; } });
|
|
30
|
+
Object.defineProperty(exports, "CLASSIFICATION_DETACHED", { enumerable: true, get: function () { return merged_branch_verdicts_2.CLASSIFICATION_DETACHED; } });
|
|
31
|
+
Object.defineProperty(exports, "PROMPTABLE_CLASSIFICATIONS", { enumerable: true, get: function () { return merged_branch_verdicts_2.PROMPTABLE_CLASSIFICATIONS; } });
|
|
12
32
|
/**
|
|
13
33
|
* The "which local branches are dead?" cache.
|
|
14
34
|
*
|
|
@@ -31,152 +51,6 @@ const MERGED_PR_LOOKUP_LIMIT = 100;
|
|
|
31
51
|
// (base → baseSquash / basewp2 / basePreMerge3). GitHub has never seen these SHAs, so no PR will ever
|
|
32
52
|
// name them — they can only be reaped by stripping back to the base branch they were cloned from.
|
|
33
53
|
const BACKUP_SUFFIX = /(?:Squash|PreMerge\d*|wp\d+)$/;
|
|
34
|
-
// Data-only (per CLAUDE.md, classes for data).
|
|
35
|
-
class MergedBranch {
|
|
36
|
-
branch;
|
|
37
|
-
pr;
|
|
38
|
-
constructor(branch, pr) {
|
|
39
|
-
this.branch = branch;
|
|
40
|
-
this.pr = pr;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
exports.MergedBranch = MergedBranch;
|
|
44
|
-
/**
|
|
45
|
-
* WHY a branch is dead, or why it was spared — as a STABLE token, alongside the English prose.
|
|
46
|
-
*
|
|
47
|
-
* `sha`/`commits`/`prState` (below) let a human SEE a spared branch. This adds the other half: a token
|
|
48
|
-
* saying WHICH KIND of spared it is. Every spared branch used to report the identical string
|
|
49
|
-
* `no merged PR found — a human must decide`, and in one observed repo that one string covered three
|
|
50
|
-
* genuinely different situations — a PR CLOSED UNMERGED whose work landed under a later number, a
|
|
51
|
-
* branch that NEVER had a PR and holds the only copy of its commits, and content already in main.
|
|
52
|
-
* Reporting all three identically is why nobody ever decided, and why the pile grew to 6 branches
|
|
53
|
-
* against a cap of 5. A token lets wp-cleanup GROUP them and ask a question that can be answered.
|
|
54
|
-
*/
|
|
55
|
-
// Dead by proof — these are auto-deletable.
|
|
56
|
-
exports.CLASSIFICATION_MERGED_PR = 'merged-pr';
|
|
57
|
-
exports.CLASSIFICATION_BACKUP_OF_MERGED = 'backup-of-merged';
|
|
58
|
-
exports.CLASSIFICATION_NO_COMMITS = 'no-commits';
|
|
59
|
-
// Spared, but a human should be ASKED — in descending order of "obviously fine to delete".
|
|
60
|
-
exports.CLASSIFICATION_SUPERSEDED = 'superseded';
|
|
61
|
-
exports.CLASSIFICATION_CONTENT_IN_MAIN = 'content-already-in-main';
|
|
62
|
-
exports.CLASSIFICATION_NEVER_PROPOSED = 'never-proposed';
|
|
63
|
-
// Spared for a mechanical reason, not a judgement call (checked out somewhere).
|
|
64
|
-
exports.CLASSIFICATION_IN_USE = 'in-use';
|
|
65
|
-
/**
|
|
66
|
-
* WORKTREE-only classifications. A worktree verdict borrows every token above (its branch is what is
|
|
67
|
-
* being judged), but three of its outcomes have no branch analogue at all, and lumping them under
|
|
68
|
-
* `in-use` would tell wp-cleanup to shut up about exactly the ones a human might want to act on.
|
|
69
|
-
*
|
|
70
|
-
* - PRUNABLE — the directory is already gone; `git worktree prune` is the reap, not `remove`.
|
|
71
|
-
* - LOCKED — a human ran `git worktree lock`. Explicitly "do not touch"; never promptable.
|
|
72
|
-
* - CURRENT — the worktree the command is running IN. Removing your own cwd is a self-destruct.
|
|
73
|
-
* - DETACHED — detached HEAD, so there is no branch to judge and no branch to archive.
|
|
74
|
-
*/
|
|
75
|
-
exports.CLASSIFICATION_PRUNABLE = 'prunable-worktree';
|
|
76
|
-
exports.CLASSIFICATION_LOCKED = 'locked-worktree';
|
|
77
|
-
exports.CLASSIFICATION_CURRENT = 'current-worktree';
|
|
78
|
-
exports.CLASSIFICATION_DETACHED = 'detached-worktree';
|
|
79
|
-
// Spared classifications a human can meaningfully rule on, most-safe first. wp-cleanup prompts in
|
|
80
|
-
// exactly this order so the easy yeses come before the ones that need thought.
|
|
81
|
-
exports.PROMPTABLE_CLASSIFICATIONS = [
|
|
82
|
-
exports.CLASSIFICATION_SUPERSEDED,
|
|
83
|
-
exports.CLASSIFICATION_CONTENT_IN_MAIN,
|
|
84
|
-
exports.CLASSIFICATION_NEVER_PROPOSED,
|
|
85
|
-
];
|
|
86
|
-
/**
|
|
87
|
-
* A local branch and the verdict on it. `pr` is 0 when no merged PR backs the verdict (a `keep`).
|
|
88
|
-
*
|
|
89
|
-
* `sha`, `commits` and `prState` exist for the SPARED branches specifically. When nothing is
|
|
90
|
-
* auto-reapable the branch cap has nothing safe to offer, and its only remaining advice was "raise
|
|
91
|
-
* maxLocalBranches" / "set turnOffRuleUntilEpoch" — both of which loosen the rule. An agent with no
|
|
92
|
-
* human present took the config edit, which is the exact failure the cap exists to prevent. To ask a
|
|
93
|
-
* human "may I delete these?" instead, the guard has to be able to SHOW what it would delete: tip SHA
|
|
94
|
-
* (so the delete is recoverable), PR state (closed? never opened?) and how much unique work is on it.
|
|
95
|
-
*
|
|
96
|
-
* All three are computed in the DETACHED refresher and read straight off merged-branches.json — the
|
|
97
|
-
* blocking hook path never recomputes them. Defaulted so a cache written by an older release (and
|
|
98
|
-
* every existing call site) revives without them.
|
|
99
|
-
*/
|
|
100
|
-
class DeletableBranch {
|
|
101
|
-
branch;
|
|
102
|
-
reason;
|
|
103
|
-
pr;
|
|
104
|
-
/** Short tip SHA — what makes the delete undoable (`git branch <name> <sha>`). '' when unknown. */
|
|
105
|
-
sha;
|
|
106
|
-
/** Commits on this branch that are not on origin/main. -1 when it could not be established. */
|
|
107
|
-
commits;
|
|
108
|
-
/** GitHub's state for the PR whose head is this branch: MERGED / CLOSED / OPEN, or '' for none. */
|
|
109
|
-
prState;
|
|
110
|
-
/**
|
|
111
|
-
* One of the CLASSIFICATION_* tokens. Defaulted like the fields above so every pre-existing call
|
|
112
|
-
* site — and every cache written by an older release — still constructs; an unclassified revived
|
|
113
|
-
* entry reads as 'never-proposed', the most conservative of the spared verdicts.
|
|
114
|
-
*/
|
|
115
|
-
classification;
|
|
116
|
-
// eslint-disable-next-line @typescript-eslint/max-params
|
|
117
|
-
constructor(branch, reason, pr, sha = '', commits = -1, prState = '', classification = exports.CLASSIFICATION_NEVER_PROPOSED) {
|
|
118
|
-
this.branch = branch;
|
|
119
|
-
this.reason = reason;
|
|
120
|
-
this.pr = pr;
|
|
121
|
-
this.sha = sha;
|
|
122
|
-
this.commits = commits;
|
|
123
|
-
this.prState = prState;
|
|
124
|
-
this.classification = classification;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
exports.DeletableBranch = DeletableBranch;
|
|
128
|
-
/**
|
|
129
|
-
* A worktree and the verdict on it. Carries `path` (what `git worktree remove` takes) AND `branch`
|
|
130
|
-
* (what `git branch -D` takes afterwards) because reaping a worktree is always those two steps, in
|
|
131
|
-
* that order — git refuses to delete a branch that is still checked out somewhere.
|
|
132
|
-
*
|
|
133
|
-
* `classification` is the same STABLE token the branch verdicts carry, plus the four worktree-only
|
|
134
|
-
* ones above. It exists for the same reason it does on DeletableBranch: `deletable` answers "may the
|
|
135
|
-
* tooling reap this unattended?", and everything false used to collapse into one undifferentiated
|
|
136
|
-
* "spared" that a human could not rule on. With a token, WorktreeReaper knows whether the reap is a
|
|
137
|
-
* `prune` or a `remove`, and wp-cleanup knows which spared worktrees are worth ASKING about.
|
|
138
|
-
* Defaulted so every pre-existing call site and every cache written by an older release still builds;
|
|
139
|
-
* an unclassified revived entry reads as 'never-proposed', the most conservative spared verdict.
|
|
140
|
-
*/
|
|
141
|
-
class DeletableWorktree {
|
|
142
|
-
path;
|
|
143
|
-
branch;
|
|
144
|
-
reason;
|
|
145
|
-
pr;
|
|
146
|
-
deletable;
|
|
147
|
-
classification;
|
|
148
|
-
// eslint-disable-next-line @typescript-eslint/max-params
|
|
149
|
-
constructor(path, branch, reason, pr, deletable, classification = exports.CLASSIFICATION_NEVER_PROPOSED) {
|
|
150
|
-
this.path = path;
|
|
151
|
-
this.branch = branch;
|
|
152
|
-
this.reason = reason;
|
|
153
|
-
this.pr = pr;
|
|
154
|
-
this.deletable = deletable;
|
|
155
|
-
this.classification = classification;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
exports.DeletableWorktree = DeletableWorktree;
|
|
159
|
-
/**
|
|
160
|
-
* `deletable` is PRECOMPUTED so the consumer just deletes the list — no re-deriving, no judgement
|
|
161
|
-
* call at block time. `keep` carries the branches we refuse to touch (no merged PR found), each with
|
|
162
|
-
* its reason, so a human can see what was spared and why.
|
|
163
|
-
*
|
|
164
|
-
* `worktrees` is the parallel verdict list for the SECOND budget (see worktrees.ts): every linked
|
|
165
|
-
* worktree, each flagged deletable or not. Both budgets are reaped from this one cache file.
|
|
166
|
-
*/
|
|
167
|
-
class MergedBranchesCache {
|
|
168
|
-
timestamp;
|
|
169
|
-
deletable;
|
|
170
|
-
keep;
|
|
171
|
-
worktrees;
|
|
172
|
-
constructor(timestamp, deletable, keep, worktrees = []) {
|
|
173
|
-
this.timestamp = timestamp;
|
|
174
|
-
this.deletable = deletable;
|
|
175
|
-
this.keep = keep;
|
|
176
|
-
this.worktrees = worktrees;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
exports.MergedBranchesCache = MergedBranchesCache;
|
|
180
54
|
/**
|
|
181
55
|
* Internal: the bulk PR lookup, indexed.
|
|
182
56
|
*
|
|
@@ -236,7 +110,7 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
236
110
|
if (!fs.existsSync(cachePath))
|
|
237
111
|
return null;
|
|
238
112
|
const raw = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
239
|
-
return new MergedBranchesCache(raw.timestamp ?? '', this.reviveList(raw.deletable), this.reviveList(raw.keep), this.reviveWorktrees(raw.worktrees));
|
|
113
|
+
return new merged_branch_verdicts_1.MergedBranchesCache(raw.timestamp ?? '', this.reviveList(raw.deletable), this.reviveList(raw.keep), this.reviveWorktrees(raw.worktrees));
|
|
240
114
|
}
|
|
241
115
|
catch (err) {
|
|
242
116
|
const error = (0, to_error_1.toError)(err);
|
|
@@ -244,6 +118,39 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
244
118
|
return null;
|
|
245
119
|
}
|
|
246
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* How old is this cache, and may its numbers be quoted as fact?
|
|
123
|
+
*
|
|
124
|
+
* `now` is a parameter purely so the specs can pin it; every caller passes nothing.
|
|
125
|
+
*/
|
|
126
|
+
freshness(cache, now = Date.now()) {
|
|
127
|
+
const written = Date.parse(cache.timestamp);
|
|
128
|
+
if (Number.isNaN(written))
|
|
129
|
+
return new merged_branch_verdicts_1.CacheFreshness(true, -1, cache.timestamp);
|
|
130
|
+
const ageMs = Math.max(0, now - written);
|
|
131
|
+
const ageMinutes = Math.floor(ageMs / 60000);
|
|
132
|
+
return new merged_branch_verdicts_1.CacheFreshness(ageMs > merged_branch_verdicts_1.CACHE_STALE_AFTER_MS, ageMinutes, cache.timestamp);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Drop every cached verdict whose subject no longer exists, using only LOCAL, instant git reads.
|
|
136
|
+
*
|
|
137
|
+
* The verdicts themselves are NOT re-derived here — that is the detached refresher's job and it
|
|
138
|
+
* needs the network. This answers a different and much cheaper question: does this branch / this
|
|
139
|
+
* worktree still exist at all? A cache written before a `git branch -D` or a `git worktree remove`
|
|
140
|
+
* keeps naming things that are gone, and a reader that counts those entries reports a repo that
|
|
141
|
+
* does not exist (observed: 8 parked branches asserted against an actual 1) and then blocks on it.
|
|
142
|
+
*
|
|
143
|
+
* Fails toward the cache: if git cannot answer (empty branch list), the cache is returned untouched
|
|
144
|
+
* rather than emptied, since "git failed" must never read as "everything was deleted".
|
|
145
|
+
*/
|
|
146
|
+
reconcile(repoRoot, cache) {
|
|
147
|
+
const live = new Set(this.localBranches(repoRoot));
|
|
148
|
+
const trees = this.worktrees.listWorktrees(repoRoot);
|
|
149
|
+
const livePaths = new Set(trees.map((tree) => tree.path));
|
|
150
|
+
const keepBranch = (entry) => live.size === 0 || live.has(entry.branch);
|
|
151
|
+
const keepTree = (entry) => trees.length === 0 || livePaths.has(entry.path);
|
|
152
|
+
return new merged_branch_verdicts_1.MergedBranchesCache(cache.timestamp, cache.deletable.filter(keepBranch), cache.keep.filter(keepBranch), cache.worktrees.filter(keepTree));
|
|
153
|
+
}
|
|
247
154
|
writeMergedBranches(repoRoot, cache) {
|
|
248
155
|
const cachePath = this.mergedBranchesPath(repoRoot);
|
|
249
156
|
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
|
|
@@ -295,7 +202,7 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
295
202
|
// worktree list below is what actually reaps it, and a human should see the connection.
|
|
296
203
|
const heldAt = holder.get(branch);
|
|
297
204
|
if (heldAt !== undefined) {
|
|
298
|
-
keep.push(new DeletableBranch(branch, `checked out in worktree '${heldAt}' — remove that worktree before deleting the branch`, verdict.entry.pr, verdict.entry.sha, verdict.entry.commits, verdict.entry.prState,
|
|
205
|
+
keep.push(new merged_branch_verdicts_1.DeletableBranch(branch, `checked out in worktree '${heldAt}' — remove that worktree before deleting the branch`, verdict.entry.pr, verdict.entry.sha, verdict.entry.commits, verdict.entry.prState, merged_branch_verdicts_1.CLASSIFICATION_IN_USE));
|
|
299
206
|
continue;
|
|
300
207
|
}
|
|
301
208
|
if (verdict.deletable)
|
|
@@ -304,17 +211,18 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
304
211
|
keep.push(verdict.entry);
|
|
305
212
|
}
|
|
306
213
|
const worktrees = this.classifyWorktrees(repoRoot, trees, prs);
|
|
307
|
-
return new MergedBranchesCache(new Date().toISOString(), deletable, keep, worktrees);
|
|
214
|
+
return new merged_branch_verdicts_1.MergedBranchesCache(new Date().toISOString(), deletable, keep, worktrees);
|
|
308
215
|
}
|
|
309
216
|
/**
|
|
310
217
|
* Verdicts for the worktree budget. The main worktree is excluded outright — it is the primary
|
|
311
218
|
* clone and is not a thing you can remove.
|
|
312
219
|
*
|
|
313
220
|
* A worktree is deletable when its directory is already gone (`prunable`), or when its branch is
|
|
314
|
-
* dead by the very same
|
|
315
|
-
*
|
|
316
|
-
* are standing in right now (removing your own cwd is not a thing to suggest to an
|
|
317
|
-
* its branch
|
|
221
|
+
* dead by the very same proof the branch cap uses — a MERGED PR, its own or that of the branch it
|
|
222
|
+
* snapshots. Nothing else. It is spared when it is LOCKED (a human said "do not touch"), when it is
|
|
223
|
+
* the worktree we are standing in right now (removing your own cwd is not a thing to suggest to an
|
|
224
|
+
* agent), or when its branch is anything short of provably merged — INCLUDING a branch with no
|
|
225
|
+
* commits yet, which is what every worktree looks like while an agent is working in it.
|
|
318
226
|
*/
|
|
319
227
|
classifyWorktrees(repoRoot, trees, prs) {
|
|
320
228
|
const out = [];
|
|
@@ -322,26 +230,26 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
322
230
|
if (tree.isMain)
|
|
323
231
|
continue;
|
|
324
232
|
if (tree.prunable) {
|
|
325
|
-
out.push(new DeletableWorktree(tree.path, tree.branch, 'its directory is gone — `git worktree prune` clears it', 0, true,
|
|
233
|
+
out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'its directory is gone — `git worktree prune` clears it', 0, true, merged_branch_verdicts_1.CLASSIFICATION_PRUNABLE));
|
|
326
234
|
continue;
|
|
327
235
|
}
|
|
328
236
|
if (tree.locked) {
|
|
329
|
-
out.push(new DeletableWorktree(tree.path, tree.branch, 'locked by a human — do not touch', 0, false,
|
|
237
|
+
out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'locked by a human — do not touch', 0, false, merged_branch_verdicts_1.CLASSIFICATION_LOCKED));
|
|
330
238
|
continue;
|
|
331
239
|
}
|
|
332
240
|
if (tree.path === repoRoot) {
|
|
333
|
-
out.push(new DeletableWorktree(tree.path, tree.branch, 'you are standing in it', 0, false,
|
|
241
|
+
out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, 'you are standing in it', 0, false, merged_branch_verdicts_1.CLASSIFICATION_CURRENT));
|
|
334
242
|
continue;
|
|
335
243
|
}
|
|
336
244
|
if (tree.branch === '') {
|
|
337
|
-
out.push(new DeletableWorktree(tree.path, '', 'detached HEAD — no branch to check, so a human must decide', 0, false,
|
|
245
|
+
out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, '', 'detached HEAD — no branch to check, so a human must decide', 0, false, merged_branch_verdicts_1.CLASSIFICATION_DETACHED));
|
|
338
246
|
continue;
|
|
339
247
|
}
|
|
340
248
|
// The branch's OWN classification token rides along unchanged. That is what lets wp-cleanup
|
|
341
249
|
// group probably-dead worktrees exactly the way it groups probably-dead branches, instead of
|
|
342
250
|
// inventing a second, parallel notion of "how dead is this".
|
|
343
251
|
const verdict = this.classify(repoRoot, tree.branch, prs);
|
|
344
|
-
out.push(new DeletableWorktree(tree.path, tree.branch, verdict.entry.reason, verdict.entry.pr, verdict.deletable, verdict.entry.classification));
|
|
252
|
+
out.push(new merged_branch_verdicts_1.DeletableWorktree(tree.path, tree.branch, verdict.entry.reason, verdict.entry.pr, verdict.deletable, verdict.entry.classification));
|
|
345
253
|
}
|
|
346
254
|
return out;
|
|
347
255
|
}
|
|
@@ -360,21 +268,24 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
360
268
|
const prState = known ? known.state : '';
|
|
361
269
|
const own = prs.merged.get(branch);
|
|
362
270
|
if (own !== undefined) {
|
|
363
|
-
return new Verdict(true, new DeletableBranch(branch, `PR #${String(own)} merged`, own, sha, commits, prState || 'MERGED',
|
|
271
|
+
return new Verdict(true, new merged_branch_verdicts_1.DeletableBranch(branch, `PR #${String(own)} merged`, own, sha, commits, prState || 'MERGED', merged_branch_verdicts_1.CLASSIFICATION_MERGED_PR));
|
|
364
272
|
}
|
|
365
273
|
const base = branch.replace(BACKUP_SUFFIX, '');
|
|
366
274
|
if (base !== branch && base.length > 0) {
|
|
367
275
|
const basePr = prs.merged.get(base);
|
|
368
276
|
if (basePr !== undefined) {
|
|
369
|
-
return new Verdict(true, new DeletableBranch(branch, `squash-merge backup of '${base}' (PR #${String(basePr)} merged) — its job is done`, basePr, sha, commits, prState,
|
|
277
|
+
return new Verdict(true, new merged_branch_verdicts_1.DeletableBranch(branch, `squash-merge backup of '${base}' (PR #${String(basePr)} merged) — its job is done`, basePr, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_BACKUP_OF_MERGED));
|
|
370
278
|
}
|
|
371
279
|
}
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
// is
|
|
280
|
+
// Zero commits of its own. This is SPARED, not deletable — see CLASSIFICATION_NO_COMMITS for
|
|
281
|
+
// why. It is a real signal (the branch is identical to origin/main), but it is equally the
|
|
282
|
+
// signature of a worktree an agent created thirty seconds ago and has not committed in yet,
|
|
283
|
+
// and there is no git-local way to tell those two apart. So it becomes a question for a human
|
|
284
|
+
// (wp-cleanup prompts on it) instead of an unattended delete.
|
|
376
285
|
if (commits === 0) {
|
|
377
|
-
return new Verdict(
|
|
286
|
+
return new Verdict(false, new merged_branch_verdicts_1.DeletableBranch(branch, 'no commits of its own — identical to origin/main. That is either an abandoned husk OR a '
|
|
287
|
+
+ 'worktree/branch someone is working in right now and has not committed to yet, so it is '
|
|
288
|
+
+ 'never reaped unattended', 0, sha, 0, prState, merged_branch_verdicts_1.CLASSIFICATION_NO_COMMITS));
|
|
378
289
|
}
|
|
379
290
|
return new Verdict(false, this.classifySpared(repoRoot, branch, prs, sha, commits, prState));
|
|
380
291
|
}
|
|
@@ -391,20 +302,20 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
391
302
|
// A PR that CLOSED without merging, in a repo that has merged later PRs, is near-certainly the
|
|
392
303
|
// abandoned first attempt at work that landed under a different number.
|
|
393
304
|
if (known !== undefined && known.state === 'CLOSED' && prs.latestMergedPr > known.number) {
|
|
394
|
-
return new DeletableBranch(branch, `PR #${String(known.number)} was CLOSED UNMERGED and later PRs (through ` +
|
|
395
|
-
`#${String(prs.latestMergedPr)}) have merged — near-certainly superseded`, known.number, sha, commits, prState,
|
|
305
|
+
return new merged_branch_verdicts_1.DeletableBranch(branch, `PR #${String(known.number)} was CLOSED UNMERGED and later PRs (through ` +
|
|
306
|
+
`#${String(prs.latestMergedPr)}) have merged — near-certainly superseded`, known.number, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_SUPERSEDED);
|
|
396
307
|
}
|
|
397
308
|
// `git cherry` compares by PATCH-ID, so it survives cherry-picks and rebases. It does NOT
|
|
398
309
|
// survive a squash — which is exactly why this is a spared verdict and not a deletable proof.
|
|
399
310
|
if (this.contentAlreadyInMain(repoRoot, branch)) {
|
|
400
|
-
return new DeletableBranch(branch, `all ${String(commits)} commit(s) already have an equivalent in origin/main ` +
|
|
401
|
-
`(git cherry) — content is not unique`, known ? known.number : 0, sha, commits, prState,
|
|
311
|
+
return new merged_branch_verdicts_1.DeletableBranch(branch, `all ${String(commits)} commit(s) already have an equivalent in origin/main ` +
|
|
312
|
+
`(git cherry) — content is not unique`, known ? known.number : 0, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_CONTENT_IN_MAIN);
|
|
402
313
|
}
|
|
403
314
|
const why = known
|
|
404
315
|
? `PR #${String(known.number)} is ${known.state} (not merged); holds ` +
|
|
405
316
|
`${String(commits)} unique commit(s) — a human must decide`
|
|
406
317
|
: `never had a PR; holds ${String(commits)} unique commit(s) that may be the only copy in existence`;
|
|
407
|
-
return new DeletableBranch(branch, why, 0, sha, commits, prState,
|
|
318
|
+
return new merged_branch_verdicts_1.DeletableBranch(branch, why, 0, sha, commits, prState, merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED);
|
|
408
319
|
}
|
|
409
320
|
/**
|
|
410
321
|
* True when EVERY commit on the branch has a patch-equivalent already in origin/main.
|
|
@@ -463,7 +374,7 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
463
374
|
const branch = entry.headRefName ?? '';
|
|
464
375
|
const pr = entry.number ?? 0;
|
|
465
376
|
if (branch !== '' && pr > 0)
|
|
466
|
-
out.push(new MergedBranch(branch, pr));
|
|
377
|
+
out.push(new merged_branch_verdicts_1.MergedBranch(branch, pr));
|
|
467
378
|
}
|
|
468
379
|
return out;
|
|
469
380
|
}
|
|
@@ -512,12 +423,12 @@ let MergedBranchesService = class MergedBranchesService {
|
|
|
512
423
|
reviveList(raw) {
|
|
513
424
|
if (!raw)
|
|
514
425
|
return [];
|
|
515
|
-
return raw.map((entry) => new DeletableBranch(entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.sha ?? '', entry.commits ?? -1, entry.prState ?? '', entry.classification ??
|
|
426
|
+
return raw.map((entry) => new merged_branch_verdicts_1.DeletableBranch(entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.sha ?? '', entry.commits ?? -1, entry.prState ?? '', entry.classification ?? merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED));
|
|
516
427
|
}
|
|
517
428
|
reviveWorktrees(raw) {
|
|
518
429
|
if (!raw)
|
|
519
430
|
return [];
|
|
520
|
-
return raw.map((entry) => new DeletableWorktree(entry.path ?? '', entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.deletable ?? false, entry.classification ??
|
|
431
|
+
return raw.map((entry) => new merged_branch_verdicts_1.DeletableWorktree(entry.path ?? '', entry.branch ?? '', entry.reason ?? '', entry.pr ?? 0, entry.deletable ?? false, entry.classification ?? merged_branch_verdicts_1.CLASSIFICATION_NEVER_PROPOSED));
|
|
521
432
|
}
|
|
522
433
|
// Run a command capturing trimmed stdout; ok=false on spawn failure or non-zero exit.
|
|
523
434
|
capture(repoRoot, cmd, args) {
|