@yoavsh/treekeeper 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +237 -2
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +165 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/commands/archive.d.ts +3 -0
  9. package/dist/commands/archive.d.ts.map +1 -0
  10. package/dist/commands/archive.js +55 -0
  11. package/dist/commands/archive.js.map +1 -0
  12. package/dist/commands/config.d.ts +2 -0
  13. package/dist/commands/config.d.ts.map +1 -0
  14. package/dist/commands/config.js +54 -0
  15. package/dist/commands/config.js.map +1 -0
  16. package/dist/commands/context.d.ts +34 -0
  17. package/dist/commands/context.d.ts.map +1 -0
  18. package/dist/commands/context.js +53 -0
  19. package/dist/commands/context.js.map +1 -0
  20. package/dist/commands/daemon.d.ts +3 -0
  21. package/dist/commands/daemon.d.ts.map +1 -0
  22. package/dist/commands/daemon.js +81 -0
  23. package/dist/commands/daemon.js.map +1 -0
  24. package/dist/commands/doctor.d.ts +3 -0
  25. package/dist/commands/doctor.d.ts.map +1 -0
  26. package/dist/commands/doctor.js +119 -0
  27. package/dist/commands/doctor.js.map +1 -0
  28. package/dist/commands/gc.d.ts +3 -0
  29. package/dist/commands/gc.d.ts.map +1 -0
  30. package/dist/commands/gc.js +98 -0
  31. package/dist/commands/gc.js.map +1 -0
  32. package/dist/commands/status.d.ts +4 -0
  33. package/dist/commands/status.d.ts.map +1 -0
  34. package/dist/commands/status.js +52 -0
  35. package/dist/commands/status.js.map +1 -0
  36. package/dist/commands/watch.d.ts +10 -0
  37. package/dist/commands/watch.d.ts.map +1 -0
  38. package/dist/commands/watch.js +41 -0
  39. package/dist/commands/watch.js.map +1 -0
  40. package/dist/core/apply.d.ts +47 -0
  41. package/dist/core/apply.d.ts.map +1 -0
  42. package/dist/core/apply.js +223 -0
  43. package/dist/core/apply.js.map +1 -0
  44. package/dist/core/archive.d.ts +69 -0
  45. package/dist/core/archive.d.ts.map +1 -0
  46. package/dist/core/archive.js +202 -0
  47. package/dist/core/archive.js.map +1 -0
  48. package/dist/core/config.d.ts +69 -0
  49. package/dist/core/config.d.ts.map +1 -0
  50. package/dist/core/config.js +261 -0
  51. package/dist/core/config.js.map +1 -0
  52. package/dist/core/decide.d.ts +102 -0
  53. package/dist/core/decide.d.ts.map +1 -0
  54. package/dist/core/decide.js +148 -0
  55. package/dist/core/decide.js.map +1 -0
  56. package/dist/core/forge.d.ts +39 -0
  57. package/dist/core/forge.d.ts.map +1 -0
  58. package/dist/core/forge.js +190 -0
  59. package/dist/core/forge.js.map +1 -0
  60. package/dist/core/git.d.ts +63 -0
  61. package/dist/core/git.d.ts.map +1 -0
  62. package/dist/core/git.js +194 -0
  63. package/dist/core/git.js.map +1 -0
  64. package/dist/core/plan.d.ts +75 -0
  65. package/dist/core/plan.d.ts.map +1 -0
  66. package/dist/core/plan.js +288 -0
  67. package/dist/core/plan.js.map +1 -0
  68. package/dist/core/render.d.ts +14 -0
  69. package/dist/core/render.d.ts.map +1 -0
  70. package/dist/core/render.js +128 -0
  71. package/dist/core/render.js.map +1 -0
  72. package/dist/core/scan.d.ts +22 -0
  73. package/dist/core/scan.d.ts.map +1 -0
  74. package/dist/core/scan.js +99 -0
  75. package/dist/core/scan.js.map +1 -0
  76. package/dist/core/sessions.d.ts +37 -0
  77. package/dist/core/sessions.d.ts.map +1 -0
  78. package/dist/core/sessions.js +300 -0
  79. package/dist/core/sessions.js.map +1 -0
  80. package/dist/core/size.d.ts +3 -0
  81. package/dist/core/size.d.ts.map +1 -0
  82. package/dist/core/size.js +11 -0
  83. package/dist/core/size.js.map +1 -0
  84. package/dist/daemon/lock.d.ts +10 -0
  85. package/dist/daemon/lock.d.ts.map +1 -0
  86. package/dist/daemon/lock.js +64 -0
  87. package/dist/daemon/lock.js.map +1 -0
  88. package/dist/daemon/service.d.ts +34 -0
  89. package/dist/daemon/service.d.ts.map +1 -0
  90. package/dist/daemon/service.js +257 -0
  91. package/dist/daemon/service.js.map +1 -0
  92. package/dist/daemon/state.d.ts +19 -0
  93. package/dist/daemon/state.d.ts.map +1 -0
  94. package/dist/daemon/state.js +32 -0
  95. package/dist/daemon/state.js.map +1 -0
  96. package/dist/index.d.ts +26 -0
  97. package/dist/index.d.ts.map +1 -0
  98. package/dist/index.js +19 -0
  99. package/dist/index.js.map +1 -0
  100. package/dist/util/bytes.d.ts +4 -0
  101. package/dist/util/bytes.d.ts.map +1 -0
  102. package/dist/util/bytes.js +14 -0
  103. package/dist/util/bytes.js.map +1 -0
  104. package/dist/util/color.d.ts +16 -0
  105. package/dist/util/color.d.ts.map +1 -0
  106. package/dist/util/color.js +32 -0
  107. package/dist/util/color.js.map +1 -0
  108. package/dist/util/exec.d.ts +27 -0
  109. package/dist/util/exec.d.ts.map +1 -0
  110. package/dist/util/exec.js +57 -0
  111. package/dist/util/exec.js.map +1 -0
  112. package/dist/util/log.d.ts +29 -0
  113. package/dist/util/log.d.ts.map +1 -0
  114. package/dist/util/log.js +72 -0
  115. package/dist/util/log.js.map +1 -0
  116. package/dist/util/paths.d.ts +18 -0
  117. package/dist/util/paths.d.ts.map +1 -0
  118. package/dist/util/paths.js +87 -0
  119. package/dist/util/paths.js.map +1 -0
  120. package/dist/util/prompt.d.ts +3 -0
  121. package/dist/util/prompt.d.ts.map +1 -0
  122. package/dist/util/prompt.js +15 -0
  123. package/dist/util/prompt.js.map +1 -0
  124. package/dist/util/redact.d.ts +15 -0
  125. package/dist/util/redact.d.ts.map +1 -0
  126. package/dist/util/redact.js +44 -0
  127. package/dist/util/redact.js.map +1 -0
  128. package/dist/util/time.d.ts +13 -0
  129. package/dist/util/time.d.ts.map +1 -0
  130. package/dist/util/time.js +77 -0
  131. package/dist/util/time.js.map +1 -0
  132. package/docs/integrations.md +128 -0
  133. package/docs/safety.md +62 -0
  134. package/package.json +69 -4
  135. package/src/cli.ts +175 -0
  136. package/src/commands/archive.ts +72 -0
  137. package/src/commands/config.ts +57 -0
  138. package/src/commands/context.ts +86 -0
  139. package/src/commands/daemon.ts +86 -0
  140. package/src/commands/doctor.ts +131 -0
  141. package/src/commands/gc.ts +108 -0
  142. package/src/commands/status.ts +68 -0
  143. package/src/commands/watch.ts +48 -0
  144. package/src/core/apply.ts +276 -0
  145. package/src/core/archive.ts +269 -0
  146. package/src/core/config.ts +339 -0
  147. package/src/core/decide.ts +230 -0
  148. package/src/core/forge.ts +243 -0
  149. package/src/core/git.ts +254 -0
  150. package/src/core/plan.ts +431 -0
  151. package/src/core/render.ts +168 -0
  152. package/src/core/scan.ts +105 -0
  153. package/src/core/sessions.ts +339 -0
  154. package/src/core/size.ts +10 -0
  155. package/src/daemon/lock.ts +65 -0
  156. package/src/daemon/service.ts +321 -0
  157. package/src/daemon/state.ts +49 -0
  158. package/src/index.ts +36 -0
  159. package/src/util/bytes.ts +11 -0
  160. package/src/util/color.ts +33 -0
  161. package/src/util/exec.ts +80 -0
  162. package/src/util/log.ts +88 -0
  163. package/src/util/paths.ts +84 -0
  164. package/src/util/prompt.ts +13 -0
  165. package/src/util/redact.ts +43 -0
  166. package/src/util/time.ts +75 -0
