@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,230 @@
1
+ import type { DirtyPolicy } from "./config.js";
2
+ import type { PrRef } from "./forge.js";
3
+ import { formatAgo, formatDuration } from "../util/time.js";
4
+
5
+ /** How the work on a branch relates to the default branch and its pull request. */
6
+ export type MergeStatus =
7
+ | { kind: "merged-ancestor"; base: string }
8
+ | { kind: "merged-pr"; pr: PrRef }
9
+ | { kind: "merged-pr-extra"; pr: PrRef; extra: number }
10
+ | { kind: "closed-pr"; pr: PrRef }
11
+ | { kind: "open-pr"; pr: PrRef }
12
+ | { kind: "unmerged"; forgeAvailable: boolean }
13
+ | { kind: "no-branch" };
14
+
15
+ export type Risk = "dirty" | "closed-pr" | "extra-commits";
16
+
17
+ export interface Verdict {
18
+ action: "reclaim" | "keep";
19
+ /** Human sentence shown in the plan and written to the log. */
20
+ reason: string;
21
+ /** Stable identifier for scripts reading `--json`. */
22
+ code: string;
23
+ /** Why this removal needs an archive first. Empty when the work is safely merged. */
24
+ risks: Risk[];
25
+ }
26
+
27
+ export interface Policy {
28
+ minAgeMs: number;
29
+ closedPrGraceMs: number;
30
+ onDirty: DirtyPolicy;
31
+ onClosedPr: DirtyPolicy;
32
+ onExtraCommits: DirtyPolicy;
33
+ /** Bypasses the age gate and the dirty / extra-commits / closed-PR holds. */
34
+ force: boolean;
35
+ }
36
+
37
+ export interface WorktreeFacts {
38
+ now: number;
39
+ isMain: boolean;
40
+ detached: boolean;
41
+ locked: boolean;
42
+ lockReason: string | null;
43
+ branch: string | null;
44
+ protectedBranch: boolean;
45
+ protectedPath: boolean;
46
+ /** The worktree contains the caller's working directory. */
47
+ isCwd: boolean;
48
+ merge: MergeStatus;
49
+ dirty: boolean;
50
+ dirtyCount: number;
51
+ /** Most recent agent/human activity, or null when nothing was detected. */
52
+ activity: { detector: string; lastActivity: number; detail: string } | null;
53
+ activityIsRecent: boolean;
54
+ /** Newest mtime seen for this worktree, used for the age gate. */
55
+ lastTouched: number;
56
+ inUse: boolean;
57
+ }
58
+
59
+ const keep = (code: string, reason: string): Verdict => ({ action: "keep", reason, code, risks: [] });
60
+
61
+ /**
62
+ * Decide the fate of one worktree.
63
+ *
64
+ * Pure on purpose: every signal is gathered elsewhere, so the rules that decide
65
+ * whether a developer's directory gets deleted are a single readable function
66
+ * with a truth table in the tests.
67
+ *
68
+ * Invariant: a worktree is only ever reclaimed when its commits are reachable
69
+ * from the default branch, or its pull request was merged or closed. `force`
70
+ * does not relax that.
71
+ */
72
+ export function decideWorktree(f: WorktreeFacts, policy: Policy): Verdict {
73
+ if (f.isMain) return keep("main-worktree", "the repo's main worktree");
74
+ if (f.protectedPath) return keep("protected-path", "matches protectPaths");
75
+ if (f.isCwd) return keep("cwd", "your current working directory");
76
+ if (f.locked) {
77
+ return keep("locked", `locked${f.lockReason ? `: ${f.lockReason}` : ""}`);
78
+ }
79
+ if (f.detached || !f.branch) return keep("detached", "detached HEAD — no branch to check");
80
+ if (f.protectedBranch) return keep("protected-branch", `${f.branch} matches protectBranches`);
81
+
82
+ const risks: Risk[] = [];
83
+ let mergedReason: string;
84
+
85
+ switch (f.merge.kind) {
86
+ case "merged-ancestor":
87
+ mergedReason = `merged into ${f.merge.base}`;
88
+ break;
89
+ case "merged-pr":
90
+ mergedReason = `PR #${f.merge.pr.number} merged`;
91
+ break;
92
+ case "merged-pr-extra": {
93
+ const n = f.merge.extra;
94
+ const detail = `PR #${f.merge.pr.number} merged, but the local branch has ${n} commit${n === 1 ? "" : "s"} the PR never had`;
95
+ if (policy.onExtraCommits === "keep" && !policy.force) {
96
+ return keep("extra-commits", detail);
97
+ }
98
+ risks.push("extra-commits");
99
+ mergedReason = detail;
100
+ break;
101
+ }
102
+ case "closed-pr": {
103
+ const detail = `PR #${f.merge.pr.number} closed without merging`;
104
+ if (policy.onClosedPr === "keep" && !policy.force) {
105
+ return keep("closed-pr", `${detail} — onClosedPr is "keep"`);
106
+ }
107
+ const closedAt = f.merge.pr.closedAt;
108
+ if (!policy.force && closedAt !== null) {
109
+ const waited = f.now - closedAt;
110
+ if (waited < policy.closedPrGraceMs) {
111
+ return keep(
112
+ "closed-pr-grace",
113
+ `${detail} ${formatAgo(closedAt, f.now)} — holding for ${formatDuration(policy.closedPrGraceMs - waited)} more`,
114
+ );
115
+ }
116
+ }
117
+ risks.push("closed-pr");
118
+ mergedReason = detail;
119
+ break;
120
+ }
121
+ case "open-pr":
122
+ return keep("open-pr", `PR #${f.merge.pr.number} is still open`);
123
+ case "unmerged":
124
+ return keep(
125
+ "unmerged",
126
+ f.merge.forgeAvailable
127
+ ? "not merged and has no merged pull request"
128
+ : "not merged (no pull-request data available)",
129
+ );
130
+ case "no-branch":
131
+ return keep("no-branch", "no branch to check");
132
+ }
133
+
134
+ // Work is finished. Now the "is anyone still using it" gates.
135
+ // A live process is checked first because it is the least ambiguous signal
136
+ // and deserves the clearest message.
137
+ if (f.inUse) {
138
+ return keep("in-use", `${mergedReason}, but a running process is still in it`);
139
+ }
140
+ if (f.activityIsRecent && f.activity) {
141
+ return keep(
142
+ "session-active",
143
+ `${mergedReason}, but ${f.activity.detector} activity ${formatAgo(f.activity.lastActivity, f.now)}`,
144
+ );
145
+ }
146
+
147
+ if (!policy.force) {
148
+ // Clamped: file mtimes carry sub-millisecond fractions while Date.now()
149
+ // truncates, so a worktree touched this instant can otherwise look like it
150
+ // was touched in the future.
151
+ const age = Math.max(0, f.now - f.lastTouched);
152
+ if (age < policy.minAgeMs) {
153
+ return keep(
154
+ "too-young",
155
+ `${mergedReason}, but touched ${formatAgo(f.lastTouched, f.now)} (minAge ${formatDuration(policy.minAgeMs)})`,
156
+ );
157
+ }
158
+ }
159
+
160
+ if (f.dirty) {
161
+ if (policy.onDirty === "keep" && !policy.force) {
162
+ return keep(
163
+ "dirty",
164
+ `${mergedReason}, but has ${f.dirtyCount} uncommitted change${f.dirtyCount === 1 ? "" : "s"}`,
165
+ );
166
+ }
167
+ risks.push("dirty");
168
+ }
169
+
170
+ return {
171
+ action: "reclaim",
172
+ reason: mergedReason,
173
+ code: f.merge.kind,
174
+ risks,
175
+ };
176
+ }
177
+
178
+ export interface BranchFacts {
179
+ now: number;
180
+ name: string;
181
+ isDefault: boolean;
182
+ protectedBranch: boolean;
183
+ /** A worktree still has this branch checked out. */
184
+ hasWorktree: boolean;
185
+ /** This branch is the current HEAD of the main worktree. */
186
+ isCurrent: boolean;
187
+ merge: MergeStatus;
188
+ lastCommit: number | null;
189
+ }
190
+
191
+ /**
192
+ * Decide whether a local branch with no worktree should be deleted.
193
+ *
194
+ * Deleting a branch only drops a pointer, so this is cheaper to get wrong than
195
+ * a worktree — but it can still orphan commits, so unmerged branches are never
196
+ * touched and closed-PR branches get archived like worktrees do.
197
+ */
198
+ export function decideBranch(f: BranchFacts, policy: Policy): Verdict {
199
+ if (f.isDefault) return keep("default-branch", "the default branch");
200
+ if (f.isCurrent) return keep("current-branch", "currently checked out");
201
+ if (f.protectedBranch) return keep("protected-branch", "matches protectBranches");
202
+ if (f.hasWorktree) return keep("has-worktree", "still checked out in a worktree");
203
+
204
+ switch (f.merge.kind) {
205
+ case "merged-ancestor":
206
+ return { action: "reclaim", reason: `merged into ${f.merge.base}`, code: f.merge.kind, risks: [] };
207
+ case "merged-pr":
208
+ return { action: "reclaim", reason: `PR #${f.merge.pr.number} merged`, code: f.merge.kind, risks: [] };
209
+ case "merged-pr-extra": {
210
+ const detail = `PR #${f.merge.pr.number} merged, local tip has ${f.merge.extra} extra commit(s)`;
211
+ if (policy.onExtraCommits === "keep" && !policy.force) return keep("extra-commits", detail);
212
+ return { action: "reclaim", reason: detail, code: f.merge.kind, risks: ["extra-commits"] };
213
+ }
214
+ case "closed-pr": {
215
+ const detail = `PR #${f.merge.pr.number} closed without merging`;
216
+ if (policy.onClosedPr === "keep" && !policy.force) return keep("closed-pr", detail);
217
+ const closedAt = f.merge.pr.closedAt;
218
+ if (!policy.force && closedAt !== null && f.now - closedAt < policy.closedPrGraceMs) {
219
+ return keep("closed-pr-grace", `${detail} ${formatAgo(closedAt, f.now)} — still in the grace window`);
220
+ }
221
+ return { action: "reclaim", reason: detail, code: f.merge.kind, risks: ["closed-pr"] };
222
+ }
223
+ case "open-pr":
224
+ return keep("open-pr", `PR #${f.merge.pr.number} is still open`);
225
+ case "unmerged":
226
+ return keep("unmerged", "not merged");
227
+ case "no-branch":
228
+ return keep("no-branch", "no branch");
229
+ }
230
+ }
@@ -0,0 +1,243 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { have, run } from "../util/exec.js";
5
+ import type { Logger } from "../util/log.js";
6
+ import { treekeeperHome } from "../util/paths.js";
7
+ import { redactRemote } from "../util/redact.js";
8
+ import { parseDuration } from "../util/time.js";
9
+ import { originUrl } from "./git.js";
10
+
11
+ export type PrState = "OPEN" | "MERGED" | "CLOSED";
12
+
13
+ export interface PrRef {
14
+ number: number;
15
+ state: PrState;
16
+ headRefName: string;
17
+ headRefOid: string;
18
+ mergedAt: number | null;
19
+ closedAt: number | null;
20
+ url: string;
21
+ title: string;
22
+ }
23
+
24
+ /** Pull requests for a repo, keyed by head branch name. */
25
+ export type PrIndex = Map<string, PrRef[]>;
26
+
27
+ export interface Forge {
28
+ readonly kind: string;
29
+ /**
30
+ * `null` means "no information available" (no remote, no CLI, offline) which
31
+ * callers must treat as "unknown", never as "no PR exists".
32
+ */
33
+ lookup(repo: string): Promise<PrIndex | null>;
34
+ readonly unavailableReason: string | null;
35
+ }
36
+
37
+ export interface ForgeOptions {
38
+ kind: "github" | "none";
39
+ cacheTtl: string;
40
+ logger?: Logger;
41
+ /** Ignore cached answers (used by `--refresh`). */
42
+ refresh?: boolean;
43
+ limit?: number;
44
+ }
45
+
46
+ export function createForge(opts: ForgeOptions): Forge {
47
+ // Not a problem worth warning about: the repo header already shows that a
48
+ // sweep ran without pull-request data.
49
+ if (opts.kind === "none") return new NullForge(null);
50
+ return new GitHubForge(opts);
51
+ }
52
+
53
+ class NullForge implements Forge {
54
+ readonly kind = "none";
55
+ readonly unavailableReason: string | null;
56
+ constructor(reason: string | null) {
57
+ this.unavailableReason = reason;
58
+ }
59
+ async lookup(): Promise<PrIndex | null> {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ interface CacheFile {
65
+ version: 1;
66
+ fetchedAt: number;
67
+ remote: string;
68
+ prs: PrRef[];
69
+ }
70
+
71
+ class GitHubForge implements Forge {
72
+ readonly kind = "github";
73
+ unavailableReason: string | null = null;
74
+ private readonly ttlMs: number;
75
+ private readonly memo = new Map<string, PrIndex | null>();
76
+ private ghChecked = false;
77
+
78
+ constructor(private readonly opts: ForgeOptions) {
79
+ this.ttlMs = parseDuration(opts.cacheTtl);
80
+ }
81
+
82
+ async lookup(repo: string): Promise<PrIndex | null> {
83
+ if (this.memo.has(repo)) return this.memo.get(repo)!;
84
+ const result = await this.fetch(repo);
85
+ this.memo.set(repo, result);
86
+ return result;
87
+ }
88
+
89
+ private async fetch(repo: string): Promise<PrIndex | null> {
90
+ const remote = await originUrl(repo);
91
+ if (!remote) {
92
+ this.opts.logger?.debug(`forge: ${repo} has no origin remote`);
93
+ return null;
94
+ }
95
+ if (!isGitHubRemote(remote)) {
96
+ this.opts.logger?.debug(`forge: ${redactRemote(remote)} is not a github remote`);
97
+ return null;
98
+ }
99
+
100
+ const cachePath = join(treekeeperHome(), "cache", `gh-${hash(remote)}.json`);
101
+ if (!this.opts.refresh) {
102
+ const cached = await readCache(cachePath);
103
+ if (cached && Date.now() - cached.fetchedAt < this.ttlMs) {
104
+ this.opts.logger?.debug(`forge: cache hit for ${redactRemote(remote)}`);
105
+ return index(cached.prs);
106
+ }
107
+ }
108
+
109
+ if (!this.ghChecked) {
110
+ this.ghChecked = true;
111
+ if (!(await have("gh"))) {
112
+ this.unavailableReason =
113
+ "the GitHub CLI (gh) is not installed, so squash-merged branches cannot be detected";
114
+ this.opts.logger?.warn(this.unavailableReason);
115
+ }
116
+ }
117
+ if (this.unavailableReason) {
118
+ const cached = await readCache(cachePath);
119
+ return cached ? index(cached.prs) : null;
120
+ }
121
+
122
+ const r = await run(
123
+ "gh",
124
+ [
125
+ "pr", "list",
126
+ "--repo", remote,
127
+ "--state", "all",
128
+ "--limit", String(this.opts.limit ?? 300),
129
+ "--json", "number,state,headRefName,headRefOid,mergedAt,closedAt,url,title",
130
+ ],
131
+ { timeoutMs: 60_000 },
132
+ );
133
+
134
+ if (r.code !== 0) {
135
+ const detail = (r.stderr || r.stdout).trim().split("\n")[0] ?? "unknown error";
136
+ this.opts.logger?.warn(`forge: gh pr list failed for ${redactRemote(remote)}: ${detail}`);
137
+ // Stale data beats no data: ancestry alone misses squash merges, and a
138
+ // missing PR record can only ever make treekeeper *more* conservative.
139
+ const cached = await readCache(cachePath);
140
+ return cached ? index(cached.prs) : null;
141
+ }
142
+
143
+ let parsed: unknown;
144
+ try {
145
+ parsed = JSON.parse(r.stdout);
146
+ } catch {
147
+ this.opts.logger?.warn(`forge: could not parse gh output for ${redactRemote(remote)}`);
148
+ return null;
149
+ }
150
+ if (!Array.isArray(parsed)) return null;
151
+
152
+ const prs = parsed.flatMap((item) => (isPrLike(item) ? [toPrRef(item)] : []));
153
+ await writeCache(cachePath, { version: 1, fetchedAt: Date.now(), remote: redactRemote(remote), prs });
154
+ this.opts.logger?.debug(`forge: fetched ${prs.length} PRs for ${redactRemote(remote)}`);
155
+ return index(prs);
156
+ }
157
+ }
158
+
159
+ function index(prs: PrRef[]): PrIndex {
160
+ const map: PrIndex = new Map();
161
+ for (const pr of prs) {
162
+ const list = map.get(pr.headRefName);
163
+ if (list) list.push(pr);
164
+ else map.set(pr.headRefName, [pr]);
165
+ }
166
+ for (const list of map.values()) list.sort((a, b) => b.number - a.number);
167
+ return map;
168
+ }
169
+
170
+ /**
171
+ * Pick the PR that decides a branch's fate. A branch name can be reused across
172
+ * several PRs, so prefer a merged PR whose head sha still matches the local tip,
173
+ * then any merged PR, then the newest.
174
+ */
175
+ export function selectPr(prs: readonly PrRef[] | undefined, localTip: string | null): PrRef | null {
176
+ if (!prs || prs.length === 0) return null;
177
+ if (localTip) {
178
+ const exact = prs.find((p) => p.headRefOid === localTip && p.state === "MERGED");
179
+ if (exact) return exact;
180
+ }
181
+ const merged = prs.find((p) => p.state === "MERGED");
182
+ if (merged) return merged;
183
+ const open = prs.find((p) => p.state === "OPEN");
184
+ if (open) return open;
185
+ return prs[0] ?? null;
186
+ }
187
+
188
+ function isGitHubRemote(remote: string): boolean {
189
+ return /github\.com/i.test(remote) || /^git@github\.com/i.test(remote);
190
+ }
191
+
192
+ function hash(s: string): string {
193
+ return createHash("sha1").update(s).digest("hex").slice(0, 16);
194
+ }
195
+
196
+ async function readCache(path: string): Promise<CacheFile | null> {
197
+ try {
198
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
199
+ if (
200
+ parsed !== null && typeof parsed === "object" &&
201
+ (parsed as CacheFile).version === 1 && Array.isArray((parsed as CacheFile).prs)
202
+ ) {
203
+ return parsed as CacheFile;
204
+ }
205
+ } catch {
206
+ /* no usable cache */
207
+ }
208
+ return null;
209
+ }
210
+
211
+ async function writeCache(path: string, data: CacheFile): Promise<void> {
212
+ try {
213
+ await mkdir(join(path, ".."), { recursive: true });
214
+ await writeFile(path, JSON.stringify(data));
215
+ } catch {
216
+ /* a cache that cannot be written is not an error */
217
+ }
218
+ }
219
+
220
+ function isPrLike(v: unknown): v is Record<string, unknown> {
221
+ return v !== null && typeof v === "object" && typeof (v as Record<string, unknown>)["number"] === "number";
222
+ }
223
+
224
+ function toPrRef(item: Record<string, unknown>): PrRef {
225
+ const stateRaw = String(item["state"] ?? "").toUpperCase();
226
+ const state: PrState = stateRaw === "MERGED" || stateRaw === "CLOSED" ? stateRaw : "OPEN";
227
+ return {
228
+ number: Number(item["number"]),
229
+ state,
230
+ headRefName: String(item["headRefName"] ?? ""),
231
+ headRefOid: String(item["headRefOid"] ?? ""),
232
+ mergedAt: toEpoch(item["mergedAt"]),
233
+ closedAt: toEpoch(item["closedAt"]),
234
+ url: String(item["url"] ?? ""),
235
+ title: String(item["title"] ?? ""),
236
+ };
237
+ }
238
+
239
+ function toEpoch(v: unknown): number | null {
240
+ if (typeof v !== "string" || v === "") return null;
241
+ const t = Date.parse(v);
242
+ return Number.isFinite(t) ? t : null;
243
+ }