@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +237 -2
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +165 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/archive.d.ts +3 -0
- package/dist/commands/archive.d.ts.map +1 -0
- package/dist/commands/archive.js +55 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/config.js +54 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/context.d.ts +34 -0
- package/dist/commands/context.d.ts.map +1 -0
- package/dist/commands/context.js +53 -0
- package/dist/commands/context.js.map +1 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.d.ts.map +1 -0
- package/dist/commands/daemon.js +81 -0
- package/dist/commands/daemon.js.map +1 -0
- package/dist/commands/doctor.d.ts +3 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +119 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/gc.d.ts +3 -0
- package/dist/commands/gc.d.ts.map +1 -0
- package/dist/commands/gc.js +98 -0
- package/dist/commands/gc.js.map +1 -0
- package/dist/commands/status.d.ts +4 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +52 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/watch.d.ts +10 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +41 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/core/apply.d.ts +47 -0
- package/dist/core/apply.d.ts.map +1 -0
- package/dist/core/apply.js +223 -0
- package/dist/core/apply.js.map +1 -0
- package/dist/core/archive.d.ts +69 -0
- package/dist/core/archive.d.ts.map +1 -0
- package/dist/core/archive.js +202 -0
- package/dist/core/archive.js.map +1 -0
- package/dist/core/config.d.ts +69 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +261 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/decide.d.ts +102 -0
- package/dist/core/decide.d.ts.map +1 -0
- package/dist/core/decide.js +148 -0
- package/dist/core/decide.js.map +1 -0
- package/dist/core/forge.d.ts +39 -0
- package/dist/core/forge.d.ts.map +1 -0
- package/dist/core/forge.js +190 -0
- package/dist/core/forge.js.map +1 -0
- package/dist/core/git.d.ts +63 -0
- package/dist/core/git.d.ts.map +1 -0
- package/dist/core/git.js +194 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/plan.d.ts +75 -0
- package/dist/core/plan.d.ts.map +1 -0
- package/dist/core/plan.js +288 -0
- package/dist/core/plan.js.map +1 -0
- package/dist/core/render.d.ts +14 -0
- package/dist/core/render.d.ts.map +1 -0
- package/dist/core/render.js +128 -0
- package/dist/core/render.js.map +1 -0
- package/dist/core/scan.d.ts +22 -0
- package/dist/core/scan.d.ts.map +1 -0
- package/dist/core/scan.js +99 -0
- package/dist/core/scan.js.map +1 -0
- package/dist/core/sessions.d.ts +37 -0
- package/dist/core/sessions.d.ts.map +1 -0
- package/dist/core/sessions.js +300 -0
- package/dist/core/sessions.js.map +1 -0
- package/dist/core/size.d.ts +3 -0
- package/dist/core/size.d.ts.map +1 -0
- package/dist/core/size.js +11 -0
- package/dist/core/size.js.map +1 -0
- package/dist/daemon/lock.d.ts +10 -0
- package/dist/daemon/lock.d.ts.map +1 -0
- package/dist/daemon/lock.js +64 -0
- package/dist/daemon/lock.js.map +1 -0
- package/dist/daemon/service.d.ts +34 -0
- package/dist/daemon/service.d.ts.map +1 -0
- package/dist/daemon/service.js +257 -0
- package/dist/daemon/service.js.map +1 -0
- package/dist/daemon/state.d.ts +19 -0
- package/dist/daemon/state.d.ts.map +1 -0
- package/dist/daemon/state.js +32 -0
- package/dist/daemon/state.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/util/bytes.d.ts +4 -0
- package/dist/util/bytes.d.ts.map +1 -0
- package/dist/util/bytes.js +14 -0
- package/dist/util/bytes.js.map +1 -0
- package/dist/util/color.d.ts +16 -0
- package/dist/util/color.d.ts.map +1 -0
- package/dist/util/color.js +32 -0
- package/dist/util/color.js.map +1 -0
- package/dist/util/exec.d.ts +27 -0
- package/dist/util/exec.d.ts.map +1 -0
- package/dist/util/exec.js +57 -0
- package/dist/util/exec.js.map +1 -0
- package/dist/util/log.d.ts +29 -0
- package/dist/util/log.d.ts.map +1 -0
- package/dist/util/log.js +72 -0
- package/dist/util/log.js.map +1 -0
- package/dist/util/paths.d.ts +18 -0
- package/dist/util/paths.d.ts.map +1 -0
- package/dist/util/paths.js +87 -0
- package/dist/util/paths.js.map +1 -0
- package/dist/util/prompt.d.ts +3 -0
- package/dist/util/prompt.d.ts.map +1 -0
- package/dist/util/prompt.js +15 -0
- package/dist/util/prompt.js.map +1 -0
- package/dist/util/redact.d.ts +15 -0
- package/dist/util/redact.d.ts.map +1 -0
- package/dist/util/redact.js +44 -0
- package/dist/util/redact.js.map +1 -0
- package/dist/util/time.d.ts +13 -0
- package/dist/util/time.d.ts.map +1 -0
- package/dist/util/time.js +77 -0
- package/dist/util/time.js.map +1 -0
- package/docs/integrations.md +128 -0
- package/docs/safety.md +62 -0
- package/package.json +69 -4
- package/src/cli.ts +175 -0
- package/src/commands/archive.ts +72 -0
- package/src/commands/config.ts +57 -0
- package/src/commands/context.ts +86 -0
- package/src/commands/daemon.ts +86 -0
- package/src/commands/doctor.ts +131 -0
- package/src/commands/gc.ts +108 -0
- package/src/commands/status.ts +68 -0
- package/src/commands/watch.ts +48 -0
- package/src/core/apply.ts +276 -0
- package/src/core/archive.ts +269 -0
- package/src/core/config.ts +339 -0
- package/src/core/decide.ts +230 -0
- package/src/core/forge.ts +243 -0
- package/src/core/git.ts +254 -0
- package/src/core/plan.ts +431 -0
- package/src/core/render.ts +168 -0
- package/src/core/scan.ts +105 -0
- package/src/core/sessions.ts +339 -0
- package/src/core/size.ts +10 -0
- package/src/daemon/lock.ts +65 -0
- package/src/daemon/service.ts +321 -0
- package/src/daemon/state.ts +49 -0
- package/src/index.ts +36 -0
- package/src/util/bytes.ts +11 -0
- package/src/util/color.ts +33 -0
- package/src/util/exec.ts +80 -0
- package/src/util/log.ts +88 -0
- package/src/util/paths.ts +84 -0
- package/src/util/prompt.ts +13 -0
- package/src/util/redact.ts +43 -0
- 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|