@@ -0,0 +1,254 @@
1
+ import { run, runLines, runOk } from "../util/exec.js";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ export interface Worktree {
5
+ path: string;
6
+ /** Short branch name, or null when detached/bare. */
7
+ branch: string | null;
8
+ head: string | null;
9
+ isMain: boolean;
10
+ detached: boolean;
11
+ bare: boolean;
12
+ locked: boolean;
13
+ lockReason: string | null;
14
+ prunable: boolean;
15
+ }
16
+
17
+ export interface LocalBranch {
18
+ name: string;
19
+ head: string;
20
+ upstream: string | null;
21
+ }
22
+
23
+ const GIT_TIMEOUT = 30_000;
24
+
25
+ async function git(cwd: string, args: string[]): Promise<string | null> {
26
+ return runOk("git", ["-C", cwd, ...args], { timeoutMs: GIT_TIMEOUT });
27
+ }
28
+
29
+ /** Top-level directory of the worktree containing `dir`, or null. */
30
+ export async function repoToplevel(dir: string): Promise<string | null> {
31
+ const out = await git(dir, ["rev-parse", "--show-toplevel"]);
32
+ return out ? resolve(out) : null;
33
+ }
34
+
35
+ /**
36
+ * The *main* worktree of the repo containing `dir`. Given a linked worktree this
37
+ * returns the original clone, which is where worktree and branch operations have
38
+ * to run.
39
+ */
40
+ export async function mainRepoPath(dir: string): Promise<string | null> {
41
+ const common = await git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
42
+ if (!common) return null;
43
+ const abs = resolve(common);
44
+ // ".../repo/.git" -> ".../repo"; a bare repo's common dir is the repo itself.
45
+ const base = abs.endsWith("/.git") || abs.endsWith("\\.git") ? dirname(abs) : abs;
46
+ return base;
47
+ }
48
+
49
+ export async function isGitRepo(dir: string): Promise<boolean> {
50
+ const out = await git(dir, ["rev-parse", "--is-inside-work-tree"]);
51
+ return out === "true";
52
+ }
53
+
54
+ /** Parse `git worktree list --porcelain`. The first record is the main worktree. */
55
+ export function parseWorktreeList(porcelain: string): Worktree[] {
56
+ const out: Worktree[] = [];
57
+ let current: Worktree | null = null;
58
+
59
+ const push = () => {
60
+ if (current) out.push(current);
61
+ };
62
+
63
+ for (const rawLine of porcelain.split("\n")) {
64
+ const line = rawLine.replace(/\r$/, "");
65
+ if (line.startsWith("worktree ")) {
66
+ push();
67
+ current = {
68
+ path: resolve(line.slice("worktree ".length)),
69
+ branch: null,
70
+ head: null,
71
+ isMain: out.length === 0,
72
+ detached: false,
73
+ bare: false,
74
+ locked: false,
75
+ lockReason: null,
76
+ prunable: false,
77
+ };
78
+ } else if (!current) {
79
+ continue;
80
+ } else if (line.startsWith("HEAD ")) {
81
+ current.head = line.slice("HEAD ".length).trim();
82
+ } else if (line.startsWith("branch ")) {
83
+ current.branch = line.slice("branch ".length).replace(/^refs\/heads\//, "").trim();
84
+ } else if (line === "detached") {
85
+ current.detached = true;
86
+ } else if (line === "bare") {
87
+ current.bare = true;
88
+ } else if (line.startsWith("locked")) {
89
+ current.locked = true;
90
+ const reason = line.slice("locked".length).trim();
91
+ current.lockReason = reason === "" ? null : reason;
92
+ } else if (line.startsWith("prunable")) {
93
+ current.prunable = true;
94
+ }
95
+ }
96
+ push();
97
+ return out;
98
+ }
99
+
100
+ export async function listWorktrees(repo: string): Promise<Worktree[]> {
101
+ const out = await git(repo, ["worktree", "list", "--porcelain"]);
102
+ return out === null ? [] : parseWorktreeList(out);
103
+ }
104
+
105
+ export async function pruneWorktrees(repo: string): Promise<void> {
106
+ await git(repo, ["worktree", "prune"]);
107
+ }
108
+
109
+ /** origin/HEAD if known, else the first of main/master/trunk that exists. */
110
+ export async function defaultBranch(repo: string): Promise<string> {
111
+ const symref = await git(repo, ["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]);
112
+ if (symref) return symref.replace(/^refs\/remotes\/origin\//, "");
113
+
114
+ const remoteShow = await git(repo, ["rev-parse", "--abbrev-ref", "origin/HEAD"]);
115
+ if (remoteShow && remoteShow !== "origin/HEAD") return remoteShow.replace(/^origin\//, "");
116
+
117
+ for (const candidate of ["main", "master", "trunk"]) {
118
+ if (await refExists(repo, `refs/heads/${candidate}`)) return candidate;
119
+ }
120
+ const head = await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
121
+ return head && head !== "HEAD" ? head : "main";
122
+ }
123
+
124
+ export async function refExists(repo: string, ref: string): Promise<boolean> {
125
+ const r = await run("git", ["-C", repo, "show-ref", "--verify", "--quiet", ref], {
126
+ timeoutMs: GIT_TIMEOUT,
127
+ });
128
+ return r.code === 0;
129
+ }
130
+
131
+ export async function revParse(repo: string, ref: string): Promise<string | null> {
132
+ return git(repo, ["rev-parse", "--verify", "--quiet", ref]);
133
+ }
134
+
135
+ export async function originUrl(repo: string): Promise<string | null> {
136
+ return git(repo, ["remote", "get-url", "origin"]);
137
+ }
138
+
139
+ export async function isAncestor(repo: string, maybeAncestor: string, descendant: string): Promise<boolean> {
140
+ const r = await run(
141
+ "git",
142
+ ["-C", repo, "merge-base", "--is-ancestor", maybeAncestor, descendant],
143
+ { timeoutMs: GIT_TIMEOUT },
144
+ );
145
+ return r.code === 0;
146
+ }
147
+
148
+ /** Commits on `branch` that are not reachable from `base`. */
149
+ export async function commitsNotIn(repo: string, base: string, branch: string): Promise<string[]> {
150
+ return runLines("git", ["-C", repo, "rev-list", `${base}..${branch}`], { timeoutMs: GIT_TIMEOUT });
151
+ }
152
+
153
+ export async function localBranches(repo: string): Promise<LocalBranch[]> {
154
+ const lines = await runLines(
155
+ "git",
156
+ ["-C", repo, "for-each-ref", "--format=%(refname:short)%09%(objectname)%09%(upstream:short)", "refs/heads/"],
157
+ { timeoutMs: GIT_TIMEOUT },
158
+ );
159
+ const out: LocalBranch[] = [];
160
+ for (const line of lines) {
161
+ const [name, head, upstream] = line.split("\t");
162
+ if (!name || !head) continue;
163
+ out.push({ name, head, upstream: upstream && upstream !== "" ? upstream : null });
164
+ }
165
+ return out;
166
+ }
167
+
168
+ export interface DirtyState {
169
+ dirty: boolean;
170
+ /** Up to a few entries, for the explanation shown to the user. */
171
+ sample: string[];
172
+ count: number;
173
+ }
174
+
175
+ /** Uncommitted changes, including untracked files but not ignored ones. */
176
+ export async function dirtyState(worktreePath: string): Promise<DirtyState> {
177
+ const out = await git(worktreePath, ["status", "--porcelain", "--untracked-files=normal"]);
178
+ if (out === null) return { dirty: false, sample: [], count: 0 };
179
+ const lines = out.split("\n").filter((l) => l.trim() !== "");
180
+ return { dirty: lines.length > 0, sample: lines.slice(0, 5), count: lines.length };
181
+ }
182
+
183
+ export async function untrackedFiles(worktreePath: string): Promise<string[]> {
184
+ return runLines(
185
+ "git",
186
+ ["-C", worktreePath, "ls-files", "--others", "--exclude-standard"],
187
+ { timeoutMs: GIT_TIMEOUT },
188
+ );
189
+ }
190
+
191
+ /** Seconds-precision commit time of a ref, as epoch ms. */
192
+ export async function commitTime(repo: string, ref: string): Promise<number | null> {
193
+ const out = await git(repo, ["log", "-1", "--format=%ct", ref]);
194
+ if (!out) return null;
195
+ const n = Number(out);
196
+ return Number.isFinite(n) ? n * 1000 : null;
197
+ }
198
+
199
+ export interface RemoveResult {
200
+ ok: boolean;
201
+ method: "git" | "none";
202
+ error?: string;
203
+ }
204
+
205
+ export async function removeWorktree(
206
+ repo: string,
207
+ worktreePath: string,
208
+ force: boolean,
209
+ ): Promise<RemoveResult> {
210
+ const args = ["-C", repo, "worktree", "remove"];
211
+ if (force) args.push("--force");
212
+ args.push(worktreePath);
213
+ const r = await run("git", args, { timeoutMs: 120_000 });
214
+ if (r.code === 0) return { ok: true, method: "git" };
215
+ return { ok: false, method: "none", error: (r.stderr || r.stdout).trim() };
216
+ }
217
+
218
+ export async function deleteBranch(repo: string, branch: string, force = true): Promise<boolean> {
219
+ const r = await run("git", ["-C", repo, "branch", force ? "-D" : "-d", branch], {
220
+ timeoutMs: GIT_TIMEOUT,
221
+ });
222
+ return r.code === 0;
223
+ }
224
+
225
+ export async function remotePrune(repo: string, remote = "origin"): Promise<boolean> {
226
+ const r = await run("git", ["-C", repo, "remote", "prune", remote], { timeoutMs: 60_000 });
227
+ return r.code === 0;
228
+ }
229
+
230
+ export async function createBundle(
231
+ repo: string,
232
+ destFile: string,
233
+ revs: string[],
234
+ ): Promise<boolean> {
235
+ if (revs.length === 0) return false;
236
+ const r = await run("git", ["-C", repo, "bundle", "create", destFile, ...revs], {
237
+ timeoutMs: 180_000,
238
+ });
239
+ return r.code === 0;
240
+ }
241
+
242
+ /** `git diff HEAD --binary` for a worktree, or null when there is nothing to save. */
243
+ export async function diffAgainstHead(worktreePath: string): Promise<string | null> {
244
+ const r = await run("git", ["-C", worktreePath, "diff", "HEAD", "--binary"], {
245
+ timeoutMs: 120_000,
246
+ });
247
+ if (r.code !== 0) return null;
248
+ return r.stdout.trim() === "" ? null : r.stdout;
249
+ }
250
+
251
+ export async function gitVersion(): Promise<string | null> {
252
+ const out = await runOk("git", ["--version"], { timeoutMs: 10_000 });
253
+ return out ? out.replace(/^git version\s*/, "") : null;
254
+ }
@@ -0,0 +1,431 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import type { Config } from "./config.js";
4
+ import { loadRepoConfig } from "./config.js";
5
+ import type { BranchFacts, MergeStatus, Policy, Verdict, WorktreeFacts } from "./decide.js";
6
+ import { decideBranch, decideWorktree } from "./decide.js";
7
+ import type { Forge, PrIndex } from "./forge.js";
8
+ import { selectPr } from "./forge.js";
9
+ import * as git from "./git.js";
10
+ import type { Worktree } from "./git.js";
11
+ import { dirSizeKb } from "./size.js";
12
+ import type { ActivityContext, ActivityHit } from "./sessions.js";
13
+ import { createActivityContext, detectActivity, isActive } from "./sessions.js";
14
+ import type { Logger } from "../util/log.js";
15
+ import { isInside, matchGlob } from "../util/paths.js";
16
+ import { parseDuration } from "../util/time.js";
17
+
18
+ export interface WorktreeCandidate {
19
+ repo: string;
20
+ repoName: string;
21
+ path: string;
22
+ branch: string | null;
23
+ head: string | null;
24
+ sizeKb: number | null;
25
+ verdict: Verdict;
26
+ merge: MergeStatus;
27
+ activity: ActivityHit | null;
28
+ lastTouched: number;
29
+ dirty: boolean;
30
+ dirtyCount: number;
31
+ }
32
+
33
+ export interface BranchCandidate {
34
+ repo: string;
35
+ repoName: string;
36
+ name: string;
37
+ head: string;
38
+ verdict: Verdict;
39
+ merge: MergeStatus;
40
+ }
41
+
42
+ export interface RepoPlan {
43
+ repo: string;
44
+ name: string;
45
+ defaultBranch: string;
46
+ forgeAvailable: boolean;
47
+ worktrees: WorktreeCandidate[];
48
+ branches: BranchCandidate[];
49
+ /** Set when the repo was skipped entirely (opted out, unreadable). */
50
+ skipped: string | null;
51
+ }
52
+
53
+ export interface PlanSummary {
54
+ repos: number;
55
+ worktreesScanned: number;
56
+ worktreesReclaimable: number;
57
+ worktreesKept: number;
58
+ branchesDeletable: number;
59
+ reclaimKb: number;
60
+ }
61
+
62
+ export interface Plan {
63
+ version: 1;
64
+ generatedAt: string;
65
+ now: number;
66
+ roots: string[];
67
+ repos: RepoPlan[];
68
+ summary: PlanSummary;
69
+ warnings: string[];
70
+ }
71
+
72
+ export interface PlanOptions {
73
+ config: Config;
74
+ repos: string[];
75
+ forge: Forge;
76
+ logger?: Logger;
77
+ now?: number;
78
+ cwd?: string;
79
+ force?: boolean;
80
+ /** Measure every worktree, not just the ones being reclaimed. */
81
+ allSizes?: boolean;
82
+ /** Plan branch deletions only, leaving every worktree in place. */
83
+ skipWorktrees?: boolean;
84
+ }
85
+
86
+ export function policyFromConfig(cfg: Config, force = false): Policy {
87
+ return {
88
+ minAgeMs: parseDuration(cfg.minAge),
89
+ closedPrGraceMs: parseDuration(cfg.closedPrGrace),
90
+ onDirty: cfg.onDirty,
91
+ onClosedPr: cfg.onClosedPr,
92
+ onExtraCommits: cfg.onExtraCommits,
93
+ force,
94
+ };
95
+ }
96
+
97
+ export async function buildPlan(opts: PlanOptions): Promise<Plan> {
98
+ const now = opts.now ?? Date.now();
99
+ const cwd = opts.cwd ?? process.cwd();
100
+ const warnings: string[] = [];
101
+ const activity = await createActivityContext(opts.config, opts.logger, now);
102
+ const repos: RepoPlan[] = [];
103
+
104
+ for (const repo of opts.repos) {
105
+ const repoPlan = await planRepo(repo, { ...opts, now, cwd }, activity, warnings);
106
+ repos.push(repoPlan);
107
+ }
108
+
109
+ if (opts.forge.unavailableReason) warnings.push(opts.forge.unavailableReason);
110
+
111
+ return {
112
+ version: 1,
113
+ generatedAt: new Date(now).toISOString(),
114
+ now,
115
+ roots: opts.config.roots,
116
+ repos,
117
+ summary: summarize(repos),
118
+ warnings,
119
+ };
120
+ }
121
+
122
+ async function planRepo(
123
+ repo: string,
124
+ opts: PlanOptions & { now: number; cwd: string },
125
+ activity: ActivityContext,
126
+ warnings: string[],
127
+ ): Promise<RepoPlan> {
128
+ const name = basename(repo);
129
+ const { config: cfg, warnings: cfgWarnings } = await loadRepoConfig(repo, opts.config);
130
+ for (const w of cfgWarnings) warnings.push(w);
131
+
132
+ const empty: RepoPlan = {
133
+ repo, name, defaultBranch: "", forgeAvailable: false,
134
+ worktrees: [], branches: [], skipped: null,
135
+ };
136
+
137
+ if (!cfg.enabled) return { ...empty, skipped: "disabled in .treekeeper.json" };
138
+
139
+ const policy = policyFromConfig(cfg, opts.force ?? false);
140
+ const defaultBranch = await git.defaultBranch(repo);
141
+ const prIndex = await opts.forge.lookup(repo);
142
+ const bases = await resolveBases(repo, defaultBranch);
143
+
144
+ const worktrees = await git.listWorktrees(repo);
145
+ if (worktrees.length === 0) {
146
+ return { ...empty, defaultBranch, skipped: "not a readable git repo" };
147
+ }
148
+
149
+ const wtCandidates: WorktreeCandidate[] = [];
150
+ for (const wt of worktrees) {
151
+ if (wt.bare) continue;
152
+ if (opts.skipWorktrees) {
153
+ wtCandidates.push(skippedWorktree(repo, name, wt));
154
+ continue;
155
+ }
156
+ wtCandidates.push(
157
+ await planWorktree(repo, name, wt, {
158
+ cfg, policy, bases, prIndex, activity, now: opts.now, cwd: opts.cwd,
159
+ allSizes: opts.allSizes ?? false, logger: opts.logger,
160
+ }),
161
+ );
162
+ }
163
+
164
+ const branches = cfg.pruneBranches
165
+ ? await planBranches(repo, name, {
166
+ cfg, policy, bases, prIndex, defaultBranch, worktrees, wtCandidates, now: opts.now,
167
+ })
168
+ : [];
169
+
170
+ return {
171
+ repo,
172
+ name,
173
+ defaultBranch,
174
+ forgeAvailable: prIndex !== null,
175
+ worktrees: wtCandidates,
176
+ branches,
177
+ skipped: null,
178
+ };
179
+ }
180
+
181
+ function skippedWorktree(repo: string, repoName: string, wt: Worktree): WorktreeCandidate {
182
+ return {
183
+ repo,
184
+ repoName,
185
+ path: wt.path,
186
+ branch: wt.branch,
187
+ head: wt.head,
188
+ sizeKb: null,
189
+ verdict: wt.isMain
190
+ ? { action: "keep", reason: "the repo's main worktree", code: "main-worktree", risks: [] }
191
+ : { action: "keep", reason: "worktrees not considered (--branches-only)", code: "branches-only", risks: [] },
192
+ merge: { kind: "no-branch" },
193
+ activity: null,
194
+ lastTouched: 0,
195
+ dirty: false,
196
+ dirtyCount: 0,
197
+ };
198
+ }
199
+
200
+ interface WorktreeCtx {
201
+ cfg: Config;
202
+ policy: Policy;
203
+ bases: string[];
204
+ prIndex: PrIndex | null;
205
+ activity: ActivityContext;
206
+ now: number;
207
+ cwd: string;
208
+ allSizes: boolean;
209
+ logger?: Logger;
210
+ }
211
+
212
+ async function planWorktree(
213
+ repo: string,
214
+ repoName: string,
215
+ wt: Worktree,
216
+ ctx: WorktreeCtx,
217
+ ): Promise<WorktreeCandidate> {
218
+ const merge = wt.isMain || wt.detached || !wt.branch
219
+ ? ({ kind: "no-branch" } as MergeStatus)
220
+ : await mergeStatus(repo, wt.branch, wt.head, ctx.bases, ctx.prIndex);
221
+
222
+ // Only pay for the expensive probes when the worktree could actually go.
223
+ const cheapKeep = wt.isMain || isProtectedPath(ctx.cfg, wt.path) || wt.locked;
224
+ const finished = isFinished(merge);
225
+
226
+ let dirty = false;
227
+ let dirtyCount = 0;
228
+ let hit: ActivityHit | null = null;
229
+ let lastTouched = 0;
230
+
231
+ if (!cheapKeep) {
232
+ try {
233
+ const st = await stat(wt.path);
234
+ lastTouched = st.mtimeMs;
235
+ } catch {
236
+ lastTouched = 0;
237
+ }
238
+ if (finished) {
239
+ const d = await git.dirtyState(wt.path);
240
+ dirty = d.dirty;
241
+ dirtyCount = d.count;
242
+ hit = await detectActivity(ctx.activity, wt.path);
243
+ if (hit) lastTouched = Math.max(lastTouched, hit.lastActivity);
244
+ }
245
+ }
246
+
247
+ const facts: WorktreeFacts = {
248
+ now: ctx.now,
249
+ isMain: wt.isMain,
250
+ detached: wt.detached,
251
+ locked: wt.locked,
252
+ lockReason: wt.lockReason,
253
+ branch: wt.branch,
254
+ protectedBranch: wt.branch !== null && isProtectedBranch(ctx.cfg, wt.branch),
255
+ protectedPath: isProtectedPath(ctx.cfg, wt.path),
256
+ isCwd: isInside(wt.path, ctx.cwd),
257
+ merge,
258
+ dirty,
259
+ dirtyCount,
260
+ activity: hit,
261
+ activityIsRecent: isActive(ctx.activity, hit),
262
+ lastTouched,
263
+ inUse:
264
+ hit?.detector === "process" ||
265
+ (ctx.activity.processCwds?.some((p) => isInside(wt.path, p)) ?? false),
266
+ };
267
+
268
+ const verdict = decideWorktree(facts, ctx.policy);
269
+ const sizeKb =
270
+ verdict.action === "reclaim" || ctx.allSizes ? await dirSizeKb(wt.path) : null;
271
+
272
+ return {
273
+ repo,
274
+ repoName,
275
+ path: wt.path,
276
+ branch: wt.branch,
277
+ head: wt.head,
278
+ sizeKb,
279
+ verdict,
280
+ merge,
281
+ activity: hit,
282
+ lastTouched,
283
+ dirty,
284
+ dirtyCount,
285
+ };
286
+ }
287
+
288
+ interface BranchCtx {
289
+ cfg: Config;
290
+ policy: Policy;
291
+ bases: string[];
292
+ prIndex: PrIndex | null;
293
+ defaultBranch: string;
294
+ worktrees: Worktree[];
295
+ wtCandidates: WorktreeCandidate[];
296
+ now: number;
297
+ }
298
+
299
+ async function planBranches(
300
+ repo: string,
301
+ repoName: string,
302
+ ctx: BranchCtx,
303
+ ): Promise<BranchCandidate[]> {
304
+ // Branches whose worktree survives this sweep must survive with it. Branches
305
+ // whose worktree is being reclaimed are deleted as part of that removal, so
306
+ // they are not listed here twice.
307
+ const keptByWorktree = new Set<string>();
308
+ const removedWithWorktree = new Set<string>();
309
+ for (const c of ctx.wtCandidates) {
310
+ if (!c.branch) continue;
311
+ if (c.verdict.action === "reclaim") removedWithWorktree.add(c.branch);
312
+ else keptByWorktree.add(c.branch);
313
+ }
314
+
315
+ const mainWt = ctx.worktrees.find((w) => w.isMain);
316
+ const currentBranch = mainWt?.branch ?? null;
317
+
318
+ const out: BranchCandidate[] = [];
319
+ for (const branch of await git.localBranches(repo)) {
320
+ if (removedWithWorktree.has(branch.name)) continue;
321
+ const merge = await mergeStatus(repo, branch.name, branch.head, ctx.bases, ctx.prIndex);
322
+ const facts: BranchFacts = {
323
+ now: ctx.now,
324
+ name: branch.name,
325
+ isDefault: branch.name === ctx.defaultBranch,
326
+ protectedBranch: isProtectedBranch(ctx.cfg, branch.name),
327
+ hasWorktree: keptByWorktree.has(branch.name),
328
+ isCurrent: branch.name === currentBranch,
329
+ merge,
330
+ lastCommit: null,
331
+ };
332
+ const verdict = decideBranch(facts, ctx.policy);
333
+ if (verdict.action === "reclaim") {
334
+ out.push({ repo, repoName, name: branch.name, head: branch.head, verdict, merge });
335
+ }
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /** Candidate base refs for ancestry checks, remote first. */
341
+ async function resolveBases(repo: string, defaultBranch: string): Promise<string[]> {
342
+ const bases: string[] = [];
343
+ for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
344
+ if (await git.refExists(repo, ref)) bases.push(ref);
345
+ }
346
+ return bases;
347
+ }
348
+
349
+ export async function mergeStatus(
350
+ repo: string,
351
+ branch: string,
352
+ tip: string | null,
353
+ bases: string[],
354
+ prIndex: PrIndex | null,
355
+ ): Promise<MergeStatus> {
356
+ const head = tip ?? (await git.revParse(repo, `refs/heads/${branch}`));
357
+ if (!head) return { kind: "unmerged", forgeAvailable: prIndex !== null };
358
+
359
+ for (const base of bases) {
360
+ if (await git.isAncestor(repo, head, base)) {
361
+ return { kind: "merged-ancestor", base: base.replace(/^refs\/(remotes|heads)\//, "") };
362
+ }
363
+ }
364
+
365
+ const pr = selectPr(prIndex?.get(branch), head);
366
+ if (!pr) return { kind: "unmerged", forgeAvailable: prIndex !== null };
367
+
368
+ if (pr.state === "MERGED") {
369
+ if (pr.headRefOid === head) return { kind: "merged-pr", pr };
370
+ const base = bases[0];
371
+ const extra = base ? (await git.commitsNotIn(repo, base, branch)).length : 0;
372
+ return extra > 0 ? { kind: "merged-pr-extra", pr, extra } : { kind: "merged-pr", pr };
373
+ }
374
+ if (pr.state === "CLOSED") return { kind: "closed-pr", pr };
375
+ return { kind: "open-pr", pr };
376
+ }
377
+
378
+ function isFinished(merge: MergeStatus): boolean {
379
+ return (
380
+ merge.kind === "merged-ancestor" ||
381
+ merge.kind === "merged-pr" ||
382
+ merge.kind === "merged-pr-extra" ||
383
+ merge.kind === "closed-pr"
384
+ );
385
+ }
386
+
387
+ export function isProtectedBranch(cfg: Config, branch: string): boolean {
388
+ return cfg.protectBranches.some((p) => (p.includes("*") || p.includes("?") ? matchGlob(p, branch) : p === branch));
389
+ }
390
+
391
+ export function isProtectedPath(cfg: Config, path: string): boolean {
392
+ return cfg.protectPaths.some((p) =>
393
+ p.includes("*") || p.includes("?") ? matchGlob(p, path) : isInside(p, path),
394
+ );
395
+ }
396
+
397
+ function summarize(repos: RepoPlan[]): PlanSummary {
398
+ let worktreesScanned = 0;
399
+ let worktreesReclaimable = 0;
400
+ let worktreesKept = 0;
401
+ let branchesDeletable = 0;
402
+ let reclaimKb = 0;
403
+
404
+ for (const r of repos) {
405
+ for (const w of r.worktrees) {
406
+ if (w.verdict.code === "main-worktree") continue;
407
+ worktreesScanned += 1;
408
+ if (w.verdict.action === "reclaim") {
409
+ worktreesReclaimable += 1;
410
+ reclaimKb += w.sizeKb ?? 0;
411
+ } else {
412
+ worktreesKept += 1;
413
+ }
414
+ }
415
+ branchesDeletable += r.branches.length;
416
+ }
417
+
418
+ return {
419
+ repos: repos.filter((r) => r.skipped === null).length,
420
+ worktreesScanned,
421
+ worktreesReclaimable,
422
+ worktreesKept,
423
+ branchesDeletable,
424
+ reclaimKb,
425
+ };
426
+ }
427
+
428
+ /** Every worktree the plan would remove, flattened. */
429
+ export function reclaimable(plan: Plan): WorktreeCandidate[] {
430
+ return plan.repos.flatMap((r) => r.worktrees.filter((w) => w.verdict.action === "reclaim"));
431
+ }