@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,108 @@
1
+ import { applyPlan } from "../core/apply.js";
2
+ import { buildPlan } from "../core/plan.js";
3
+ import { renderApply, renderPlan } from "../core/render.js";
4
+ import { color } from "../util/color.js";
5
+ import { formatKb } from "../util/bytes.js";
6
+ import { confirm } from "../util/prompt.js";
7
+ import { acquireLock } from "../daemon/lock.js";
8
+ import { recordSweep } from "../daemon/state.js";
9
+ import type { CliFlags } from "./context.js";
10
+ import { createContext, resolveRepos } from "./context.js";
11
+
12
+ export async function gcCommand(positionals: string[], flags: CliFlags): Promise<number> {
13
+ const ctx = await createContext(flags);
14
+ const repos = await resolveRepos(ctx, positionals, flags);
15
+
16
+ if (repos.length === 0) {
17
+ if (flags.json) {
18
+ process.stdout.write(JSON.stringify({ version: 1, repos: [], error: "no git repos found" }) + "\n");
19
+ } else {
20
+ process.stderr.write(
21
+ `no git repos found. Looked in ${ctx.config.roots.join(", ")}.\n` +
22
+ `Set "roots" in your config, pass a path, or run from inside a repo.\n`,
23
+ );
24
+ }
25
+ return 1;
26
+ }
27
+
28
+ const plan = await buildPlan({
29
+ config: ctx.config,
30
+ repos,
31
+ forge: ctx.forge,
32
+ logger: ctx.logger,
33
+ force: flags.force,
34
+ allSizes: flags.sizes,
35
+ skipWorktrees: flags.branchesOnly,
36
+ });
37
+
38
+ if (!flags.apply) {
39
+ if (flags.json) {
40
+ process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
41
+ } else {
42
+ const text = renderPlan(plan, { verbose: flags.verbose, quiet: flags.quiet });
43
+ process.stdout.write(text + "\n");
44
+ if (plan.summary.worktreesReclaimable > 0 || plan.summary.branchesDeletable > 0) {
45
+ process.stdout.write(color.gray("re-run with --apply to do it\n"));
46
+ }
47
+ }
48
+ return 0;
49
+ }
50
+
51
+ const nothingToDo =
52
+ plan.summary.worktreesReclaimable === 0 && plan.summary.branchesDeletable === 0;
53
+
54
+ if (!nothingToDo && !flags.yes) {
55
+ if (!flags.json) {
56
+ process.stdout.write(renderPlan(plan, { verbose: flags.verbose }) + "\n\n");
57
+ }
58
+ const ok = await confirm(
59
+ `Remove ${plan.summary.worktreesReclaimable} worktree(s) (${formatKb(plan.summary.reclaimKb)}) ` +
60
+ `and ${plan.summary.branchesDeletable} branch(es)?`,
61
+ false,
62
+ );
63
+ if (!ok) {
64
+ process.stderr.write("aborted — nothing was removed\n");
65
+ return 130;
66
+ }
67
+ }
68
+
69
+ // One sweep at a time per machine: a scheduled sweep and a manual one must
70
+ // never race over the same worktree.
71
+ const lock = await acquireLock();
72
+ if (!lock) {
73
+ const message = "another treekeeper sweep is already running";
74
+ if (flags.json) process.stdout.write(JSON.stringify({ error: message }) + "\n");
75
+ else process.stderr.write(message + "\n");
76
+ return 75;
77
+ }
78
+
79
+ const startedAt = Date.now();
80
+ let result;
81
+ try {
82
+ result = await applyPlan({
83
+ plan,
84
+ config: ctx.config,
85
+ logger: ctx.logger,
86
+ force: flags.force,
87
+ });
88
+ } finally {
89
+ await lock.release();
90
+ }
91
+
92
+ await recordSweep({
93
+ at: startedAt,
94
+ durationMs: Date.now() - startedAt,
95
+ repos: plan.summary.repos,
96
+ worktrees: result.removedWorktrees.length,
97
+ branches: result.deletedBranches.length,
98
+ reclaimedKb: result.reclaimedKb,
99
+ failures: result.failures.length,
100
+ });
101
+
102
+ if (flags.json) {
103
+ process.stdout.write(JSON.stringify({ plan, result }, null, 2) + "\n");
104
+ } else {
105
+ process.stdout.write(renderApply(result, plan) + "\n");
106
+ }
107
+ return result.failures.some((f) => f.stage === "archive") ? 1 : 0;
108
+ }
@@ -0,0 +1,68 @@
1
+ import { buildPlan } from "../core/plan.js";
2
+ import { renderPlan } from "../core/render.js";
3
+ import { listArchives } from "../core/archive.js";
4
+ import { discoverRepos } from "../core/scan.js";
5
+ import { daemonStatus } from "../daemon/service.js";
6
+ import { formatKb } from "../util/bytes.js";
7
+ import { color } from "../util/color.js";
8
+ import { shortenPath } from "../util/paths.js";
9
+ import { formatAgo } from "../util/time.js";
10
+ import type { CliFlags } from "./context.js";
11
+ import { createContext } from "./context.js";
12
+
13
+ /** A whole-machine overview: what is reclaimable, and is the daemon running. */
14
+ export async function statusCommand(flags: CliFlags): Promise<number> {
15
+ const ctx = await createContext({ ...flags, quiet: true });
16
+ const repos = await discoverRepos({
17
+ roots: ctx.config.roots,
18
+ maxDepth: ctx.config.maxDepth,
19
+ protectPaths: ctx.config.protectPaths,
20
+ logger: ctx.logger,
21
+ });
22
+
23
+ const plan = await buildPlan({
24
+ config: ctx.config,
25
+ repos,
26
+ forge: ctx.forge,
27
+ logger: ctx.logger,
28
+ });
29
+
30
+ const service = await daemonStatus();
31
+ const archives = await listArchives(ctx.config.archive.dir);
32
+
33
+ if (flags.json) {
34
+ process.stdout.write(
35
+ JSON.stringify({ plan, daemon: service, archives: archives.length }, null, 2) + "\n",
36
+ );
37
+ return 0;
38
+ }
39
+
40
+ const lines: string[] = [];
41
+ lines.push(
42
+ `${color.bold("treekeeper")} ` +
43
+ color.gray(`${plan.summary.repos} repos · ${plan.summary.worktreesScanned} worktrees · roots ${ctx.config.roots.map(shortenPath).join(", ")}`),
44
+ );
45
+ lines.push(
46
+ service.installed
47
+ ? `daemon ${service.active ? color.green("scheduled") : color.yellow("installed, not loaded")}` +
48
+ color.gray(` every ${ctx.config.interval}`)
49
+ : `daemon ${color.gray("not installed")} ${color.gray("run `treekeeper daemon install`")}`,
50
+ );
51
+ if (service.lastSweep) {
52
+ lines.push(`last sweep ${formatAgo(service.lastSweep.at)} ` + color.gray(
53
+ `reclaimed ${formatKb(service.lastSweep.reclaimedKb)} from ${service.lastSweep.worktrees} worktree(s)`,
54
+ ));
55
+ }
56
+ if (archives.length > 0) {
57
+ const newest = archives[0]!;
58
+ lines.push(
59
+ `archives ${archives.length} kept in ${shortenPath(ctx.config.archive.dir)} ` +
60
+ color.gray(`newest ${formatAgo(Date.parse(newest.createdAt))}, retention ${ctx.config.archive.retention}`),
61
+ );
62
+ }
63
+ lines.push("");
64
+ lines.push(renderPlan(plan, { verbose: flags.verbose }));
65
+
66
+ process.stdout.write(lines.join("\n") + "\n");
67
+ return 0;
68
+ }
@@ -0,0 +1,48 @@
1
+ import { color } from "../util/color.js";
2
+ import { formatDuration, parseDuration } from "../util/time.js";
3
+ import { loadConfig } from "../core/config.js";
4
+ import type { CliFlags } from "./context.js";
5
+ import { gcCommand } from "./gc.js";
6
+
7
+ /**
8
+ * Foreground watcher, for people who would rather supervise treekeeper
9
+ * themselves (tmux, pm2, Docker) than install a launchd/systemd unit.
10
+ *
11
+ * Config is re-read every pass, so editing config.json takes effect without a
12
+ * restart.
13
+ */
14
+ export async function watchCommand(flags: CliFlags): Promise<number> {
15
+ let stopping = false;
16
+ const stop = () => {
17
+ stopping = true;
18
+ };
19
+ process.on("SIGINT", stop);
20
+ process.on("SIGTERM", stop);
21
+
22
+ const { config } = await loadConfig();
23
+ const firstInterval = parseDuration(flags.interval ?? config.interval);
24
+ process.stderr.write(
25
+ `${color.bold("treekeeper")} watching ${config.roots.join(", ")} every ${formatDuration(firstInterval)}\n` +
26
+ color.gray("ctrl-c to stop\n"),
27
+ );
28
+
29
+ while (!stopping) {
30
+ await gcCommand([], { ...flags, all: true, apply: true, yes: true });
31
+ if (stopping) break;
32
+
33
+ const { config: fresh } = await loadConfig();
34
+ const intervalMs = parseDuration(flags.interval ?? fresh.interval);
35
+ // Wake up often enough to notice a signal promptly.
36
+ const deadline = Date.now() + intervalMs;
37
+ while (!stopping && Date.now() < deadline) {
38
+ await sleep(Math.min(1_000, deadline - Date.now()));
39
+ }
40
+ }
41
+
42
+ process.stderr.write("stopped\n");
43
+ return 0;
44
+ }
45
+
46
+ function sleep(ms: number): Promise<void> {
47
+ return new Promise((r) => setTimeout(r, Math.max(0, ms)));
48
+ }
@@ -0,0 +1,276 @@
1
+ import { stat } from "node:fs/promises";
2
+ import type { Config } from "./config.js";
3
+ import { loadRepoConfig } from "./config.js";
4
+ import type { ArchiveRecord } from "./archive.js";
5
+ import { archiveWork, pruneArchives } from "./archive.js";
6
+ import * as git from "./git.js";
7
+ import type { BranchCandidate, Plan, WorktreeCandidate } from "./plan.js";
8
+ import type { Logger } from "../util/log.js";
9
+ import { parseDuration } from "../util/time.js";
10
+
11
+ export interface ApplyOptions {
12
+ plan: Plan;
13
+ config: Config;
14
+ logger: Logger;
15
+ force?: boolean;
16
+ /** Skip the just-in-time safety re-check (only for tests). */
17
+ skipReverify?: boolean;
18
+ }
19
+
20
+ export interface Removal {
21
+ path: string;
22
+ branch: string | null;
23
+ repo: string;
24
+ sizeKb: number;
25
+ reason: string;
26
+ archive: string | null;
27
+ }
28
+
29
+ export interface Failure {
30
+ target: string;
31
+ stage: "reverify" | "archive" | "remove" | "branch";
32
+ message: string;
33
+ }
34
+
35
+ export interface ApplyResult {
36
+ removedWorktrees: Removal[];
37
+ deletedBranches: Array<{ repo: string; name: string; reason: string; archive: string | null }>;
38
+ reclaimedKb: number;
39
+ archives: ArchiveRecord[];
40
+ failures: Failure[];
41
+ prunedArchives: string[];
42
+ }
43
+
44
+ /**
45
+ * Carry out a plan.
46
+ *
47
+ * Each worktree is re-checked immediately before removal, because a plan is a
48
+ * snapshot: with a daemon, minutes can pass between deciding and acting, and in
49
+ * that time an agent may have started working in the directory again.
50
+ */
51
+ export async function applyPlan(opts: ApplyOptions): Promise<ApplyResult> {
52
+ const { plan, logger } = opts;
53
+ const result: ApplyResult = {
54
+ removedWorktrees: [],
55
+ deletedBranches: [],
56
+ reclaimedKb: 0,
57
+ archives: [],
58
+ failures: [],
59
+ prunedArchives: [],
60
+ };
61
+
62
+ for (const repoPlan of plan.repos) {
63
+ if (repoPlan.skipped) continue;
64
+ const { config: cfg } = await loadRepoConfig(repoPlan.repo, opts.config);
65
+ const base = await preferredBase(repoPlan.repo, repoPlan.defaultBranch);
66
+
67
+ for (const candidate of repoPlan.worktrees) {
68
+ if (candidate.verdict.action !== "reclaim") continue;
69
+ await removeWorktree(candidate, { cfg, base, result, logger, opts });
70
+ }
71
+
72
+ for (const branch of repoPlan.branches) {
73
+ await deleteBranch(branch, { cfg, base, result, logger });
74
+ }
75
+
76
+ await git.pruneWorktrees(repoPlan.repo);
77
+ if (cfg.pruneRemotes) await git.remotePrune(repoPlan.repo);
78
+ }
79
+
80
+ if (opts.config.archive.enabled) {
81
+ const pruned = await pruneArchives(
82
+ opts.config.archive.dir,
83
+ parseDuration(opts.config.archive.retention),
84
+ plan.now,
85
+ );
86
+ result.prunedArchives = pruned.removed;
87
+ if (pruned.removed.length > 0) {
88
+ logger.event("archive-pruned", { count: pruned.removed.length, freedKb: pruned.freedKb });
89
+ }
90
+ }
91
+
92
+ return result;
93
+ }
94
+
95
+ interface StepCtx {
96
+ cfg: Config;
97
+ base: string | null;
98
+ result: ApplyResult;
99
+ logger: Logger;
100
+ opts?: ApplyOptions;
101
+ }
102
+
103
+ async function removeWorktree(c: WorktreeCandidate, ctx: StepCtx): Promise<void> {
104
+ const { logger, result } = ctx;
105
+
106
+ if (!(ctx.opts?.skipReverify ?? false)) {
107
+ const problem = await reverify(c);
108
+ if (problem) {
109
+ logger.warn(`skipping ${c.path}: ${problem}`);
110
+ logger.event("skip", { path: c.path, branch: c.branch, stage: "reverify", reason: problem });
111
+ result.failures.push({ target: c.path, stage: "reverify", message: problem });
112
+ return;
113
+ }
114
+ }
115
+
116
+ let archive: ArchiveRecord | null = null;
117
+ try {
118
+ archive = await archiveWork({
119
+ repo: c.repo,
120
+ worktreePath: c.path,
121
+ branch: c.branch,
122
+ head: c.head,
123
+ reason: c.verdict.reason,
124
+ risks: c.verdict.risks,
125
+ merge: c.merge,
126
+ base: ctx.base,
127
+ config: ctx.cfg.archive,
128
+ logger,
129
+ });
130
+ } catch (err) {
131
+ const message = (err as Error).message;
132
+ // Never remove work we failed to back up.
133
+ logger.error(`archive failed for ${c.path}, keeping it: ${message}`);
134
+ result.failures.push({ target: c.path, stage: "archive", message });
135
+ return;
136
+ }
137
+ if (archive) result.archives.push(archive);
138
+
139
+ const archived = archive !== null;
140
+ let removal = await git.removeWorktree(c.repo, c.path, false);
141
+ if (!removal.ok && (archived || ctx.opts?.force)) {
142
+ removal = await git.removeWorktree(c.repo, c.path, true);
143
+ }
144
+
145
+ if (!removal.ok) {
146
+ const message = removal.error ?? "git worktree remove failed";
147
+ logger.error(`could not remove ${c.path}: ${message}`);
148
+ result.failures.push({ target: c.path, stage: "remove", message });
149
+ return;
150
+ }
151
+
152
+ const sizeKb = c.sizeKb ?? 0;
153
+ result.removedWorktrees.push({
154
+ path: c.path,
155
+ branch: c.branch,
156
+ repo: c.repo,
157
+ sizeKb,
158
+ reason: c.verdict.reason,
159
+ archive: archive?.dir ?? null,
160
+ });
161
+ result.reclaimedKb += sizeKb;
162
+ logger.event("reclaim", {
163
+ path: c.path,
164
+ repo: c.repo,
165
+ branch: c.branch,
166
+ sizeKb,
167
+ reason: c.verdict.reason,
168
+ risks: c.verdict.risks,
169
+ archive: archive?.dir ?? null,
170
+ });
171
+
172
+ if (c.branch) {
173
+ const ok = await git.deleteBranch(c.repo, c.branch, true);
174
+ if (ok) {
175
+ result.deletedBranches.push({
176
+ repo: c.repo,
177
+ name: c.branch,
178
+ reason: c.verdict.reason,
179
+ archive: archive?.dir ?? null,
180
+ });
181
+ } else {
182
+ logger.warn(`removed ${c.path} but could not delete branch ${c.branch}`);
183
+ result.failures.push({
184
+ target: `${c.repo}#${c.branch}`,
185
+ stage: "branch",
186
+ message: "git branch -D failed",
187
+ });
188
+ }
189
+ }
190
+ }
191
+
192
+ async function deleteBranch(b: BranchCandidate, ctx: StepCtx): Promise<void> {
193
+ const { logger, result } = ctx;
194
+
195
+ // The tip may have moved since the plan was built.
196
+ const tip = await git.revParse(b.repo, `refs/heads/${b.name}`);
197
+ if (tip !== b.head) {
198
+ const message = "branch tip moved since the plan was built";
199
+ logger.warn(`skipping branch ${b.name}: ${message}`);
200
+ result.failures.push({ target: `${b.repo}#${b.name}`, stage: "reverify", message });
201
+ return;
202
+ }
203
+
204
+ let archive: ArchiveRecord | null = null;
205
+ if (b.verdict.risks.length > 0) {
206
+ archive = await archiveWork({
207
+ repo: b.repo,
208
+ worktreePath: null,
209
+ branch: b.name,
210
+ head: b.head,
211
+ reason: b.verdict.reason,
212
+ risks: b.verdict.risks,
213
+ merge: b.merge,
214
+ base: ctx.base,
215
+ config: ctx.cfg.archive,
216
+ logger,
217
+ });
218
+ if (archive) result.archives.push(archive);
219
+ }
220
+
221
+ if (await git.deleteBranch(b.repo, b.name, true)) {
222
+ result.deletedBranches.push({
223
+ repo: b.repo,
224
+ name: b.name,
225
+ reason: b.verdict.reason,
226
+ archive: archive?.dir ?? null,
227
+ });
228
+ logger.event("branch-delete", {
229
+ repo: b.repo,
230
+ branch: b.name,
231
+ reason: b.verdict.reason,
232
+ archive: archive?.dir ?? null,
233
+ });
234
+ } else {
235
+ result.failures.push({
236
+ target: `${b.repo}#${b.name}`,
237
+ stage: "branch",
238
+ message: "git branch -D failed",
239
+ });
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Confirm the worktree is still exactly what the plan described. Anything that
245
+ * moved — a new commit, a switched branch, a fresh file — means the snapshot is
246
+ * stale and the worktree is left alone until the next sweep.
247
+ */
248
+ async function reverify(c: WorktreeCandidate): Promise<string | null> {
249
+ try {
250
+ const st = await stat(c.path);
251
+ if (!st.isDirectory()) return "no longer a directory";
252
+ if (st.mtimeMs > c.lastTouched + 1_000) return "directory changed since the plan was built";
253
+ } catch {
254
+ return "directory no longer exists";
255
+ }
256
+
257
+ const worktrees = await git.listWorktrees(c.repo);
258
+ const current = worktrees.find((w) => w.path === c.path);
259
+ if (!current) return "no longer a registered worktree";
260
+ if (current.branch !== c.branch) return `branch changed to ${current.branch ?? "(detached)"}`;
261
+ if (current.head !== c.head) return "new commits since the plan was built";
262
+ if (current.locked) return "worktree was locked";
263
+
264
+ if (!c.dirty) {
265
+ const dirty = await git.dirtyState(c.path);
266
+ if (dirty.dirty) return "uncommitted changes appeared since the plan was built";
267
+ }
268
+ return null;
269
+ }
270
+
271
+ async function preferredBase(repo: string, defaultBranch: string): Promise<string | null> {
272
+ for (const ref of [`refs/remotes/origin/${defaultBranch}`, `refs/heads/${defaultBranch}`]) {
273
+ if (await git.refExists(repo, ref)) return ref;
274
+ }
275
+ return null;
276
+ }