@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
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@yoavsh/treekeeper",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reclaim disk from finished agent worktrees. Watches git, forge PR state and agent sessions, archives anything unique, then removes what is done.",
5
+ "keywords": [
6
+ "git",
7
+ "worktree",
8
+ "cleanup",
9
+ "gc",
10
+ "disk-space",
11
+ "agent",
12
+ "ai-agent",
13
+ "claude-code",
14
+ "codex",
15
+ "aider",
16
+ "cursor",
17
+ "branches",
18
+ "daemon"
19
+ ],
20
+ "homepage": "https://github.com/yoavshmariahu/treekeeper#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/yoavshmariahu/treekeeper/issues"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/yoavshmariahu/treekeeper.git"
27
+ },
28
+ "license": "MIT",
29
+ "author": "Yoav Shmariahu",
30
+ "type": "module",
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "bin": {
35
+ "treekeeper": "dist/cli.js",
36
+ "tk": "dist/cli.js"
37
+ },
38
+ "main": "./dist/index.js",
39
+ "types": "./dist/index.d.ts",
40
+ "exports": {
41
+ ".": {
42
+ "types": "./dist/index.d.ts",
43
+ "import": "./dist/index.js"
44
+ }
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "files": [
50
+ "dist",
51
+ "src",
52
+ "docs",
53
+ "README.md",
54
+ "CHANGELOG.md",
55
+ "LICENSE"
56
+ ],
57
+ "scripts": {
58
+ "build": "tsc -p tsconfig.json",
59
+ "watch": "tsc -w -p tsconfig.json",
60
+ "typecheck": "tsc --noEmit -p tsconfig.json",
61
+ "test": "vitest run",
62
+ "test:watch": "vitest",
63
+ "start": "node dist/cli.js",
64
+ "prepack": "npm run build"
65
+ },
66
+ "devDependencies": {
67
+ "@types/node": "^22.10.2",
68
+ "typescript": "^5.7.2",
69
+ "vitest": "^2.1.8"
70
+ }
71
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
4
+ import { archiveCommand } from "./commands/archive.js";
5
+ import { configCommand } from "./commands/config.js";
6
+ import { daemonCommand } from "./commands/daemon.js";
7
+ import { doctorCommand } from "./commands/doctor.js";
8
+ import { gcCommand } from "./commands/gc.js";
9
+ import { statusCommand } from "./commands/status.js";
10
+ import { watchCommand } from "./commands/watch.js";
11
+ import type { CliFlags } from "./commands/context.js";
12
+ import { color } from "./util/color.js";
13
+
14
+ const HELP = `${color.bold("treekeeper")} — reclaim disk from finished agent worktrees
15
+
16
+ ${color.bold("USAGE")}
17
+ treekeeper <command> [options] [path...]
18
+
19
+ ${color.bold("COMMANDS")}
20
+ gc [path...] Plan a sweep (default). Add --apply to carry it out.
21
+ status What is reclaimable right now, plus daemon state.
22
+ watch Sweep on an interval in the foreground.
23
+ daemon <sub> install | uninstall | start | stop | status | logs
24
+ archive <sub> list | prune | path
25
+ config <sub> show | path | init
26
+ doctor Check git, gh, lsof, config and detectors.
27
+
28
+ ${color.bold("OPTIONS")}
29
+ --apply Actually remove things (default is a dry run)
30
+ -y, --yes Skip the confirmation prompt
31
+ --all Every repo under the configured roots
32
+ --json Machine-readable output on stdout
33
+ -q, --quiet Summary only
34
+ -v, --verbose Show kept worktrees and debug logging
35
+ --force Bypass the age gate and the dirty / closed-PR holds
36
+ --min-age <dur> Override minAge, e.g. 2h
37
+ --session-grace <d> Override how long after agent activity a worktree is held
38
+ --forge <kind> github | none (none = git ancestry only)
39
+ --refresh Ignore the cached pull-request data
40
+ --branches-only Prune branches, leave every worktree alone
41
+ --worktrees-only Remove worktrees, do not prune other branches
42
+ --sizes Measure every worktree, not just the removable ones
43
+ --root <dir> Override roots (repeatable)
44
+ --interval <dur> Interval for watch / daemon install
45
+ --config <file> Use a specific config file
46
+ -h, --help This text
47
+ --version Version
48
+
49
+ ${color.bold("EXAMPLES")}
50
+ treekeeper gc --all # what could go, across every repo
51
+ treekeeper gc --all --apply --yes # the sweep a harness hook should run
52
+ treekeeper gc --json | jq .summary # read the plan from a script
53
+ treekeeper daemon install # sweep on a schedule, forever
54
+
55
+ ${color.gray("A worktree is only ever removed when its work is merged, or its pull request")}
56
+ ${color.gray("was merged or closed — and anything unique is archived before it goes.")}
57
+ `;
58
+
59
+ export async function main(argv: string[]): Promise<number> {
60
+ let parsed;
61
+ try {
62
+ parsed = parseArgs({
63
+ args: argv,
64
+ allowPositionals: true,
65
+ strict: true,
66
+ options: {
67
+ apply: { type: "boolean" },
68
+ yes: { type: "boolean", short: "y" },
69
+ all: { type: "boolean" },
70
+ json: { type: "boolean" },
71
+ quiet: { type: "boolean", short: "q" },
72
+ verbose: { type: "boolean", short: "v" },
73
+ force: { type: "boolean" },
74
+ refresh: { type: "boolean" },
75
+ sizes: { type: "boolean" },
76
+ "branches-only": { type: "boolean" },
77
+ "worktrees-only": { type: "boolean" },
78
+ "min-age": { type: "string" },
79
+ "session-grace": { type: "string" },
80
+ forge: { type: "string" },
81
+ root: { type: "string", multiple: true },
82
+ interval: { type: "string" },
83
+ config: { type: "string" },
84
+ help: { type: "boolean", short: "h" },
85
+ version: { type: "boolean" },
86
+ },
87
+ });
88
+ } catch (err) {
89
+ process.stderr.write(`${(err as Error).message}\nTry \`treekeeper --help\`.\n`);
90
+ return 2;
91
+ }
92
+
93
+ const { values, positionals } = parsed;
94
+
95
+ if (values.version) {
96
+ process.stdout.write((await version()) + "\n");
97
+ return 0;
98
+ }
99
+
100
+ const [command = "gc", ...rest] = positionals;
101
+
102
+ if (values.help) {
103
+ process.stdout.write(HELP);
104
+ return 0;
105
+ }
106
+
107
+ const flags: CliFlags = {
108
+ apply: values.apply,
109
+ yes: values.yes,
110
+ json: values.json,
111
+ quiet: values.quiet,
112
+ verbose: values.verbose,
113
+ all: values.all,
114
+ force: values.force,
115
+ refresh: values.refresh,
116
+ sizes: values.sizes,
117
+ branchesOnly: values["branches-only"],
118
+ worktreesOnly: values["worktrees-only"],
119
+ minAge: values["min-age"],
120
+ sessionGrace: values["session-grace"],
121
+ forge: values.forge,
122
+ interval: values.interval,
123
+ config: values.config,
124
+ roots: values.root,
125
+ };
126
+
127
+ switch (command) {
128
+ case "gc":
129
+ case "clean":
130
+ return gcCommand(rest, flags);
131
+ case "status":
132
+ return statusCommand(flags);
133
+ case "watch":
134
+ return watchCommand(flags);
135
+ case "daemon":
136
+ return daemonCommand(rest, flags);
137
+ case "archive":
138
+ return archiveCommand(rest, flags);
139
+ case "config":
140
+ return configCommand(rest);
141
+ case "doctor":
142
+ return doctorCommand(values.json === true);
143
+ case "help":
144
+ process.stdout.write(HELP);
145
+ return 0;
146
+ default:
147
+ process.stderr.write(`unknown command "${command}". Try \`treekeeper --help\`.\n`);
148
+ return 2;
149
+ }
150
+ }
151
+
152
+ async function version(): Promise<string> {
153
+ try {
154
+ const text = await readFile(new URL("../package.json", import.meta.url), "utf8");
155
+ const pkg: unknown = JSON.parse(text);
156
+ return String((pkg as { version?: unknown }).version ?? "0.0.0");
157
+ } catch {
158
+ return "0.0.0";
159
+ }
160
+ }
161
+
162
+ const isDirectRun =
163
+ process.argv[1] !== undefined &&
164
+ (import.meta.url.endsWith("/cli.js") || import.meta.url.endsWith("/cli.ts"));
165
+
166
+ if (isDirectRun) {
167
+ main(process.argv.slice(2))
168
+ .then((code) => {
169
+ process.exitCode = code;
170
+ })
171
+ .catch((err: unknown) => {
172
+ process.stderr.write(`treekeeper: ${(err as Error).stack ?? String(err)}\n`);
173
+ process.exitCode = 1;
174
+ });
175
+ }
@@ -0,0 +1,72 @@
1
+ import { listArchives, pruneArchives } from "../core/archive.js";
2
+ import { loadConfig } from "../core/config.js";
3
+ import { dirSizeKb } from "../core/size.js";
4
+ import { formatBytes, formatKb } from "../util/bytes.js";
5
+ import { color } from "../util/color.js";
6
+ import { shortenPath } from "../util/paths.js";
7
+ import { formatAgo, parseDuration } from "../util/time.js";
8
+ import type { CliFlags } from "./context.js";
9
+
10
+ const USAGE = `usage: treekeeper archive <list|prune|path>`;
11
+
12
+ export async function archiveCommand(args: string[], flags: CliFlags): Promise<number> {
13
+ const { config } = await loadConfig();
14
+ const sub = args[0] ?? "list";
15
+
16
+ if (sub === "path") {
17
+ process.stdout.write(config.archive.dir + "\n");
18
+ return 0;
19
+ }
20
+
21
+ if (sub === "prune") {
22
+ const retention = parseDuration(config.archive.retention);
23
+ const result = await pruneArchives(config.archive.dir, retention);
24
+ process.stdout.write(
25
+ result.removed.length === 0
26
+ ? `nothing older than ${config.archive.retention}\n`
27
+ : `pruned ${result.removed.length} archive(s), freed ${formatKb(result.freedKb)}\n`,
28
+ );
29
+ return 0;
30
+ }
31
+
32
+ if (sub !== "list") {
33
+ process.stderr.write(USAGE + "\n");
34
+ return 2;
35
+ }
36
+
37
+ const records = await listArchives(config.archive.dir);
38
+ if (flags.json) {
39
+ process.stdout.write(JSON.stringify(records, null, 2) + "\n");
40
+ return 0;
41
+ }
42
+
43
+ if (records.length === 0) {
44
+ process.stdout.write(`no archives in ${shortenPath(config.archive.dir)}\n`);
45
+ return 0;
46
+ }
47
+
48
+ const total = (await dirSizeKb(config.archive.dir)) ?? 0;
49
+ process.stdout.write(
50
+ `${color.bold(String(records.length))} archive(s) in ${shortenPath(config.archive.dir)} ` +
51
+ color.gray(`(${formatKb(total)}, retention ${config.archive.retention})\n\n`),
52
+ );
53
+
54
+ for (const r of records) {
55
+ const bits = [
56
+ r.bundle ? `${r.bundle.commits} unmerged commit${r.bundle.commits === 1 ? "" : "s"}` : null,
57
+ r.patch ? `diff ${formatBytes(r.patch.bytes)}` : null,
58
+ r.untracked
59
+ ? `${r.untracked.count} untracked${r.untracked.truncated ? " (list only)" : ""}`
60
+ : null,
61
+ ].filter(Boolean);
62
+ process.stdout.write(
63
+ `${color.cyan(r.id)} ${color.gray(formatAgo(Date.parse(r.createdAt)))}\n` +
64
+ ` ${color.gray(r.reason)}${bits.length > 0 ? ` ${color.yellow(bits.join(", "))}` : color.gray(" (nothing unique)")}\n` +
65
+ ` ${color.gray(shortenPath(r.dir))}\n`,
66
+ );
67
+ }
68
+ process.stdout.write(
69
+ color.gray("\neach archive has a RESTORE.md with the exact commands to bring it back\n"),
70
+ );
71
+ return 0;
72
+ }
@@ -0,0 +1,57 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { defaultConfig, globalConfigPath, loadConfig } from "../core/config.js";
4
+ import { color } from "../util/color.js";
5
+ import { shortenPath } from "../util/paths.js";
6
+
7
+ const USAGE = `usage: treekeeper config <show|path|init>`;
8
+
9
+ export async function configCommand(args: string[]): Promise<number> {
10
+ const sub = args[0] ?? "show";
11
+
12
+ if (sub === "path") {
13
+ process.stdout.write(globalConfigPath() + "\n");
14
+ return 0;
15
+ }
16
+
17
+ if (sub === "show") {
18
+ const { config, sources, warnings } = await loadConfig();
19
+ process.stdout.write(JSON.stringify(config, null, 2) + "\n");
20
+ if (sources.length === 0) {
21
+ process.stderr.write(color.gray(`(defaults — no file at ${shortenPath(globalConfigPath())})\n`));
22
+ }
23
+ for (const w of warnings) process.stderr.write(color.yellow(`warn ${w}\n`));
24
+ return 0;
25
+ }
26
+
27
+ if (sub === "init") {
28
+ const path = globalConfigPath();
29
+ const starter = {
30
+ $schema: "https://github.com/yoavshmariahu/treekeeper",
31
+ roots: defaultConfig().roots,
32
+ minAge: "30m",
33
+ interval: "15m",
34
+ onDirty: "archive",
35
+ onClosedPr: "archive",
36
+ protectBranches: defaultConfig().protectBranches,
37
+ protectPaths: [],
38
+ sessions: { grace: "20m" },
39
+ };
40
+ try {
41
+ await mkdir(dirname(path), { recursive: true });
42
+ await writeFile(path, JSON.stringify(starter, null, 2) + "\n", { flag: "wx" });
43
+ process.stdout.write(`wrote ${shortenPath(path)}\n`);
44
+ return 0;
45
+ } catch (err) {
46
+ if ((err as NodeJS.ErrnoException).code === "EEXIST") {
47
+ process.stderr.write(`${shortenPath(path)} already exists\n`);
48
+ return 1;
49
+ }
50
+ process.stderr.write(`could not write ${path}: ${(err as Error).message}\n`);
51
+ return 1;
52
+ }
53
+ }
54
+
55
+ process.stderr.write(USAGE + "\n");
56
+ return 2;
57
+ }
@@ -0,0 +1,86 @@
1
+ import type { Config } from "../core/config.js";
2
+ import { loadConfig } from "../core/config.js";
3
+ import type { Forge } from "../core/forge.js";
4
+ import { createForge } from "../core/forge.js";
5
+ import { discoverRepos, reposFromPaths } from "../core/scan.js";
6
+ import { Logger } from "../util/log.js";
7
+ import { expandPath } from "../util/paths.js";
8
+
9
+ export interface CliFlags {
10
+ apply?: boolean;
11
+ yes?: boolean;
12
+ json?: boolean;
13
+ quiet?: boolean;
14
+ verbose?: boolean;
15
+ all?: boolean;
16
+ force?: boolean;
17
+ minAge?: string;
18
+ sessionGrace?: string;
19
+ forge?: string;
20
+ refresh?: boolean;
21
+ branchesOnly?: boolean;
22
+ worktreesOnly?: boolean;
23
+ sizes?: boolean;
24
+ interval?: string;
25
+ config?: string;
26
+ roots?: string[];
27
+ }
28
+
29
+ export interface Context {
30
+ config: Config;
31
+ logger: Logger;
32
+ forge: Forge;
33
+ warnings: string[];
34
+ configSources: string[];
35
+ }
36
+
37
+ /** Load config, apply CLI overrides, and build the shared logger and forge. */
38
+ export async function createContext(flags: CliFlags): Promise<Context> {
39
+ const loaded = await loadConfig(flags.config ? expandPath(flags.config) : undefined);
40
+ const config = loaded.config;
41
+
42
+ if (flags.minAge !== undefined) config.minAge = flags.minAge;
43
+ if (flags.sessionGrace !== undefined) config.sessions.grace = flags.sessionGrace;
44
+ if (flags.forge === "none" || flags.forge === "github") config.forge = flags.forge;
45
+ if (flags.worktreesOnly) config.pruneBranches = false;
46
+ if (flags.roots && flags.roots.length > 0) config.roots = flags.roots.map((r) => expandPath(r));
47
+ if (flags.verbose) config.logLevel = "debug";
48
+
49
+ const logger = new Logger({
50
+ level: config.logLevel,
51
+ file: config.logFile,
52
+ quiet: flags.quiet === true,
53
+ });
54
+
55
+ for (const w of loaded.warnings) logger.warn(w);
56
+
57
+ const forge = createForge({
58
+ kind: config.forge,
59
+ cacheTtl: config.forgeCacheTtl,
60
+ logger,
61
+ refresh: flags.refresh === true,
62
+ });
63
+
64
+ return { config, logger, forge, warnings: loaded.warnings, configSources: loaded.sources };
65
+ }
66
+
67
+ /** Which repos a command should operate on, from positionals, `--all`, or cwd. */
68
+ export async function resolveRepos(
69
+ ctx: Context,
70
+ positionals: string[],
71
+ flags: CliFlags,
72
+ ): Promise<string[]> {
73
+ const scan = { maxDepth: ctx.config.maxDepth, protectPaths: ctx.config.protectPaths, logger: ctx.logger };
74
+
75
+ if (positionals.length > 0) {
76
+ return reposFromPaths(positionals.map((p) => expandPath(p)), scan);
77
+ }
78
+ if (flags.all) {
79
+ return discoverRepos({ ...scan, roots: ctx.config.roots });
80
+ }
81
+ // Default to the repo containing the cwd, falling back to the configured
82
+ // roots when the cwd is not inside one (e.g. the daemon, started from "/").
83
+ const here = await reposFromPaths([process.cwd()], { ...scan, maxDepth: 1 });
84
+ if (here.length > 0) return here;
85
+ return discoverRepos({ ...scan, roots: ctx.config.roots });
86
+ }
@@ -0,0 +1,86 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { loadConfig } from "../core/config.js";
3
+ import {
4
+ daemonStatus, installDaemon, setDaemonRunning, sweepArgv, uninstallDaemon,
5
+ } 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, formatDuration } from "../util/time.js";
10
+ import type { CliFlags } from "./context.js";
11
+
12
+ const USAGE = `usage: treekeeper daemon <install|uninstall|start|stop|status|logs>`;
13
+
14
+ export async function daemonCommand(args: string[], flags: CliFlags): Promise<number> {
15
+ const sub = args[0] ?? "status";
16
+
17
+ switch (sub) {
18
+ case "install": {
19
+ const { config } = await loadConfig();
20
+ const interval = flags.interval ?? config.interval;
21
+ const result = await installDaemon(interval);
22
+ for (const m of result.messages) {
23
+ process.stdout.write((result.ok ? "" : color.yellow("warn ")) + m + "\n");
24
+ }
25
+ if (result.ok) {
26
+ process.stdout.write(
27
+ color.gray(`it runs: ${sweepArgv().slice(1).join(" ")}\n`) +
28
+ color.gray("change the interval in config.json, then re-run `treekeeper daemon install`\n"),
29
+ );
30
+ }
31
+ return result.ok ? 0 : 1;
32
+ }
33
+ case "uninstall": {
34
+ const result = await uninstallDaemon();
35
+ for (const m of result.messages) process.stdout.write(m + "\n");
36
+ return result.ok ? 0 : 1;
37
+ }
38
+ case "start":
39
+ case "stop": {
40
+ const result = await setDaemonRunning(sub === "start");
41
+ for (const m of result.messages) process.stdout.write(m + "\n");
42
+ return result.ok ? 0 : 1;
43
+ }
44
+ case "status": {
45
+ const status = await daemonStatus();
46
+ if (flags.json) {
47
+ process.stdout.write(JSON.stringify(status, null, 2) + "\n");
48
+ return 0;
49
+ }
50
+ const lines = [
51
+ `manager ${status.manager}`,
52
+ `installed ${status.installed ? color.green("yes") : color.gray("no")}` +
53
+ (status.unitPath ? color.gray(` ${shortenPath(status.unitPath)}`) : ""),
54
+ `scheduled ${status.active ? color.green("yes") : color.gray("no")}` +
55
+ (status.intervalSeconds ? color.gray(` every ${formatDuration(status.intervalSeconds * 1000)}`) : ""),
56
+ ];
57
+ if (status.lastSweep) {
58
+ const s = status.lastSweep;
59
+ lines.push(
60
+ `last run ${formatAgo(s.at)}` +
61
+ color.gray(` ${s.worktrees} worktree(s), ${s.branches} branch(es), ${formatKb(s.reclaimedKb)} in ${formatDuration(s.durationMs)}`),
62
+ );
63
+ } else {
64
+ lines.push(`last run ${color.gray("never")}`);
65
+ }
66
+ lines.push(`logs ${shortenPath(status.logFile)}`);
67
+ process.stdout.write(lines.join("\n") + "\n");
68
+ return 0;
69
+ }
70
+ case "logs": {
71
+ const status = await daemonStatus();
72
+ try {
73
+ const text = await readFile(status.logFile, "utf8");
74
+ const lines = text.split("\n");
75
+ process.stdout.write(lines.slice(-200).join("\n"));
76
+ } catch {
77
+ process.stderr.write(`no daemon log yet at ${status.logFile}\n`);
78
+ return 1;
79
+ }
80
+ return 0;
81
+ }
82
+ default:
83
+ process.stderr.write(USAGE + "\n");
84
+ return 2;
85
+ }
86
+ }
@@ -0,0 +1,131 @@
1
+ import { access, constants } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { globalConfigPath, loadConfig } from "../core/config.js";
5
+ import { discoverRepos } from "../core/scan.js";
6
+ import { daemonStatus } from "../daemon/service.js";
7
+ import { gitVersion } from "../core/git.js";
8
+ import { have, runOk } from "../util/exec.js";
9
+ import { color } from "../util/color.js";
10
+ import { Logger } from "../util/log.js";
11
+ import { shortenPath } from "../util/paths.js";
12
+
13
+ interface Check {
14
+ name: string;
15
+ ok: boolean | "warn";
16
+ detail: string;
17
+ }
18
+
19
+ /** Check that everything treekeeper depends on is actually present. */
20
+ export async function doctorCommand(json: boolean): Promise<number> {
21
+ const checks: Check[] = [];
22
+ const { config, warnings, sources } = await loadConfig();
23
+
24
+ const git = await gitVersion();
25
+ checks.push({
26
+ name: "git",
27
+ ok: git !== null,
28
+ detail: git ?? "not found on PATH — treekeeper cannot do anything without it",
29
+ });
30
+
31
+ const gh = await have("gh");
32
+ if (config.forge === "github") {
33
+ if (!gh) {
34
+ checks.push({
35
+ name: "gh",
36
+ ok: "warn",
37
+ detail: "not installed — squash-merged branches will not be detected (brew install gh)",
38
+ });
39
+ } else {
40
+ const auth = await runOk("gh", ["auth", "status"], { timeoutMs: 20_000 });
41
+ checks.push({
42
+ name: "gh",
43
+ ok: auth !== null ? true : "warn",
44
+ detail: auth !== null ? "installed and authenticated" : "installed but not logged in (gh auth login)",
45
+ });
46
+ }
47
+ } else {
48
+ checks.push({ name: "gh", ok: true, detail: "forge is disabled in config" });
49
+ }
50
+
51
+ const lsof = await have("lsof");
52
+ checks.push({
53
+ name: "lsof",
54
+ ok: lsof ? true : "warn",
55
+ detail: lsof
56
+ ? "available — can tell when a process is still in a worktree"
57
+ : "missing — the 'process' session detector will be skipped",
58
+ });
59
+
60
+ checks.push({
61
+ name: "config",
62
+ ok: warnings.length === 0,
63
+ detail: sources.length > 0
64
+ ? `${shortenPath(sources[0]!)}${warnings.length > 0 ? ` — ${warnings.length} warning(s)` : ""}`
65
+ : `using defaults (no file at ${shortenPath(globalConfigPath())})`,
66
+ });
67
+
68
+ const repos = await discoverRepos({
69
+ roots: config.roots,
70
+ maxDepth: config.maxDepth,
71
+ protectPaths: config.protectPaths,
72
+ logger: new Logger({ level: "error" }),
73
+ });
74
+ checks.push({
75
+ name: "roots",
76
+ ok: repos.length > 0 ? true : "warn",
77
+ detail: `${repos.length} repo(s) under ${config.roots.map(shortenPath).join(", ")}`,
78
+ });
79
+
80
+ for (const detector of config.sessions.detectors) {
81
+ if (detector === "claude-code") {
82
+ const dir = join(homedir(), ".claude", "projects");
83
+ const present = await exists(dir);
84
+ checks.push({
85
+ name: "detector claude-code",
86
+ ok: true,
87
+ detail: present ? `reading ${shortenPath(dir)}` : "no Claude Code sessions on this machine",
88
+ });
89
+ } else if (detector === "codex") {
90
+ const dir = join(homedir(), ".codex", "sessions");
91
+ const present = await exists(dir);
92
+ checks.push({
93
+ name: "detector codex",
94
+ ok: true,
95
+ detail: present ? `reading ${shortenPath(dir)}` : "no Codex sessions on this machine",
96
+ });
97
+ }
98
+ }
99
+
100
+ const daemon = await daemonStatus();
101
+ checks.push({
102
+ name: "daemon",
103
+ ok: daemon.installed ? (daemon.active ? true : "warn") : "warn",
104
+ detail: daemon.installed
105
+ ? daemon.active ? "installed and scheduled" : "installed but not loaded (treekeeper daemon start)"
106
+ : "not installed (treekeeper daemon install)",
107
+ });
108
+
109
+ if (json) {
110
+ process.stdout.write(JSON.stringify({ checks, warnings }, null, 2) + "\n");
111
+ return checks.some((c) => c.ok === false) ? 1 : 0;
112
+ }
113
+
114
+ const width = Math.max(...checks.map((c) => c.name.length));
115
+ for (const c of checks) {
116
+ const mark = c.ok === true ? color.green("ok ") : c.ok === "warn" ? color.yellow("warn") : color.red("fail");
117
+ process.stdout.write(`${mark} ${c.name.padEnd(width)} ${color.gray(c.detail)}\n`);
118
+ }
119
+ for (const w of warnings) process.stdout.write(color.yellow(`warn config: ${w}\n`));
120
+
121
+ return checks.some((c) => c.ok === false) ? 1 : 0;
122
+ }
123
+
124
+ async function exists(path: string): Promise<boolean> {
125
+ try {
126
+ await access(path, constants.R_OK);
127
+ return true;
128
+ } catch {
129
+ return false;
130
+ }
131
+ }