@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,269 @@
1
+ import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import type { ArchiveConfig } from "./config.js";
4
+ import type { MergeStatus, Risk } from "./decide.js";
5
+ import * as git from "./git.js";
6
+ import { dirSizeKb } from "./size.js";
7
+ import { run } from "../util/exec.js";
8
+ import type { Logger } from "../util/log.js";
9
+ import { formatBytes } from "../util/bytes.js";
10
+ import { formatStamp } from "../util/time.js";
11
+
12
+ export interface ArchiveRecord {
13
+ version: 1;
14
+ id: string;
15
+ dir: string;
16
+ createdAt: string;
17
+ repo: string;
18
+ repoName: string;
19
+ worktree: string | null;
20
+ branch: string | null;
21
+ head: string | null;
22
+ reason: string;
23
+ risks: Risk[];
24
+ pr: { number: number; url: string; state: string } | null;
25
+ /** Commits that were not reachable from the default branch. */
26
+ bundle: { file: string; commits: number; revRange: string } | null;
27
+ /** Uncommitted tracked changes. */
28
+ patch: { file: string; bytes: number } | null;
29
+ untracked: { count: number; bytes: number; file: string | null; truncated: boolean } | null;
30
+ }
31
+
32
+ export interface ArchiveInput {
33
+ repo: string;
34
+ worktreePath: string | null;
35
+ branch: string | null;
36
+ head: string | null;
37
+ reason: string;
38
+ risks: Risk[];
39
+ merge: MergeStatus;
40
+ /** Base ref to diff history against, e.g. `refs/remotes/origin/main`. */
41
+ base: string | null;
42
+ config: ArchiveConfig;
43
+ logger?: Logger;
44
+ now?: number;
45
+ }
46
+
47
+ const META = "meta.json";
48
+
49
+ /**
50
+ * Save everything that would be lost, *before* anything is removed.
51
+ *
52
+ * A cleanly merged worktree has nothing unique in it, so only a small metadata
53
+ * record is written. Anything with unmerged commits, uncommitted changes or
54
+ * untracked files gets a bundle, a patch and a tarball — which is what makes
55
+ * an unattended daemon a reasonable thing to run.
56
+ */
57
+ export async function archiveWork(input: ArchiveInput): Promise<ArchiveRecord | null> {
58
+ if (!input.config.enabled) return null;
59
+
60
+ const now = input.now ?? Date.now();
61
+ const repoName = basename(input.repo);
62
+ const slug = (input.branch ?? "detached").replace(/[^a-zA-Z0-9._-]/g, "-");
63
+ const id = join(repoName, `${slug}-${formatStamp(now)}`);
64
+ const dir = join(input.config.dir, id);
65
+
66
+ await mkdir(dir, { recursive: true });
67
+
68
+ const record: ArchiveRecord = {
69
+ version: 1,
70
+ id,
71
+ dir,
72
+ createdAt: new Date(now).toISOString(),
73
+ repo: input.repo,
74
+ repoName,
75
+ worktree: input.worktreePath,
76
+ branch: input.branch,
77
+ head: input.head,
78
+ reason: input.reason,
79
+ risks: input.risks,
80
+ pr: prOf(input.merge),
81
+ bundle: null,
82
+ patch: null,
83
+ untracked: null,
84
+ };
85
+
86
+ // 1. Commits that exist nowhere else.
87
+ if (input.branch) {
88
+ const revRange = input.base ? `${input.base}..${input.branch}` : input.branch;
89
+ const commits = input.base
90
+ ? await git.commitsNotIn(input.repo, input.base, input.branch)
91
+ : [];
92
+ if (!input.base || commits.length > 0) {
93
+ const file = join(dir, "commits.bundle");
94
+ const ok = await git.createBundle(input.repo, file, [revRange]);
95
+ if (ok) {
96
+ record.bundle = { file, commits: commits.length, revRange };
97
+ } else {
98
+ input.logger?.warn(`archive: could not bundle ${revRange} for ${input.branch}`);
99
+ }
100
+ }
101
+ }
102
+
103
+ // 2. Uncommitted tracked changes.
104
+ if (input.worktreePath) {
105
+ const diff = await git.diffAgainstHead(input.worktreePath);
106
+ if (diff) {
107
+ const file = join(dir, "uncommitted.patch");
108
+ await writeFile(file, diff);
109
+ record.patch = { file, bytes: Buffer.byteLength(diff) };
110
+ }
111
+
112
+ // 3. Untracked (but not ignored) files.
113
+ const untracked = await git.untrackedFiles(input.worktreePath);
114
+ if (untracked.length > 0) {
115
+ record.untracked = await saveUntracked(
116
+ input.worktreePath, untracked, dir, input.config, input.logger,
117
+ );
118
+ }
119
+ }
120
+
121
+ await writeFile(join(dir, META), JSON.stringify(record, null, 2) + "\n");
122
+ await writeFile(join(dir, "RESTORE.md"), restoreDoc(record));
123
+ input.logger?.debug(`archive: wrote ${dir}`);
124
+ return record;
125
+ }
126
+
127
+ async function saveUntracked(
128
+ worktree: string,
129
+ files: string[],
130
+ dir: string,
131
+ cfg: ArchiveConfig,
132
+ logger?: Logger,
133
+ ): Promise<ArchiveRecord["untracked"]> {
134
+ let bytes = 0;
135
+ for (const f of files) {
136
+ try {
137
+ const st = await stat(join(worktree, f));
138
+ bytes += st.size;
139
+ } catch {
140
+ /* raced away */
141
+ }
142
+ if (bytes > cfg.maxUntrackedBytes) break;
143
+ }
144
+
145
+ const listFile = join(dir, "untracked.txt");
146
+ await writeFile(listFile, files.join("\n") + "\n");
147
+
148
+ if (bytes > cfg.maxUntrackedBytes) {
149
+ logger?.warn(
150
+ `archive: ${files.length} untracked files total ${formatBytes(bytes)}, over the ` +
151
+ `${formatBytes(cfg.maxUntrackedBytes)} limit — saved the file list only`,
152
+ );
153
+ return { count: files.length, bytes, file: listFile, truncated: true };
154
+ }
155
+
156
+ const tarball = join(dir, "untracked.tar.gz");
157
+ const r = await run("tar", ["-czf", tarball, "-C", worktree, "-T", listFile], {
158
+ timeoutMs: 300_000,
159
+ });
160
+ if (r.code !== 0) {
161
+ logger?.warn(`archive: tar failed (${(r.stderr || "").trim().split("\n")[0] ?? ""}) — kept the list`);
162
+ return { count: files.length, bytes, file: listFile, truncated: true };
163
+ }
164
+ return { count: files.length, bytes, file: tarball, truncated: false };
165
+ }
166
+
167
+ function prOf(merge: MergeStatus): ArchiveRecord["pr"] {
168
+ if ("pr" in merge && merge.pr) {
169
+ return { number: merge.pr.number, url: merge.pr.url, state: merge.pr.state };
170
+ }
171
+ return null;
172
+ }
173
+
174
+ function restoreDoc(r: ArchiveRecord): string {
175
+ const lines: string[] = [
176
+ `# Archive: ${r.branch ?? "(detached)"}`,
177
+ "",
178
+ `Removed from \`${r.worktree ?? r.repo}\` on ${r.createdAt}.`,
179
+ `Reason: ${r.reason}.`,
180
+ r.risks.length > 0 ? `Flagged: ${r.risks.join(", ")}.` : "Nothing unique was at risk.",
181
+ "",
182
+ "## Restore",
183
+ "",
184
+ "```bash",
185
+ `cd ${r.repo}`,
186
+ ];
187
+
188
+ if (r.bundle) {
189
+ lines.push(
190
+ `# ${r.bundle.commits} commit(s) that were not on the default branch`,
191
+ `git fetch ${r.bundle.file} '*:*'`,
192
+ `git branch ${r.branch ?? "restored"} ${r.head ?? "FETCH_HEAD"}`,
193
+ );
194
+ } else {
195
+ lines.push(`# all commits were already on the default branch`, `git branch ${r.branch ?? "restored"} ${r.head ?? ""}`);
196
+ }
197
+
198
+ lines.push(`git worktree add ../${basename(r.worktree ?? r.repo)} ${r.branch ?? "restored"}`);
199
+
200
+ if (r.patch) {
201
+ lines.push("", "# uncommitted changes", `git apply ${r.patch.file}`);
202
+ }
203
+ if (r.untracked && !r.untracked.truncated && r.untracked.file) {
204
+ lines.push("", "# untracked files", `tar -xzf ${r.untracked.file} -C .`);
205
+ } else if (r.untracked) {
206
+ lines.push("", `# ${r.untracked.count} untracked files were too large to copy; see untracked.txt`);
207
+ }
208
+
209
+ lines.push("```", "");
210
+ return lines.join("\n");
211
+ }
212
+
213
+ export async function listArchives(dir: string): Promise<ArchiveRecord[]> {
214
+ const out: ArchiveRecord[] = [];
215
+ let repoDirs;
216
+ try {
217
+ repoDirs = await readdir(dir, { withFileTypes: true });
218
+ } catch {
219
+ return out;
220
+ }
221
+ for (const repoDir of repoDirs) {
222
+ if (!repoDir.isDirectory()) continue;
223
+ let entries;
224
+ try {
225
+ entries = await readdir(join(dir, repoDir.name), { withFileTypes: true });
226
+ } catch {
227
+ continue;
228
+ }
229
+ for (const e of entries) {
230
+ if (!e.isDirectory()) continue;
231
+ const metaPath = join(dir, repoDir.name, e.name, META);
232
+ try {
233
+ const parsed: unknown = JSON.parse(await readFile(metaPath, "utf8"));
234
+ if (parsed && typeof parsed === "object") out.push(parsed as ArchiveRecord);
235
+ } catch {
236
+ /* an archive without readable metadata is not listed */
237
+ }
238
+ }
239
+ }
240
+ out.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
241
+ return out;
242
+ }
243
+
244
+ export interface PruneResult {
245
+ removed: string[];
246
+ freedKb: number;
247
+ }
248
+
249
+ /** Delete archives older than the retention window. */
250
+ export async function pruneArchives(
251
+ dir: string,
252
+ retentionMs: number,
253
+ now = Date.now(),
254
+ ): Promise<PruneResult> {
255
+ const result: PruneResult = { removed: [], freedKb: 0 };
256
+ for (const record of await listArchives(dir)) {
257
+ const age = now - Date.parse(record.createdAt);
258
+ if (!Number.isFinite(age) || age < retentionMs) continue;
259
+ const kb = (await dirSizeKb(record.dir)) ?? 0;
260
+ try {
261
+ await rm(record.dir, { recursive: true, force: true });
262
+ result.removed.push(record.id);
263
+ result.freedKb += kb;
264
+ } catch {
265
+ /* leave it for next time */
266
+ }
267
+ }
268
+ return result;
269
+ }
@@ -0,0 +1,339 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { expandPath, treekeeperHome } from "../util/paths.js";
4
+ import { parseDuration } from "../util/time.js";
5
+
6
+ export type DirtyPolicy = "archive" | "keep";
7
+ export type ForgeKind = "github" | "none";
8
+ export type LogLevel = "debug" | "info" | "warn" | "error";
9
+
10
+ export interface SessionCommand {
11
+ name: string;
12
+ /** Shell command; exit code 0 means "an agent is still working here". */
13
+ command: string;
14
+ }
15
+
16
+ export interface SessionConfig {
17
+ /** A worktree touched by any detector inside this window is left alone. */
18
+ grace: string;
19
+ detectors: string[];
20
+ /**
21
+ * Extra files/dirs whose mtime counts as agent activity.
22
+ * `{worktree}` is substituted with the worktree path.
23
+ */
24
+ extraPaths: string[];
25
+ commands: SessionCommand[];
26
+ }
27
+
28
+ export interface ArchiveConfig {
29
+ enabled: boolean;
30
+ dir: string;
31
+ retention: string;
32
+ /** Untracked files above this total are listed instead of copied. */
33
+ maxUntrackedBytes: number;
34
+ }
35
+
36
+ export interface Config {
37
+ /** Directories scanned for git repos (and therefore for their worktrees). */
38
+ roots: string[];
39
+ maxDepth: number;
40
+ /** A worktree must be untouched this long before it can be reclaimed. */
41
+ minAge: string;
42
+ /** A worktree whose PR was closed unmerged waits this long on top of minAge. */
43
+ closedPrGrace: string;
44
+ onDirty: DirtyPolicy;
45
+ onClosedPr: DirtyPolicy;
46
+ /** PR merged but the local branch tip has commits the PR never had. */
47
+ onExtraCommits: DirtyPolicy;
48
+ protectBranches: string[];
49
+ protectPaths: string[];
50
+ /** Also delete merged local branches that have no worktree. */
51
+ pruneBranches: boolean;
52
+ pruneRemotes: boolean;
53
+ forge: ForgeKind;
54
+ forgeCacheTtl: string;
55
+ /** Daemon sweep interval. */
56
+ interval: string;
57
+ sessions: SessionConfig;
58
+ archive: ArchiveConfig;
59
+ logFile: string;
60
+ logLevel: LogLevel;
61
+ /** Set to false in a repo's .treekeeper.json to opt that repo out entirely. */
62
+ enabled: boolean;
63
+ }
64
+
65
+ export const CONFIG_FILENAME = "config.json";
66
+ export const REPO_CONFIG_FILENAME = ".treekeeper.json";
67
+
68
+ export function defaultConfig(): Config {
69
+ const home = treekeeperHome();
70
+ return {
71
+ roots: ["~/src"],
72
+ maxDepth: 3,
73
+ minAge: "30m",
74
+ closedPrGrace: "7d",
75
+ onDirty: "archive",
76
+ onClosedPr: "archive",
77
+ onExtraCommits: "keep",
78
+ protectBranches: ["main", "master", "trunk", "develop", "dev", "release/*"],
79
+ protectPaths: [],
80
+ pruneBranches: true,
81
+ pruneRemotes: false,
82
+ forge: "github",
83
+ forgeCacheTtl: "10m",
84
+ interval: "15m",
85
+ sessions: {
86
+ grace: "20m",
87
+ detectors: ["claude-code", "codex", "aider", "process", "mtime"],
88
+ extraPaths: [],
89
+ commands: [],
90
+ },
91
+ archive: {
92
+ enabled: true,
93
+ dir: join(home, "archive"),
94
+ retention: "14d",
95
+ maxUntrackedBytes: 64 * 1024 * 1024,
96
+ },
97
+ logFile: join(home, "treekeeper.log"),
98
+ logLevel: "info",
99
+ enabled: true,
100
+ };
101
+ }
102
+
103
+ export interface LoadedConfig {
104
+ config: Config;
105
+ /** Files that contributed, in precedence order. */
106
+ sources: string[];
107
+ warnings: string[];
108
+ }
109
+
110
+ export function globalConfigPath(): string {
111
+ const override = process.env.TREEKEEPER_CONFIG;
112
+ if (override && override.trim() !== "") return expandPath(override);
113
+ return join(treekeeperHome(), CONFIG_FILENAME);
114
+ }
115
+
116
+ /** Load the user-level config, falling back to defaults when absent. */
117
+ export async function loadConfig(path = globalConfigPath()): Promise<LoadedConfig> {
118
+ const base = defaultConfig();
119
+ const warnings: string[] = [];
120
+ const sources: string[] = [];
121
+
122
+ const raw = await readJsonIfPresent(path, warnings);
123
+ if (raw !== null) {
124
+ sources.push(path);
125
+ mergeInto(base, raw, warnings, path);
126
+ }
127
+ normalize(base, warnings);
128
+ return { config: base, sources, warnings };
129
+ }
130
+
131
+ /** Layer a repo-local `.treekeeper.json` on top of an already-loaded config. */
132
+ export async function loadRepoConfig(repoPath: string, parent: Config): Promise<LoadedConfig> {
133
+ const path = join(repoPath, REPO_CONFIG_FILENAME);
134
+ const warnings: string[] = [];
135
+ const raw = await readJsonIfPresent(path, warnings);
136
+ if (raw === null) return { config: parent, sources: [], warnings };
137
+ const merged: Config = structuredClone(parent);
138
+ mergeInto(merged, raw, warnings, path);
139
+ normalize(merged, warnings);
140
+ return { config: merged, sources: [path], warnings };
141
+ }
142
+
143
+ async function readJsonIfPresent(
144
+ path: string,
145
+ warnings: string[],
146
+ ): Promise<Record<string, unknown> | null> {
147
+ let text: string;
148
+ try {
149
+ text = await readFile(path, "utf8");
150
+ } catch (err) {
151
+ const code = (err as NodeJS.ErrnoException).code;
152
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
153
+ warnings.push(`could not read ${path}: ${(err as Error).message}`);
154
+ }
155
+ return null;
156
+ }
157
+ // Tolerate `//` and `/* */` comments and trailing commas — these files get
158
+ // hand-edited, and a strict parser turns a comment into a crashed daemon.
159
+ const stripped = text
160
+ .replace(/\/\*[\s\S]*?\*\//g, "")
161
+ .replace(/(^|[^:"'\\])\/\/.*$/gm, "$1")
162
+ .replace(/,(\s*[}\]])/g, "$1");
163
+ try {
164
+ const parsed: unknown = JSON.parse(stripped);
165
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
166
+ warnings.push(`${path}: expected a JSON object, ignoring`);
167
+ return null;
168
+ }
169
+ return parsed as Record<string, unknown>;
170
+ } catch (err) {
171
+ warnings.push(`${path}: invalid JSON (${(err as Error).message}), ignoring`);
172
+ return null;
173
+ }
174
+ }
175
+
176
+ const KNOWN_KEYS = new Set<keyof Config | string>([
177
+ "roots", "maxDepth", "minAge", "closedPrGrace", "onDirty", "onClosedPr",
178
+ "onExtraCommits", "protectBranches", "protectPaths", "pruneBranches",
179
+ "pruneRemotes", "forge", "forgeCacheTtl", "interval", "sessions", "archive",
180
+ "logFile", "logLevel", "enabled", "$schema",
181
+ ]);
182
+
183
+ function mergeInto(
184
+ target: Config,
185
+ raw: Record<string, unknown>,
186
+ warnings: string[],
187
+ source: string,
188
+ ): void {
189
+ for (const key of Object.keys(raw)) {
190
+ if (!KNOWN_KEYS.has(key)) warnings.push(`${source}: unknown option "${key}"`);
191
+ }
192
+
193
+ strArray(raw, "roots", warnings, source, (v) => (target.roots = v));
194
+ num(raw, "maxDepth", warnings, source, (v) => (target.maxDepth = v));
195
+ dur(raw, "minAge", warnings, source, (v) => (target.minAge = v));
196
+ dur(raw, "closedPrGrace", warnings, source, (v) => (target.closedPrGrace = v));
197
+ enumOf(raw, "onDirty", ["archive", "keep"], warnings, source, (v) => (target.onDirty = v as DirtyPolicy));
198
+ enumOf(raw, "onClosedPr", ["archive", "keep"], warnings, source, (v) => (target.onClosedPr = v as DirtyPolicy));
199
+ enumOf(raw, "onExtraCommits", ["archive", "keep"], warnings, source, (v) => (target.onExtraCommits = v as DirtyPolicy));
200
+ strArray(raw, "protectBranches", warnings, source, (v) => (target.protectBranches = v));
201
+ strArray(raw, "protectPaths", warnings, source, (v) => (target.protectPaths = v));
202
+ bool(raw, "pruneBranches", warnings, source, (v) => (target.pruneBranches = v));
203
+ bool(raw, "pruneRemotes", warnings, source, (v) => (target.pruneRemotes = v));
204
+ enumOf(raw, "forge", ["github", "none"], warnings, source, (v) => (target.forge = v as ForgeKind));
205
+ dur(raw, "forgeCacheTtl", warnings, source, (v) => (target.forgeCacheTtl = v));
206
+ dur(raw, "interval", warnings, source, (v) => (target.interval = v));
207
+ str(raw, "logFile", warnings, source, (v) => (target.logFile = v));
208
+ enumOf(raw, "logLevel", ["debug", "info", "warn", "error"], warnings, source, (v) => (target.logLevel = v as LogLevel));
209
+ bool(raw, "enabled", warnings, source, (v) => (target.enabled = v));
210
+
211
+ const sessions = raw["sessions"];
212
+ if (sessions !== undefined) {
213
+ if (isObject(sessions)) {
214
+ dur(sessions, "grace", warnings, source, (v) => (target.sessions.grace = v));
215
+ strArray(sessions, "detectors", warnings, source, (v) => (target.sessions.detectors = v));
216
+ strArray(sessions, "extraPaths", warnings, source, (v) => (target.sessions.extraPaths = v));
217
+ const cmds = sessions["commands"];
218
+ if (cmds !== undefined) {
219
+ if (Array.isArray(cmds)) {
220
+ const out: SessionCommand[] = [];
221
+ for (const c of cmds) {
222
+ if (isObject(c) && typeof c["command"] === "string") {
223
+ out.push({
224
+ name: typeof c["name"] === "string" ? c["name"] : "custom",
225
+ command: c["command"],
226
+ });
227
+ } else {
228
+ warnings.push(`${source}: sessions.commands entries need a "command" string`);
229
+ }
230
+ }
231
+ target.sessions.commands = out;
232
+ } else {
233
+ warnings.push(`${source}: sessions.commands must be an array`);
234
+ }
235
+ }
236
+ } else {
237
+ warnings.push(`${source}: sessions must be an object`);
238
+ }
239
+ }
240
+
241
+ const archive = raw["archive"];
242
+ if (archive !== undefined) {
243
+ if (isObject(archive)) {
244
+ bool(archive, "enabled", warnings, source, (v) => (target.archive.enabled = v));
245
+ str(archive, "dir", warnings, source, (v) => (target.archive.dir = v));
246
+ dur(archive, "retention", warnings, source, (v) => (target.archive.retention = v));
247
+ num(archive, "maxUntrackedBytes", warnings, source, (v) => (target.archive.maxUntrackedBytes = v));
248
+ } else {
249
+ warnings.push(`${source}: archive must be an object`);
250
+ }
251
+ }
252
+ }
253
+
254
+ function normalize(cfg: Config, warnings: string[]): void {
255
+ cfg.roots = cfg.roots.map((r) => expandPath(r));
256
+ cfg.protectPaths = cfg.protectPaths.map((p) => (p.includes("*") ? expandPath0(p) : expandPath(p)));
257
+ cfg.archive.dir = expandPath(cfg.archive.dir);
258
+ cfg.logFile = expandPath(cfg.logFile);
259
+ if (cfg.maxDepth < 1) {
260
+ warnings.push("maxDepth must be at least 1, using 1");
261
+ cfg.maxDepth = 1;
262
+ }
263
+ const unknownDetectors = cfg.sessions.detectors.filter(
264
+ (d) => !["claude-code", "codex", "aider", "process", "mtime"].includes(d),
265
+ );
266
+ for (const d of unknownDetectors) warnings.push(`unknown session detector "${d}"`);
267
+ }
268
+
269
+ /** Expand `~` in a glob without resolving the wildcards away. */
270
+ function expandPath0(pattern: string): string {
271
+ if (pattern === "~" || pattern.startsWith("~/")) {
272
+ const home = expandPath("~");
273
+ return pattern === "~" ? home : `${home}/${pattern.slice(2)}`;
274
+ }
275
+ return pattern;
276
+ }
277
+
278
+ function isObject(v: unknown): v is Record<string, unknown> {
279
+ return v !== null && typeof v === "object" && !Array.isArray(v);
280
+ }
281
+
282
+ type Sink<T> = (value: T) => void;
283
+
284
+ function str(raw: Record<string, unknown>, key: string, w: string[], src: string, set: Sink<string>) {
285
+ const v = raw[key];
286
+ if (v === undefined) return;
287
+ if (typeof v === "string") set(v);
288
+ else w.push(`${src}: ${key} must be a string`);
289
+ }
290
+
291
+ function num(raw: Record<string, unknown>, key: string, w: string[], src: string, set: Sink<number>) {
292
+ const v = raw[key];
293
+ if (v === undefined) return;
294
+ if (typeof v === "number" && Number.isFinite(v)) set(v);
295
+ else w.push(`${src}: ${key} must be a number`);
296
+ }
297
+
298
+ function bool(raw: Record<string, unknown>, key: string, w: string[], src: string, set: Sink<boolean>) {
299
+ const v = raw[key];
300
+ if (v === undefined) return;
301
+ if (typeof v === "boolean") set(v);
302
+ else w.push(`${src}: ${key} must be true or false`);
303
+ }
304
+
305
+ function strArray(raw: Record<string, unknown>, key: string, w: string[], src: string, set: Sink<string[]>) {
306
+ const v = raw[key];
307
+ if (v === undefined) return;
308
+ if (Array.isArray(v) && v.every((x) => typeof x === "string")) set(v as string[]);
309
+ else w.push(`${src}: ${key} must be an array of strings`);
310
+ }
311
+
312
+ function dur(raw: Record<string, unknown>, key: string, w: string[], src: string, set: Sink<string>) {
313
+ const v = raw[key];
314
+ if (v === undefined) return;
315
+ if (typeof v !== "string" && typeof v !== "number") {
316
+ w.push(`${src}: ${key} must be a duration like "30m"`);
317
+ return;
318
+ }
319
+ try {
320
+ parseDuration(v);
321
+ set(String(v));
322
+ } catch (err) {
323
+ w.push(`${src}: ${key} — ${(err as Error).message}`);
324
+ }
325
+ }
326
+
327
+ function enumOf(
328
+ raw: Record<string, unknown>,
329
+ key: string,
330
+ allowed: string[],
331
+ w: string[],
332
+ src: string,
333
+ set: Sink<string>,
334
+ ) {
335
+ const v = raw[key];
336
+ if (v === undefined) return;
337
+ if (typeof v === "string" && allowed.includes(v)) set(v);
338
+ else w.push(`${src}: ${key} must be one of ${allowed.join(", ")}`);
339
+ }