@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ApplyResult } from "./apply.js";
|
|
2
|
+
import type { Plan, RepoPlan, WorktreeCandidate } from "./plan.js";
|
|
3
|
+
import { formatKb } from "../util/bytes.js";
|
|
4
|
+
import { color, padEndVisible, visibleWidth } from "../util/color.js";
|
|
5
|
+
import { shortenPath } from "../util/paths.js";
|
|
6
|
+
|
|
7
|
+
export interface RenderOptions {
|
|
8
|
+
/** Show kept worktrees too (default: only when there are few). */
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
/** Only the summary line. */
|
|
11
|
+
quiet?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The human-readable plan. */
|
|
15
|
+
export function renderPlan(plan: Plan, opts: RenderOptions = {}): string {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
if (opts.quiet) return summaryLine(plan);
|
|
18
|
+
|
|
19
|
+
for (const repo of plan.repos) {
|
|
20
|
+
const block = renderRepo(repo, opts);
|
|
21
|
+
if (block.length > 0) {
|
|
22
|
+
out.push(...block, "");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const warning of plan.warnings) {
|
|
27
|
+
out.push(color.yellow(`note ${warning}`));
|
|
28
|
+
}
|
|
29
|
+
if (plan.warnings.length > 0) out.push("");
|
|
30
|
+
|
|
31
|
+
out.push(summaryLine(plan));
|
|
32
|
+
return out.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderRepo(repo: RepoPlan, opts: RenderOptions): string[] {
|
|
36
|
+
if (repo.skipped) {
|
|
37
|
+
return opts.verbose ? [`${color.bold(repo.name)} ${color.gray(`skipped — ${repo.skipped}`)}`] : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const reclaim = repo.worktrees.filter((w) => w.verdict.action === "reclaim");
|
|
41
|
+
const kept = repo.worktrees.filter(
|
|
42
|
+
(w) => w.verdict.action === "keep" && w.verdict.code !== "main-worktree",
|
|
43
|
+
);
|
|
44
|
+
const showKept = opts.verbose ? kept : [];
|
|
45
|
+
|
|
46
|
+
if (reclaim.length === 0 && showKept.length === 0 && repo.branches.length === 0) return [];
|
|
47
|
+
|
|
48
|
+
const rows: Array<[string, string, string, string, string]> = [];
|
|
49
|
+
for (const w of reclaim) rows.push(row(w, true));
|
|
50
|
+
for (const w of showKept) rows.push(row(w, false));
|
|
51
|
+
|
|
52
|
+
const widths = [0, 0, 0, 0];
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
for (let i = 0; i < 4; i += 1) {
|
|
55
|
+
widths[i] = Math.max(widths[i]!, visibleWidth(r[i]!));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const head = `${color.bold(repo.name)} ${color.gray(`(${repo.defaultBranch})`)}${
|
|
60
|
+
repo.forgeAvailable ? "" : color.gray(" [no PR data]")
|
|
61
|
+
}`;
|
|
62
|
+
const lines = [head];
|
|
63
|
+
|
|
64
|
+
for (const r of rows) {
|
|
65
|
+
const cells = [
|
|
66
|
+
padEndVisible(r[0]!, widths[0]!),
|
|
67
|
+
padEndVisible(r[1]!, widths[1]!),
|
|
68
|
+
padEndVisible(r[2]!, widths[2]!),
|
|
69
|
+
padEndVisible(r[3]!, widths[3]!),
|
|
70
|
+
r[4]!,
|
|
71
|
+
];
|
|
72
|
+
lines.push(` ${cells.join(" ").trimEnd()}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (repo.branches.length > 0) {
|
|
76
|
+
const names = repo.branches.map((b) => b.name);
|
|
77
|
+
const shown = names.slice(0, 6).join(" ");
|
|
78
|
+
const more = names.length > 6 ? color.gray(` +${names.length - 6} more`) : "";
|
|
79
|
+
lines.push(
|
|
80
|
+
` ${color.red("DELETE")} ${names.length} merged branch${names.length === 1 ? "" : "es"}: ${color.gray(shown)}${more}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function row(w: WorktreeCandidate, reclaim: boolean): [string, string, string, string, string] {
|
|
88
|
+
const action = reclaim ? color.red("RECLAIM") : color.gray("keep ");
|
|
89
|
+
const path = reclaim ? shortenPath(w.path) : color.gray(shortenPath(w.path));
|
|
90
|
+
const branch = w.branch ? color.cyan(`[${w.branch}]`) : color.gray("[detached]");
|
|
91
|
+
const size = w.sizeKb === null ? "" : (reclaim ? color.yellow(formatKb(w.sizeKb)) : color.gray(formatKb(w.sizeKb)));
|
|
92
|
+
const risks = w.verdict.risks.length > 0 ? color.yellow(` (${w.verdict.risks.join(", ")})`) : "";
|
|
93
|
+
const reason = (reclaim ? color.gray(w.verdict.reason) : color.gray(w.verdict.reason)) + risks;
|
|
94
|
+
return [action, path, branch, size, reason];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function summaryLine(plan: Plan): string {
|
|
98
|
+
const s = plan.summary;
|
|
99
|
+
if (s.worktreesReclaimable === 0 && s.branchesDeletable === 0) {
|
|
100
|
+
return color.green(
|
|
101
|
+
`nothing to clean — ${s.worktreesScanned} worktree${s.worktreesScanned === 1 ? "" : "s"} across ${s.repos} repo${s.repos === 1 ? "" : "s"} all still in play`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return [
|
|
105
|
+
`would reclaim ${color.bold(String(s.worktreesReclaimable))} worktree${s.worktreesReclaimable === 1 ? "" : "s"}`,
|
|
106
|
+
`(${color.bold(formatKb(s.reclaimKb))})`,
|
|
107
|
+
`and delete ${color.bold(String(s.branchesDeletable))} branch${s.branchesDeletable === 1 ? "" : "es"}`,
|
|
108
|
+
color.gray(`· ${s.worktreesKept} kept`),
|
|
109
|
+
].join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What actually happened, after `--apply`. */
|
|
113
|
+
export function renderApply(result: ApplyResult, plan: Plan): string {
|
|
114
|
+
const out: string[] = [];
|
|
115
|
+
|
|
116
|
+
for (const r of result.removedWorktrees) {
|
|
117
|
+
const parts = [
|
|
118
|
+
color.green("reclaimed"),
|
|
119
|
+
shortenPath(r.path),
|
|
120
|
+
r.branch ? color.cyan(`[${r.branch}]`) : "",
|
|
121
|
+
color.yellow(formatKb(r.sizeKb)),
|
|
122
|
+
color.gray(`— ${r.reason}`),
|
|
123
|
+
].filter((p) => p !== "");
|
|
124
|
+
out.push(parts.join(" "));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const branchOnly = result.deletedBranches.filter(
|
|
128
|
+
(b) => !result.removedWorktrees.some((w) => w.branch === b.name && w.repo === b.repo),
|
|
129
|
+
);
|
|
130
|
+
if (branchOnly.length > 0) {
|
|
131
|
+
out.push(
|
|
132
|
+
`${color.green("deleted")} ${branchOnly.length} branch${branchOnly.length === 1 ? "" : "es"} ${color.gray(
|
|
133
|
+
branchOnly.slice(0, 8).map((b) => b.name).join(" "),
|
|
134
|
+
)}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const archived = result.archives.filter((a) => a.bundle || a.patch || a.untracked);
|
|
139
|
+
if (archived.length > 0) {
|
|
140
|
+
out.push("");
|
|
141
|
+
out.push(color.gray(`archived unique work from ${archived.length} of them:`));
|
|
142
|
+
for (const a of archived.slice(0, 10)) {
|
|
143
|
+
const bits = [
|
|
144
|
+
a.bundle ? `${a.bundle.commits} commit${a.bundle.commits === 1 ? "" : "s"}` : null,
|
|
145
|
+
a.patch ? "uncommitted diff" : null,
|
|
146
|
+
a.untracked ? `${a.untracked.count} untracked` : null,
|
|
147
|
+
].filter(Boolean);
|
|
148
|
+
out.push(color.gray(` ${shortenPath(a.dir)} (${bits.join(", ")})`));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const f of result.failures) {
|
|
153
|
+
out.push(color.yellow(`kept ${shortenPath(f.target)} — ${f.message}`));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (out.length > 0) out.push("");
|
|
157
|
+
|
|
158
|
+
const nothing = result.removedWorktrees.length === 0 && result.deletedBranches.length === 0;
|
|
159
|
+
out.push(
|
|
160
|
+
nothing
|
|
161
|
+
? color.green("nothing to clean")
|
|
162
|
+
: `${color.bold("reclaimed")} ${formatKb(result.reclaimedKb)} · ` +
|
|
163
|
+
`${result.removedWorktrees.length} worktree${result.removedWorktrees.length === 1 ? "" : "s"}, ` +
|
|
164
|
+
`${result.deletedBranches.length} branch${result.deletedBranches.length === 1 ? "" : "es"} · ` +
|
|
165
|
+
color.gray(`${plan.summary.worktreesKept} kept`),
|
|
166
|
+
);
|
|
167
|
+
return out.join("\n");
|
|
168
|
+
}
|
package/src/core/scan.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isGitRepo, mainRepoPath } from "./git.js";
|
|
4
|
+
import { isInside, matchAnyGlob } from "../util/paths.js";
|
|
5
|
+
import type { Logger } from "../util/log.js";
|
|
6
|
+
|
|
7
|
+
/** Directories never worth descending into when hunting for repos. */
|
|
8
|
+
const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules", ".git", ".svn", ".hg", "vendor", "target", "dist", "build",
|
|
10
|
+
".next", ".nuxt", ".venv", "venv", "__pycache__", ".cache", ".pnpm-store",
|
|
11
|
+
"Library", "Applications", ".Trash", ".cargo", ".rustup", ".gradle", ".m2",
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export interface ScanOptions {
|
|
15
|
+
roots: string[];
|
|
16
|
+
maxDepth: number;
|
|
17
|
+
protectPaths?: string[];
|
|
18
|
+
logger?: Logger;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Find the main worktree of every git repo under `roots`.
|
|
23
|
+
*
|
|
24
|
+
* A linked worktree found during the walk resolves to its parent clone, so a
|
|
25
|
+
* repo is discovered whether the root holds the clone, the worktrees, or both.
|
|
26
|
+
*/
|
|
27
|
+
export async function discoverRepos(opts: ScanOptions): Promise<string[]> {
|
|
28
|
+
const found = new Set<string>();
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
|
|
31
|
+
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
32
|
+
if (seen.has(dir)) return;
|
|
33
|
+
seen.add(dir);
|
|
34
|
+
if (opts.protectPaths && matchAnyGlob(opts.protectPaths, dir)) return;
|
|
35
|
+
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
39
|
+
} catch (err) {
|
|
40
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
41
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "ENOTDIR" && code !== "EPERM") {
|
|
42
|
+
opts.logger?.debug(`scan: cannot read ${dir}: ${(err as Error).message}`);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hasGit = entries.some((e) => e.name === ".git");
|
|
48
|
+
if (hasGit) {
|
|
49
|
+
const main = await mainRepoPath(dir);
|
|
50
|
+
if (main) {
|
|
51
|
+
found.add(main);
|
|
52
|
+
// Stop here: `git worktree list` from the main repo enumerates the rest,
|
|
53
|
+
// and nested repos inside a checkout are not ours to garbage-collect.
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (depth >= opts.maxDepth) return;
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
61
|
+
if (entry.name.startsWith(".") && entry.name !== ".worktrees") continue;
|
|
62
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
63
|
+
const child = join(dir, entry.name);
|
|
64
|
+
if (entry.isSymbolicLink()) {
|
|
65
|
+
// Follow a symlinked root one level, but never let a loop trap the walk.
|
|
66
|
+
try {
|
|
67
|
+
const st = await stat(child);
|
|
68
|
+
if (!st.isDirectory()) continue;
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await walk(child, depth + 1);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (const root of opts.roots) {
|
|
78
|
+
await walk(root, 0);
|
|
79
|
+
}
|
|
80
|
+
return [...found].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the repos implied by explicit CLI paths. A path inside a repo or
|
|
85
|
+
* worktree resolves to that repo; a plain directory is scanned shallowly.
|
|
86
|
+
*/
|
|
87
|
+
export async function reposFromPaths(paths: string[], opts: Omit<ScanOptions, "roots">): Promise<string[]> {
|
|
88
|
+
const out = new Set<string>();
|
|
89
|
+
for (const p of paths) {
|
|
90
|
+
if (await isGitRepo(p)) {
|
|
91
|
+
const main = await mainRepoPath(p);
|
|
92
|
+
if (main) {
|
|
93
|
+
out.add(main);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const repo of await discoverRepos({ ...opts, roots: [p] })) out.add(repo);
|
|
98
|
+
}
|
|
99
|
+
return [...out].sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when any root contains the given path — used to sanity-check config. */
|
|
103
|
+
export function underAnyRoot(roots: string[], path: string): boolean {
|
|
104
|
+
return roots.some((r) => isInside(r, path));
|
|
105
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Config, SessionCommand } from "./config.js";
|
|
5
|
+
import { run } from "../util/exec.js";
|
|
6
|
+
import type { Logger } from "../util/log.js";
|
|
7
|
+
import { expandPath, isInside } from "../util/paths.js";
|
|
8
|
+
import { parseDuration } from "../util/time.js";
|
|
9
|
+
|
|
10
|
+
export interface ActivityHit {
|
|
11
|
+
/** Which detector saw it: "claude-code", "process", "mtime", … */
|
|
12
|
+
detector: string;
|
|
13
|
+
/** Epoch ms of the most recent activity. */
|
|
14
|
+
lastActivity: number;
|
|
15
|
+
detail: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ActivityContext {
|
|
19
|
+
now: number;
|
|
20
|
+
graceMs: number;
|
|
21
|
+
detectors: Set<string>;
|
|
22
|
+
extraPaths: string[];
|
|
23
|
+
commands: SessionCommand[];
|
|
24
|
+
logger?: Logger;
|
|
25
|
+
/** Absolute paths that are the cwd of some live process; null when unknown. */
|
|
26
|
+
processCwds: string[] | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Directories whose contents say nothing about whether an agent is working. */
|
|
30
|
+
const MTIME_SKIP = new Set([
|
|
31
|
+
"node_modules", ".git", "dist", "build", "target", ".next", ".nuxt", ".venv",
|
|
32
|
+
"venv", "__pycache__", ".cache", "coverage", ".turbo", ".pytest_cache",
|
|
33
|
+
".gradle", ".mypy_cache", ".ruff_cache", "vendor", ".terraform",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const MTIME_MAX_ENTRIES = 4_000;
|
|
37
|
+
const MTIME_MAX_DEPTH = 3;
|
|
38
|
+
|
|
39
|
+
export async function createActivityContext(
|
|
40
|
+
cfg: Config,
|
|
41
|
+
logger?: Logger,
|
|
42
|
+
now = Date.now(),
|
|
43
|
+
): Promise<ActivityContext> {
|
|
44
|
+
const detectors = new Set(cfg.sessions.detectors);
|
|
45
|
+
const ctx: ActivityContext = {
|
|
46
|
+
now,
|
|
47
|
+
graceMs: parseDuration(cfg.sessions.grace),
|
|
48
|
+
detectors,
|
|
49
|
+
extraPaths: cfg.sessions.extraPaths,
|
|
50
|
+
commands: cfg.sessions.commands,
|
|
51
|
+
logger,
|
|
52
|
+
processCwds: null,
|
|
53
|
+
};
|
|
54
|
+
if (detectors.has("process")) {
|
|
55
|
+
ctx.processCwds = await loadProcessCwds(logger);
|
|
56
|
+
}
|
|
57
|
+
return ctx;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Is an agent (or a human) still working in this worktree?
|
|
62
|
+
*
|
|
63
|
+
* Detectors are deliberately layered: the harness-specific ones give a precise
|
|
64
|
+
* answer with a timestamp, while `process` and `mtime` are the catch-all that
|
|
65
|
+
* makes this work with harnesses treekeeper has never heard of.
|
|
66
|
+
*/
|
|
67
|
+
export async function detectActivity(
|
|
68
|
+
ctx: ActivityContext,
|
|
69
|
+
worktreePath: string,
|
|
70
|
+
): Promise<ActivityHit | null> {
|
|
71
|
+
const cutoff = ctx.now - ctx.graceMs;
|
|
72
|
+
const hits: ActivityHit[] = [];
|
|
73
|
+
|
|
74
|
+
if (ctx.detectors.has("process") && ctx.processCwds) {
|
|
75
|
+
const match = ctx.processCwds.find((p) => isInside(worktreePath, p));
|
|
76
|
+
if (match) {
|
|
77
|
+
hits.push({
|
|
78
|
+
detector: "process",
|
|
79
|
+
lastActivity: ctx.now,
|
|
80
|
+
detail: "a running process is sitting in this directory",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (ctx.detectors.has("claude-code")) {
|
|
86
|
+
const hit = await claudeCodeActivity(worktreePath, cutoff);
|
|
87
|
+
if (hit) hits.push(hit);
|
|
88
|
+
}
|
|
89
|
+
if (ctx.detectors.has("codex")) {
|
|
90
|
+
const hit = await codexActivity(worktreePath, cutoff, ctx);
|
|
91
|
+
if (hit) hits.push(hit);
|
|
92
|
+
}
|
|
93
|
+
if (ctx.detectors.has("aider")) {
|
|
94
|
+
const hit = await newestFileIn(
|
|
95
|
+
worktreePath,
|
|
96
|
+
[".aider.chat.history.md", ".aider.input.history"],
|
|
97
|
+
"aider",
|
|
98
|
+
cutoff,
|
|
99
|
+
);
|
|
100
|
+
if (hit) hits.push(hit);
|
|
101
|
+
}
|
|
102
|
+
if (ctx.extraPaths.length > 0) {
|
|
103
|
+
const paths = ctx.extraPaths.map((p) =>
|
|
104
|
+
expandPath(p.replaceAll("{worktree}", worktreePath)),
|
|
105
|
+
);
|
|
106
|
+
const hit = await newestPath(paths, "extra-path", cutoff);
|
|
107
|
+
if (hit) hits.push(hit);
|
|
108
|
+
}
|
|
109
|
+
if (ctx.detectors.has("mtime")) {
|
|
110
|
+
const hit = await mtimeActivity(worktreePath, cutoff);
|
|
111
|
+
if (hit) hits.push(hit);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const cmd of ctx.commands) {
|
|
115
|
+
const r = await run("sh", ["-c", cmd.command], {
|
|
116
|
+
timeoutMs: 15_000,
|
|
117
|
+
env: { ...process.env, TREEKEEPER_WORKTREE: worktreePath },
|
|
118
|
+
cwd: undefined,
|
|
119
|
+
});
|
|
120
|
+
if (r.code === 0) {
|
|
121
|
+
hits.push({
|
|
122
|
+
detector: cmd.name,
|
|
123
|
+
lastActivity: ctx.now,
|
|
124
|
+
detail: `custom check "${cmd.name}" reported activity`,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (hits.length === 0) return null;
|
|
130
|
+
hits.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
131
|
+
return hits[0]!;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `true` when the hit falls inside the session grace window. */
|
|
135
|
+
export function isActive(ctx: ActivityContext, hit: ActivityHit | null): boolean {
|
|
136
|
+
if (!hit) return false;
|
|
137
|
+
return hit.lastActivity >= ctx.now - ctx.graceMs;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- harness-specific detectors --------------------------------------------
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Claude Code stores one JSONL transcript per session under
|
|
144
|
+
* `~/.claude/projects/<slugified-cwd>/`, so the newest transcript mtime is the
|
|
145
|
+
* last time an agent did anything in that worktree.
|
|
146
|
+
*/
|
|
147
|
+
export function claudeProjectSlugs(worktreePath: string): string[] {
|
|
148
|
+
const all = worktreePath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
149
|
+
const slashesOnly = worktreePath.replace(/\//g, "-");
|
|
150
|
+
return all === slashesOnly ? [all] : [all, slashesOnly];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function claudeCodeActivity(worktreePath: string, cutoff: number): Promise<ActivityHit | null> {
|
|
154
|
+
const base = process.env.CLAUDE_CONFIG_DIR
|
|
155
|
+
? expandPath(process.env.CLAUDE_CONFIG_DIR)
|
|
156
|
+
: join(homedir(), ".claude");
|
|
157
|
+
for (const slug of claudeProjectSlugs(worktreePath)) {
|
|
158
|
+
const dir = join(base, "projects", slug);
|
|
159
|
+
const hit = await newestInDir(dir, ".jsonl", "claude-code", cutoff);
|
|
160
|
+
if (hit) return hit;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Codex CLI writes rollout transcripts under `~/.codex/sessions/<y>/<m>/<d>/`.
|
|
167
|
+
* Only recently-modified files are opened, and only their head is read, so the
|
|
168
|
+
* cwd match stays cheap even with thousands of archived sessions.
|
|
169
|
+
*/
|
|
170
|
+
async function codexActivity(
|
|
171
|
+
worktreePath: string,
|
|
172
|
+
cutoff: number,
|
|
173
|
+
ctx: ActivityContext,
|
|
174
|
+
): Promise<ActivityHit | null> {
|
|
175
|
+
const root = join(homedir(), ".codex", "sessions");
|
|
176
|
+
const recent: Array<{ path: string; mtime: number }> = [];
|
|
177
|
+
|
|
178
|
+
const walk = async (dir: string, depth: number): Promise<void> => {
|
|
179
|
+
if (depth > 4 || recent.length >= 40) return;
|
|
180
|
+
let entries;
|
|
181
|
+
try {
|
|
182
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
183
|
+
} catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
for (const e of entries) {
|
|
187
|
+
const p = join(dir, e.name);
|
|
188
|
+
if (e.isDirectory()) {
|
|
189
|
+
await walk(p, depth + 1);
|
|
190
|
+
} else if (e.isFile() && e.name.endsWith(".jsonl")) {
|
|
191
|
+
try {
|
|
192
|
+
const st = await stat(p);
|
|
193
|
+
if (st.mtimeMs >= cutoff) recent.push({ path: p, mtime: st.mtimeMs });
|
|
194
|
+
} catch {
|
|
195
|
+
/* vanished mid-walk */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
await walk(root, 0);
|
|
201
|
+
|
|
202
|
+
recent.sort((a, b) => b.mtime - a.mtime);
|
|
203
|
+
for (const file of recent) {
|
|
204
|
+
try {
|
|
205
|
+
const handle = await readFile(file.path, "utf8");
|
|
206
|
+
const head = handle.slice(0, 8_000);
|
|
207
|
+
if (head.includes(`"cwd":"${worktreePath}"`) || head.includes(`"cwd": "${worktreePath}"`)) {
|
|
208
|
+
return {
|
|
209
|
+
detector: "codex",
|
|
210
|
+
lastActivity: file.mtime,
|
|
211
|
+
detail: "a Codex session transcript was updated",
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
ctx.logger?.debug(`codex detector: ${(err as Error).message}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- generic detectors ------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Last-resort signal that covers every harness: did anything inside the
|
|
225
|
+
* worktree change recently? Bounded in depth and entry count so a sweep over
|
|
226
|
+
* dozens of worktrees stays fast.
|
|
227
|
+
*/
|
|
228
|
+
async function mtimeActivity(worktreePath: string, cutoff: number): Promise<ActivityHit | null> {
|
|
229
|
+
let visited = 0;
|
|
230
|
+
let newest = 0;
|
|
231
|
+
let newestPathSeen = "";
|
|
232
|
+
|
|
233
|
+
const walk = async (dir: string, depth: number): Promise<boolean> => {
|
|
234
|
+
if (visited >= MTIME_MAX_ENTRIES) return false;
|
|
235
|
+
let entries;
|
|
236
|
+
try {
|
|
237
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
for (const e of entries) {
|
|
242
|
+
if (visited >= MTIME_MAX_ENTRIES) return false;
|
|
243
|
+
visited += 1;
|
|
244
|
+
if (MTIME_SKIP.has(e.name)) continue;
|
|
245
|
+
const p = join(dir, e.name);
|
|
246
|
+
if (e.isDirectory()) {
|
|
247
|
+
if (depth < MTIME_MAX_DEPTH && (await walk(p, depth + 1))) return true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!e.isFile()) continue;
|
|
251
|
+
try {
|
|
252
|
+
const st = await stat(p);
|
|
253
|
+
if (st.mtimeMs > newest) {
|
|
254
|
+
newest = st.mtimeMs;
|
|
255
|
+
newestPathSeen = e.name;
|
|
256
|
+
}
|
|
257
|
+
if (st.mtimeMs >= cutoff) return true; // early exit: already "active"
|
|
258
|
+
} catch {
|
|
259
|
+
/* ignore */
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const active = await walk(worktreePath, 0);
|
|
266
|
+
if (!active && newest < cutoff) return null;
|
|
267
|
+
return {
|
|
268
|
+
detector: "mtime",
|
|
269
|
+
lastActivity: newest,
|
|
270
|
+
detail: `files changed recently (${newestPathSeen})`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function loadProcessCwds(logger?: Logger): Promise<string[] | null> {
|
|
275
|
+
if (process.platform === "win32") return null;
|
|
276
|
+
const r = await run("lsof", ["-d", "cwd", "-Fn"], { timeoutMs: 30_000 });
|
|
277
|
+
if (r.code !== 0 && r.stdout.trim() === "") {
|
|
278
|
+
logger?.debug("process detector: lsof unavailable, skipping");
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const out = new Set<string>();
|
|
282
|
+
for (const line of r.stdout.split("\n")) {
|
|
283
|
+
if (line.startsWith("n/")) out.add(line.slice(1));
|
|
284
|
+
}
|
|
285
|
+
return [...out];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function newestInDir(
|
|
289
|
+
dir: string,
|
|
290
|
+
suffix: string,
|
|
291
|
+
detector: string,
|
|
292
|
+
cutoff: number,
|
|
293
|
+
): Promise<ActivityHit | null> {
|
|
294
|
+
let entries;
|
|
295
|
+
try {
|
|
296
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
let newest = 0;
|
|
301
|
+
for (const e of entries) {
|
|
302
|
+
if (!e.isFile() || !e.name.endsWith(suffix)) continue;
|
|
303
|
+
try {
|
|
304
|
+
const st = await stat(join(dir, e.name));
|
|
305
|
+
if (st.mtimeMs > newest) newest = st.mtimeMs;
|
|
306
|
+
} catch {
|
|
307
|
+
/* ignore */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (newest === 0 || newest < cutoff) return null;
|
|
311
|
+
return { detector, lastActivity: newest, detail: "a session transcript was updated" };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function newestFileIn(
|
|
315
|
+
base: string,
|
|
316
|
+
names: string[],
|
|
317
|
+
detector: string,
|
|
318
|
+
cutoff: number,
|
|
319
|
+
): Promise<ActivityHit | null> {
|
|
320
|
+
return newestPath(names.map((n) => join(base, n)), detector, cutoff);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function newestPath(paths: string[], detector: string, cutoff: number): Promise<ActivityHit | null> {
|
|
324
|
+
let newest = 0;
|
|
325
|
+
let which = "";
|
|
326
|
+
for (const p of paths) {
|
|
327
|
+
try {
|
|
328
|
+
const st = await stat(p);
|
|
329
|
+
if (st.mtimeMs > newest) {
|
|
330
|
+
newest = st.mtimeMs;
|
|
331
|
+
which = p;
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
/* missing is fine */
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (newest === 0 || newest < cutoff) return null;
|
|
338
|
+
return { detector, lastActivity: newest, detail: `${which} was updated` };
|
|
339
|
+
}
|
package/src/core/size.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { run } from "../util/exec.js";
|
|
2
|
+
|
|
3
|
+
/** Disk usage in KB. Uses `du` (fast, counts real blocks); null when unavailable. */
|
|
4
|
+
export async function dirSizeKb(path: string): Promise<number | null> {
|
|
5
|
+
const r = await run("du", ["-sk", path], { timeoutMs: 120_000 });
|
|
6
|
+
if (r.code !== 0 && r.stdout.trim() === "") return null;
|
|
7
|
+
const first = r.stdout.trim().split("\n")[0] ?? "";
|
|
8
|
+
const kb = Number(first.split(/\s+/)[0]);
|
|
9
|
+
return Number.isFinite(kb) ? kb : null;
|
|
10
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { treekeeperHome } from "../util/paths.js";
|
|
4
|
+
|
|
5
|
+
const LOCK = "sweep.lock";
|
|
6
|
+
|
|
7
|
+
export interface Lock {
|
|
8
|
+
release(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function lockPath(): string {
|
|
12
|
+
return join(treekeeperHome(), LOCK);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function alive(pid: number): boolean {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(pid, 0);
|
|
18
|
+
return true;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Take the machine-wide sweep lock, so a scheduled sweep and a manual
|
|
26
|
+
* `treekeeper gc --apply` can never remove the same worktree twice.
|
|
27
|
+
* Returns null when another sweep holds it.
|
|
28
|
+
*/
|
|
29
|
+
export async function acquireLock(): Promise<Lock | null> {
|
|
30
|
+
const path = lockPath();
|
|
31
|
+
await mkdir(treekeeperHome(), { recursive: true });
|
|
32
|
+
|
|
33
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
34
|
+
try {
|
|
35
|
+
await writeFile(path, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: "wx" });
|
|
36
|
+
return {
|
|
37
|
+
release: async () => {
|
|
38
|
+
try {
|
|
39
|
+
await unlink(path);
|
|
40
|
+
} catch {
|
|
41
|
+
/* already gone */
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
} catch (err) {
|
|
46
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
47
|
+
// Stale lock from a killed process? Clear it once and retry.
|
|
48
|
+
let holder: number | null = null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
51
|
+
const pid = (parsed as { pid?: unknown }).pid;
|
|
52
|
+
holder = typeof pid === "number" ? pid : null;
|
|
53
|
+
} catch {
|
|
54
|
+
holder = null;
|
|
55
|
+
}
|
|
56
|
+
if (holder !== null && alive(holder)) return null;
|
|
57
|
+
try {
|
|
58
|
+
await unlink(path);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|