rl-workspaces 0.0.0-stage → 0.5.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/LICENSE +21 -0
- package/README.md +703 -2
- package/bin/rl +54 -0
- package/config.example.zsh +9 -0
- package/dist/access.js +40 -0
- package/dist/activity.js +90 -0
- package/dist/agent-claude.js +16 -0
- package/dist/agent-codex.js +22 -0
- package/dist/agent-launch.js +74 -0
- package/dist/agent-presets.js +45 -0
- package/dist/agent-registry.js +8 -0
- package/dist/agent-resume.js +96 -0
- package/dist/agent-runner.js +366 -0
- package/dist/agent-workflows.js +231 -0
- package/dist/agents.js +73 -0
- package/dist/automation-config.js +242 -0
- package/dist/base.js +66 -0
- package/dist/cleanup-path.js +33 -0
- package/dist/cli.js +57 -0
- package/dist/codex-sessions.js +188 -0
- package/dist/command-context.js +67 -0
- package/dist/common.js +262 -0
- package/dist/delete.js +113 -0
- package/dist/display.js +134 -0
- package/dist/doctor.js +145 -0
- package/dist/git-safety.js +24 -0
- package/dist/git-workflow.js +183 -0
- package/dist/help.js +726 -0
- package/dist/hooks.js +159 -0
- package/dist/json.js +34 -0
- package/dist/mcp.js +336 -0
- package/dist/picker.js +182 -0
- package/dist/pr-discovery.js +223 -0
- package/dist/pr-update.js +419 -0
- package/dist/pr-workflow.js +161 -0
- package/dist/repos.js +537 -0
- package/dist/review-policy.js +150 -0
- package/dist/session-workflows.js +616 -0
- package/dist/session.js +12 -0
- package/dist/state.js +250 -0
- package/dist/storage.js +366 -0
- package/dist/store.js +379 -0
- package/dist/summary.js +164 -0
- package/dist/task-state.js +65 -0
- package/dist/task.js +192 -0
- package/dist/templates.js +4 -0
- package/dist/workflow-config.js +177 -0
- package/dist/workflow.js +211 -0
- package/lib/rl.zsh +330 -0
- package/node_modules/graceful-fs/LICENSE +15 -0
- package/node_modules/graceful-fs/README.md +143 -0
- package/node_modules/graceful-fs/clone.js +23 -0
- package/node_modules/graceful-fs/graceful-fs.js +448 -0
- package/node_modules/graceful-fs/legacy-streams.js +118 -0
- package/node_modules/graceful-fs/package.json +53 -0
- package/node_modules/graceful-fs/polyfills.js +355 -0
- package/node_modules/proper-lockfile/LICENSE +21 -0
- package/node_modules/proper-lockfile/README.md +183 -0
- package/node_modules/proper-lockfile/index.js +40 -0
- package/node_modules/proper-lockfile/lib/adapter.js +85 -0
- package/node_modules/proper-lockfile/lib/lockfile.js +342 -0
- package/node_modules/proper-lockfile/lib/mtime-precision.js +55 -0
- package/node_modules/proper-lockfile/package.json +71 -0
- package/node_modules/retry/.npmignore +3 -0
- package/node_modules/retry/.travis.yml +15 -0
- package/node_modules/retry/License +21 -0
- package/node_modules/retry/Makefile +18 -0
- package/node_modules/retry/README.md +227 -0
- package/node_modules/retry/equation.gif +0 -0
- package/node_modules/retry/example/dns.js +31 -0
- package/node_modules/retry/example/stop.js +40 -0
- package/node_modules/retry/index.js +1 -0
- package/node_modules/retry/lib/retry.js +100 -0
- package/node_modules/retry/lib/retry_operation.js +158 -0
- package/node_modules/retry/package.json +32 -0
- package/node_modules/retry/test/common.js +10 -0
- package/node_modules/retry/test/integration/test-forever.js +24 -0
- package/node_modules/retry/test/integration/test-retry-operation.js +258 -0
- package/node_modules/retry/test/integration/test-retry-wrap.js +101 -0
- package/node_modules/retry/test/integration/test-timeouts.js +69 -0
- package/node_modules/signal-exit/LICENSE.txt +16 -0
- package/node_modules/signal-exit/README.md +39 -0
- package/node_modules/signal-exit/index.js +202 -0
- package/node_modules/signal-exit/package.json +38 -0
- package/node_modules/signal-exit/signals.js +53 -0
- package/package.json +57 -4
- package/shell/rl.zsh +11 -0
package/dist/state.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { agentAdapter } from "./agent-registry.js";
|
|
2
|
+
import { activityView } from "./activity.js";
|
|
3
|
+
import { handleHook } from "./hooks.js";
|
|
4
|
+
import { doctor } from "./doctor.js";
|
|
5
|
+
import { currentSessionId } from "./session.js";
|
|
6
|
+
import { syncCodexSessions } from "./codex-sessions.js";
|
|
7
|
+
import { resumeSessions } from "./agent-resume.js";
|
|
8
|
+
import { workflowSummary } from "./agent-runner.js";
|
|
9
|
+
import { github } from "./pr-workflow.js";
|
|
10
|
+
import { prText, statusText } from "./display.js";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { args, arity, canonical, git, json, message, now, object, required, run, } from "./common.js";
|
|
14
|
+
import { branchStatus, Store } from "./store.js";
|
|
15
|
+
function agent(value) {
|
|
16
|
+
return agentAdapter(value).name;
|
|
17
|
+
}
|
|
18
|
+
async function syncPr(store, requested, asJson = false) {
|
|
19
|
+
const [file, data] = await store.locked(() => store.current());
|
|
20
|
+
const selector = requested || data.pr?.url || data.branch;
|
|
21
|
+
if (selector.startsWith("-"))
|
|
22
|
+
throw new Error("PR selector must be a branch, number, or URL");
|
|
23
|
+
const repository = JSON.parse(run(["gh", "repo", "view", "--json", "nameWithOwner,url"], data.worktree));
|
|
24
|
+
if (!object(repository) || typeof repository.url !== "string")
|
|
25
|
+
throw new Error("GitHub returned invalid repository metadata");
|
|
26
|
+
const repoUrl = repository.url.replace(/\/+$/, ""), parsed = new URL(repoUrl);
|
|
27
|
+
if (parsed.protocol !== "https:" ||
|
|
28
|
+
!parsed.host ||
|
|
29
|
+
!parsed.pathname.replaceAll("/", ""))
|
|
30
|
+
throw new Error("GitHub returned an invalid repository URL");
|
|
31
|
+
const prefix = repoUrl + "/pull/";
|
|
32
|
+
if (selector.includes("://") && !selector.startsWith(prefix))
|
|
33
|
+
throw new Error("PR URL does not belong to this GitHub repository");
|
|
34
|
+
const fields = "number,url,state,title,headRefName,baseRefName,isDraft,updatedAt";
|
|
35
|
+
const pr = JSON.parse(run([
|
|
36
|
+
"gh",
|
|
37
|
+
"pr",
|
|
38
|
+
"view",
|
|
39
|
+
selector,
|
|
40
|
+
"--repo",
|
|
41
|
+
parsed.host + parsed.pathname,
|
|
42
|
+
"--json",
|
|
43
|
+
fields,
|
|
44
|
+
], data.worktree));
|
|
45
|
+
if (!object(pr) || fields.split(",").some((field) => !(field in pr)))
|
|
46
|
+
throw new Error("GitHub returned incomplete PR metadata");
|
|
47
|
+
if (pr.headRefName !== data.branch)
|
|
48
|
+
throw new Error("PR head branch does not match the RL instance branch");
|
|
49
|
+
if (typeof pr.url !== "string" || !pr.url.startsWith(prefix))
|
|
50
|
+
throw new Error("PR does not belong to this GitHub repository");
|
|
51
|
+
pr.syncedAt = now();
|
|
52
|
+
await store.locked(() => {
|
|
53
|
+
const [currentFile, current] = store.current(data.worktree);
|
|
54
|
+
if (currentFile !== file)
|
|
55
|
+
throw new Error("Instance changed while fetching PR state");
|
|
56
|
+
current.pr = pr;
|
|
57
|
+
store.save(file, current);
|
|
58
|
+
});
|
|
59
|
+
if (asJson)
|
|
60
|
+
json(pr);
|
|
61
|
+
else
|
|
62
|
+
console.log(prText(pr));
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
async function openPr(store, asJson) {
|
|
66
|
+
const [, data] = await store.locked(() => store.current());
|
|
67
|
+
if (asJson) {
|
|
68
|
+
json(data.pr);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
if (data.pr) {
|
|
72
|
+
const url = new URL(data.pr.url);
|
|
73
|
+
if (url.protocol !== "https:" ||
|
|
74
|
+
url.username ||
|
|
75
|
+
url.password ||
|
|
76
|
+
!/\/pull\/[1-9]\d*$/.test(url.pathname))
|
|
77
|
+
throw new Error("Invalid saved PR URL; run rl pr sync to refresh");
|
|
78
|
+
console.log(prText(data.pr));
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
console.log(`Opening PR for ${data.branch}…`);
|
|
82
|
+
try {
|
|
83
|
+
const result = await github(data.worktree, [
|
|
84
|
+
"pr",
|
|
85
|
+
"view",
|
|
86
|
+
data.pr?.url ?? data.branch,
|
|
87
|
+
"--web",
|
|
88
|
+
]);
|
|
89
|
+
if (result.code)
|
|
90
|
+
throw new Error(result.stderr.trim() || "Could not open PR in the browser");
|
|
91
|
+
if (result.stdout)
|
|
92
|
+
process.stdout.write(result.stdout);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (!data.pr)
|
|
97
|
+
throw error;
|
|
98
|
+
console.error(`Could not open the browser: ${message(error)}. Use the PR link above.`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function main(argv) {
|
|
103
|
+
const { values, positionals } = args(argv, ["repo", "state-dir", "worktree", "base"], [
|
|
104
|
+
"quiet",
|
|
105
|
+
"fetch",
|
|
106
|
+
"json",
|
|
107
|
+
"repair",
|
|
108
|
+
"extended-hooks",
|
|
109
|
+
"latest",
|
|
110
|
+
"l",
|
|
111
|
+
"workspace",
|
|
112
|
+
], { latest: "l" });
|
|
113
|
+
const store = new Store(required(values, "repo"), required(values, "state-dir"));
|
|
114
|
+
const [command, ...rest] = positionals;
|
|
115
|
+
if ((values.latest || values.l) && command !== "resume")
|
|
116
|
+
throw new Error("--latest is only supported by rl resume");
|
|
117
|
+
if (command === "adopt") {
|
|
118
|
+
arity(rest, 0);
|
|
119
|
+
const [file, data] = await store.adopt(String(values.worktree ?? "."), typeof values.base === "string" ? values.base : undefined, values["extended-hooks"] ? true : undefined);
|
|
120
|
+
if (!values.quiet)
|
|
121
|
+
console.log(`RL instance: ${data.id}\nState: ${path.dirname(file)}\nSession hooks installed. In Codex, review and trust them with /hooks.`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
if (command === "resume") {
|
|
125
|
+
arity(rest, 0, 1);
|
|
126
|
+
return resumeSessions(store, rest[0] === undefined ? undefined : agent(rest[0]), { latest: !!(values.latest || values.l) });
|
|
127
|
+
}
|
|
128
|
+
if (command === "doctor") {
|
|
129
|
+
arity(rest, 0, 1);
|
|
130
|
+
if (values.repair) {
|
|
131
|
+
const [, data] = store.current();
|
|
132
|
+
await store.adopt(data.worktree);
|
|
133
|
+
}
|
|
134
|
+
return doctor(store, rest[0], !!values.json);
|
|
135
|
+
}
|
|
136
|
+
if (command === "hook") {
|
|
137
|
+
arity(rest, 2);
|
|
138
|
+
return handleHook(store, rest[0], rest[1]);
|
|
139
|
+
}
|
|
140
|
+
if (command === "pr" && rest[0] === "sync") {
|
|
141
|
+
arity(rest, 1, 2);
|
|
142
|
+
return syncPr(store, rest[1], !!values.json);
|
|
143
|
+
}
|
|
144
|
+
if (command === "pr" && (!rest.length || rest[0] === "open")) {
|
|
145
|
+
arity(rest, 0, rest[0] === "open" ? 1 : 0);
|
|
146
|
+
return openPr(store, !!values.json);
|
|
147
|
+
}
|
|
148
|
+
if (command === "status" || command === "instances")
|
|
149
|
+
arity(rest, 0);
|
|
150
|
+
if (command === "session" && rest[0] === "list") {
|
|
151
|
+
arity(rest, 1, 2);
|
|
152
|
+
if (rest[1])
|
|
153
|
+
agent(rest[1]);
|
|
154
|
+
}
|
|
155
|
+
if (command === "status" && values.fetch)
|
|
156
|
+
git(store.repo, "fetch", "--all", "--prune");
|
|
157
|
+
if (command === "status" ||
|
|
158
|
+
command === "instances" ||
|
|
159
|
+
(command === "session" && rest[0] === "list"))
|
|
160
|
+
await syncCodexSessions(store, command === "instances" ? undefined : process.cwd());
|
|
161
|
+
let replacement = "";
|
|
162
|
+
if (["context", "progress"].includes(command) &&
|
|
163
|
+
["set", "append"].includes(rest[0])) {
|
|
164
|
+
arity(rest, 2);
|
|
165
|
+
replacement = fs.readFileSync(rest[1] === "-" ? 0 : rest[1], "utf8");
|
|
166
|
+
}
|
|
167
|
+
return store.locked(() => {
|
|
168
|
+
if (command === "instances") {
|
|
169
|
+
arity(rest, 0);
|
|
170
|
+
json(store.records().map(([, data]) => ({
|
|
171
|
+
...data,
|
|
172
|
+
gitStatus: branchStatus(store.repo, data),
|
|
173
|
+
})));
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
if (command === "archive") {
|
|
177
|
+
arity(rest, 0);
|
|
178
|
+
const root = canonical(required(values, "worktree"));
|
|
179
|
+
store.archiveWorktrees([root]);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
const [file, data] = store.current();
|
|
183
|
+
if (command === "status") {
|
|
184
|
+
arity(rest, 0);
|
|
185
|
+
const status = {
|
|
186
|
+
...data,
|
|
187
|
+
activity: {
|
|
188
|
+
codex: activityView(data.activity?.codex),
|
|
189
|
+
claude: activityView(data.activity?.claude),
|
|
190
|
+
},
|
|
191
|
+
stateDirectory: path.dirname(file),
|
|
192
|
+
gitStatus: branchStatus(store.repo, data),
|
|
193
|
+
workflow: workflowSummary(store),
|
|
194
|
+
};
|
|
195
|
+
if (values.json)
|
|
196
|
+
json(status);
|
|
197
|
+
else
|
|
198
|
+
console.log(statusText(data, status.gitStatus, status.workflow));
|
|
199
|
+
}
|
|
200
|
+
else if (command === "session") {
|
|
201
|
+
if (rest[0] === "save") {
|
|
202
|
+
arity(rest, 1, 3);
|
|
203
|
+
const kind = agent(rest[1] ?? "codex");
|
|
204
|
+
const sid = currentSessionId(kind, rest[2]);
|
|
205
|
+
store.recordSession(file, data, kind, sid);
|
|
206
|
+
if (values.json)
|
|
207
|
+
json({ agent: kind, sessionId: sid, instance: data.id });
|
|
208
|
+
else
|
|
209
|
+
console.log(`Saved ${kind} session ${sid} for ${data.id}`);
|
|
210
|
+
}
|
|
211
|
+
else if (rest[0] === "add") {
|
|
212
|
+
arity(rest, 3);
|
|
213
|
+
const kind = agent(rest[1]);
|
|
214
|
+
store.recordSession(file, data, kind, rest[2]);
|
|
215
|
+
console.log(`Associated ${kind} session ${rest[2]} with ${data.id}`);
|
|
216
|
+
}
|
|
217
|
+
else if (rest[0] === "list") {
|
|
218
|
+
arity(rest, 1, 2);
|
|
219
|
+
json(rest[1] ? data.sessions[agent(rest[1])] : data.sessions);
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
throw new Error("Usage: rl session list [agent] | save [agent] [UUID] | add <agent> <UUID>");
|
|
223
|
+
}
|
|
224
|
+
else if (command === "context" || command === "progress") {
|
|
225
|
+
const target = path.join(path.dirname(file), command + ".md"), action = rest[0];
|
|
226
|
+
if (action === "path" || action === "show") {
|
|
227
|
+
arity(rest, 1);
|
|
228
|
+
if (action === "path")
|
|
229
|
+
console.log(target);
|
|
230
|
+
else
|
|
231
|
+
process.stdout.write(store.readDocument(file, command));
|
|
232
|
+
}
|
|
233
|
+
else if (action === "set" || action === "append") {
|
|
234
|
+
store.updateDocument(file, data, command, action, replacement);
|
|
235
|
+
}
|
|
236
|
+
else
|
|
237
|
+
throw new Error(`Usage: rl ${command} show|path|set <file>|append <file>`);
|
|
238
|
+
}
|
|
239
|
+
else if (command === "pr" && rest[0] === "show") {
|
|
240
|
+
arity(rest, 1);
|
|
241
|
+
if (values.json)
|
|
242
|
+
json(data.pr);
|
|
243
|
+
else
|
|
244
|
+
console.log(prText(data.pr));
|
|
245
|
+
}
|
|
246
|
+
else
|
|
247
|
+
throw new Error("Unknown state command");
|
|
248
|
+
return 0;
|
|
249
|
+
});
|
|
250
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { stripVTControlCharacters } from "node:util";
|
|
4
|
+
import { args, arity, canonical, git, inside, json, locked, message, } from "./common.js";
|
|
5
|
+
import { commandContext, contextOptions } from "./command-context.js";
|
|
6
|
+
import { lastAccessed } from "./access.js";
|
|
7
|
+
import { protectedCleanupName } from "./cleanup-path.js";
|
|
8
|
+
import { containedPath, readWorkflowConfig } from "./workflow-config.js";
|
|
9
|
+
import { assertReady, GIT_WORKFLOW_DIRECTORY, GIT_WORKFLOW_LOCK, } from "./git-safety.js";
|
|
10
|
+
const DEPENDENCIES = new Set(["node_modules", ".venv", "venv"]);
|
|
11
|
+
const BUILDS = new Set([
|
|
12
|
+
".next",
|
|
13
|
+
".nuxt",
|
|
14
|
+
".turbo",
|
|
15
|
+
".cache",
|
|
16
|
+
"dist",
|
|
17
|
+
"build",
|
|
18
|
+
"coverage",
|
|
19
|
+
".vite",
|
|
20
|
+
"out",
|
|
21
|
+
"target",
|
|
22
|
+
"__pycache__",
|
|
23
|
+
".pytest_cache",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".ruff_cache",
|
|
26
|
+
".parcel-cache",
|
|
27
|
+
".svelte-kit",
|
|
28
|
+
]);
|
|
29
|
+
const visible = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/g, " ");
|
|
30
|
+
function size(bytes) {
|
|
31
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (bytes >= 1024 && i < units.length - 1) {
|
|
34
|
+
bytes /= 1024;
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
return `${i ? bytes.toFixed(1) : bytes} ${units[i]}`;
|
|
38
|
+
}
|
|
39
|
+
function category(relative, clean) {
|
|
40
|
+
const parts = relative.split(path.sep);
|
|
41
|
+
if (parts.some((part) => DEPENDENCIES.has(part)))
|
|
42
|
+
return "dependenciesBytes";
|
|
43
|
+
if (parts.some((part) => BUILDS.has(part)) ||
|
|
44
|
+
clean.some((item) => relative === item || relative.startsWith(item + path.sep)))
|
|
45
|
+
return "buildsBytes";
|
|
46
|
+
return "otherBytes";
|
|
47
|
+
}
|
|
48
|
+
/** Read only this directory tree. Never follow symlinks or enter Git metadata. */
|
|
49
|
+
function measure(roots, clean, protect = false) {
|
|
50
|
+
const usage = {
|
|
51
|
+
allocatedBytes: 0,
|
|
52
|
+
logicalBytes: 0,
|
|
53
|
+
estimatedBytes: 0,
|
|
54
|
+
dependenciesBytes: 0,
|
|
55
|
+
buildsBytes: 0,
|
|
56
|
+
otherBytes: 0,
|
|
57
|
+
files: 0,
|
|
58
|
+
symlinks: 0,
|
|
59
|
+
largestPaths: [],
|
|
60
|
+
};
|
|
61
|
+
const largest = new Map();
|
|
62
|
+
const links = new Map();
|
|
63
|
+
const pending = [...roots];
|
|
64
|
+
while (pending.length) {
|
|
65
|
+
const item = pending.pop();
|
|
66
|
+
const name = path.basename(item.path);
|
|
67
|
+
if (protect && protectedCleanupName(name))
|
|
68
|
+
throw new Error(`Cleanup contains protected metadata or credentials: ${item.relative}`);
|
|
69
|
+
if (!protect && name === ".git")
|
|
70
|
+
continue;
|
|
71
|
+
const stat = fs.lstatSync(item.path);
|
|
72
|
+
const bytes = stat.blocks * 512;
|
|
73
|
+
const kind = category(item.relative, clean);
|
|
74
|
+
let counted = true;
|
|
75
|
+
if (stat.isDirectory()) {
|
|
76
|
+
for (const child of fs.readdirSync(item.path).sort().reverse())
|
|
77
|
+
pending.push({
|
|
78
|
+
path: path.join(item.path, child),
|
|
79
|
+
relative: path.join(item.relative, child),
|
|
80
|
+
});
|
|
81
|
+
usage.estimatedBytes += bytes;
|
|
82
|
+
}
|
|
83
|
+
else if (stat.isFile() || stat.isSymbolicLink()) {
|
|
84
|
+
if (stat.isSymbolicLink())
|
|
85
|
+
usage.symlinks++;
|
|
86
|
+
else
|
|
87
|
+
usage.files++;
|
|
88
|
+
usage.logicalBytes += stat.size;
|
|
89
|
+
if (stat.nlink > 1) {
|
|
90
|
+
const key = `${stat.dev}:${stat.ino}`;
|
|
91
|
+
const previous = links.get(key);
|
|
92
|
+
if (previous) {
|
|
93
|
+
previous.seen++;
|
|
94
|
+
counted = false;
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
links.set(key, { bytes, seen: 1, count: stat.nlink });
|
|
98
|
+
}
|
|
99
|
+
else
|
|
100
|
+
usage.estimatedBytes += bytes;
|
|
101
|
+
}
|
|
102
|
+
else if (protect)
|
|
103
|
+
throw new Error(`Cleanup contains a special file: ${item.relative}`);
|
|
104
|
+
if (counted) {
|
|
105
|
+
usage.allocatedBytes += bytes;
|
|
106
|
+
usage[kind] += bytes;
|
|
107
|
+
const top = item.relative.split(path.sep)[0];
|
|
108
|
+
if (top)
|
|
109
|
+
largest.set(top, (largest.get(top) ?? 0) + bytes);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
for (const link of links.values())
|
|
113
|
+
if (link.seen >= link.count)
|
|
114
|
+
usage.estimatedBytes += link.bytes;
|
|
115
|
+
usage.largestPaths = [...largest]
|
|
116
|
+
.map(([path, allocatedBytes]) => ({ path, allocatedBytes }))
|
|
117
|
+
.sort((a, b) => b.allocatedBytes - a.allocatedBytes || a.path.localeCompare(b.path));
|
|
118
|
+
return usage;
|
|
119
|
+
}
|
|
120
|
+
function roots(clean) {
|
|
121
|
+
const sorted = [...new Set(clean)].sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
122
|
+
return sorted.filter((item, index) => !sorted.slice(0, index).some((parent) => item.startsWith(parent + "/")));
|
|
123
|
+
}
|
|
124
|
+
function validateIgnored(workspace, relative) {
|
|
125
|
+
if (git(workspace, "--literal-pathspecs", "ls-files", "--cached", "-z", "--", relative))
|
|
126
|
+
throw new Error(`Cleanup path contains tracked files: ${relative}`);
|
|
127
|
+
try {
|
|
128
|
+
git(workspace, "check-ignore", "--no-index", "--", relative + "/");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error(`Cleanup path must be ignored by Git: ${relative}`);
|
|
132
|
+
}
|
|
133
|
+
if (git(workspace, "--literal-pathspecs", "ls-files", "--others", "--exclude-standard", "-z", "--", relative))
|
|
134
|
+
throw new Error(`Cleanup path contains non-ignored files: ${relative}`);
|
|
135
|
+
}
|
|
136
|
+
function plan(workspace, clean) {
|
|
137
|
+
return roots(clean).flatMap((relative) => {
|
|
138
|
+
const target = containedPath(workspace, relative);
|
|
139
|
+
if (!fs.existsSync(target))
|
|
140
|
+
return [];
|
|
141
|
+
const stat = fs.lstatSync(target);
|
|
142
|
+
if (!stat.isDirectory())
|
|
143
|
+
throw new Error(`Cleanup target must be a directory: ${relative}`);
|
|
144
|
+
validateIgnored(workspace, relative);
|
|
145
|
+
const cwd = canonical(process.cwd());
|
|
146
|
+
if (cwd === target || inside(cwd, target))
|
|
147
|
+
throw new Error(`Cleanup would remove the current directory: ${relative}. Change to the workspace root first`);
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
path: target,
|
|
151
|
+
relative,
|
|
152
|
+
dev: stat.dev,
|
|
153
|
+
ino: stat.ino,
|
|
154
|
+
usage: measure([{ path: target, relative }], clean, true),
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function verifyTarget(workspace, target) {
|
|
160
|
+
const current = containedPath(workspace, target.relative);
|
|
161
|
+
const stat = fs.lstatSync(current);
|
|
162
|
+
if (!stat.isDirectory() || stat.dev !== target.dev || stat.ino !== target.ino)
|
|
163
|
+
throw new Error(`Cleanup directory changed during inspection: ${target.relative}`);
|
|
164
|
+
validateIgnored(workspace, target.relative);
|
|
165
|
+
}
|
|
166
|
+
/** Stage within the current workspace before deletion; validation failures restore artifacts. */
|
|
167
|
+
function remove(workspace, targets, recheck) {
|
|
168
|
+
const holding = fs.mkdtempSync(path.join(workspace, ".rl-clean-"));
|
|
169
|
+
const holdingIdentity = fs.lstatSync(holding);
|
|
170
|
+
const holdingValid = () => {
|
|
171
|
+
try {
|
|
172
|
+
const current = fs.lstatSync(holding);
|
|
173
|
+
return (current.isDirectory() &&
|
|
174
|
+
current.dev === holdingIdentity.dev &&
|
|
175
|
+
current.ino === holdingIdentity.ino &&
|
|
176
|
+
canonical(holding) === holding);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const staged = [];
|
|
183
|
+
const removed = [];
|
|
184
|
+
try {
|
|
185
|
+
for (const target of targets) {
|
|
186
|
+
recheck();
|
|
187
|
+
verifyTarget(workspace, target);
|
|
188
|
+
const destination = path.join(holding, String(staged.length));
|
|
189
|
+
fs.renameSync(target.path, destination);
|
|
190
|
+
staged.push({ target, path: destination });
|
|
191
|
+
const stat = fs.lstatSync(destination);
|
|
192
|
+
if (stat.dev !== target.dev ||
|
|
193
|
+
stat.ino !== target.ino ||
|
|
194
|
+
!stat.isDirectory())
|
|
195
|
+
throw new Error("Cleanup directory changed while staging");
|
|
196
|
+
// Recheck credentials/metadata added while the initial plan was being built.
|
|
197
|
+
measure([{ path: destination, relative: target.relative }], [], true);
|
|
198
|
+
}
|
|
199
|
+
recheck();
|
|
200
|
+
for (const item of staged)
|
|
201
|
+
validateIgnored(workspace, item.target.relative);
|
|
202
|
+
for (const item of staged) {
|
|
203
|
+
recheck();
|
|
204
|
+
if (!holdingValid())
|
|
205
|
+
throw new Error("Cleanup staging directory changed");
|
|
206
|
+
const stagedIdentity = fs.lstatSync(item.path);
|
|
207
|
+
if (!stagedIdentity.isDirectory() ||
|
|
208
|
+
stagedIdentity.dev !== item.target.dev ||
|
|
209
|
+
stagedIdentity.ino !== item.target.ino)
|
|
210
|
+
throw new Error("Cleanup artifact changed before removal");
|
|
211
|
+
fs.rmSync(item.path, { recursive: true });
|
|
212
|
+
removed.push(item.target.relative);
|
|
213
|
+
}
|
|
214
|
+
return removed;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// A replaced staging path must never redirect rollback into another directory.
|
|
218
|
+
if (!holdingValid())
|
|
219
|
+
throw new Error(`${message(error)}. Staging path changed; inspect retained artifacts in the current workspace`);
|
|
220
|
+
for (const item of staged.reverse()) {
|
|
221
|
+
if (!fs.existsSync(item.path))
|
|
222
|
+
continue;
|
|
223
|
+
try {
|
|
224
|
+
containedPath(workspace, item.target.relative);
|
|
225
|
+
if (!fs.existsSync(item.target.path))
|
|
226
|
+
fs.renameSync(item.path, item.target.path);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* Preserve artifacts in the staging directory on restore failure. */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`${message(error)}${fs.readdirSync(holding).length ? `. Remaining artifacts retained at ${holding}` : ""}`);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
if (holdingValid() && !fs.readdirSync(holding).length)
|
|
236
|
+
fs.rmdirSync(holding);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function suggestions(data, usage, estimatedBytes, days) {
|
|
240
|
+
const result = [];
|
|
241
|
+
if (estimatedBytes)
|
|
242
|
+
result.push(`Run rl clean --dry-run to preview ${size(estimatedBytes)} of configured generated files.`);
|
|
243
|
+
else if (usage.dependenciesBytes || usage.buildsBytes)
|
|
244
|
+
result.push("Add generated directory paths to the clean list in this workspace's .rl/workflows.json.");
|
|
245
|
+
const accessed = lastAccessed(data);
|
|
246
|
+
if (accessed && Date.now() - Date.parse(accessed) >= days * 86_400_000)
|
|
247
|
+
result.push(`This workspace has not been used for at least ${days} days.`);
|
|
248
|
+
if (String(data.pr?.state).toUpperCase() === "MERGED")
|
|
249
|
+
result.push("The cached PR is merged. Consider deleting this workspace when finished.");
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
export async function main(argv) {
|
|
253
|
+
const { values, positionals } = args(argv, [...contextOptions, "older-than"], ["json", "dry-run"]);
|
|
254
|
+
const [command, ...rest] = positionals;
|
|
255
|
+
arity(rest, 0);
|
|
256
|
+
if (!["storage", "clean"].includes(command) ||
|
|
257
|
+
(command === "storage" && values["dry-run"]) ||
|
|
258
|
+
(command === "clean" && values["older-than"] !== undefined))
|
|
259
|
+
throw new Error("Usage: rl storage [--json] [--older-than 14d] | rl clean [--dry-run] [--json] (current workspace only)");
|
|
260
|
+
const context = commandContext(values);
|
|
261
|
+
// Resolve from the caller's cwd before any scan, lock creation, or mutation.
|
|
262
|
+
const [file, data] = context.store.current();
|
|
263
|
+
const workspaceIdentity = fs.lstatSync(data.worktree);
|
|
264
|
+
const clean = readWorkflowConfig(data.worktree).clean;
|
|
265
|
+
const workspace = { id: data.id, branch: data.branch, path: data.worktree };
|
|
266
|
+
const days = Number(String(values["older-than"] ?? "14d").replace(/d$/, ""));
|
|
267
|
+
if (!Number.isInteger(days) || days < 1 || days > 36500)
|
|
268
|
+
throw new Error("--older-than must be a positive number of days, such as 14d");
|
|
269
|
+
if (command === "storage") {
|
|
270
|
+
const usage = measure(fs.readdirSync(data.worktree).map((name) => ({
|
|
271
|
+
path: path.join(data.worktree, name),
|
|
272
|
+
relative: name,
|
|
273
|
+
})), clean);
|
|
274
|
+
let targets = [], cleanupError = null;
|
|
275
|
+
try {
|
|
276
|
+
targets = plan(data.worktree, clean);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
cleanupError = message(error);
|
|
280
|
+
}
|
|
281
|
+
const estimatedBytes = targets.length
|
|
282
|
+
? measure(targets.map((target) => ({
|
|
283
|
+
path: target.path,
|
|
284
|
+
relative: target.relative,
|
|
285
|
+
})), clean, true).estimatedBytes
|
|
286
|
+
: 0;
|
|
287
|
+
const report = {
|
|
288
|
+
workspace,
|
|
289
|
+
usage,
|
|
290
|
+
clean: {
|
|
291
|
+
configuredPaths: clean,
|
|
292
|
+
paths: targets.map((target) => target.relative),
|
|
293
|
+
estimatedBytes,
|
|
294
|
+
error: cleanupError,
|
|
295
|
+
},
|
|
296
|
+
lastAccessedAt: lastAccessed(data) ?? null,
|
|
297
|
+
suggestions: suggestions(data, usage, estimatedBytes, days),
|
|
298
|
+
};
|
|
299
|
+
if (values.json)
|
|
300
|
+
json(report);
|
|
301
|
+
else {
|
|
302
|
+
console.log(`Storage: ${visible(data.id)} (${visible(data.branch)})\n${visible(data.worktree)}\n\nTotal: ${size(usage.allocatedBytes)}\nDependencies: ${size(usage.dependenciesBytes)}\nBuilds / caches: ${size(usage.buildsBytes)}\nOther: ${size(usage.otherBytes)}\nConfigured cleanup: ${size(estimatedBytes)} estimated`);
|
|
303
|
+
console.log("\nLargest paths:");
|
|
304
|
+
for (const item of usage.largestPaths.slice(0, 8))
|
|
305
|
+
console.log(` ${size(item.allocatedBytes).padStart(12)} ${visible(item.path)}`);
|
|
306
|
+
if (cleanupError)
|
|
307
|
+
console.log(`\nCleanup unavailable: ${visible(cleanupError)}`);
|
|
308
|
+
for (const item of report.suggestions)
|
|
309
|
+
console.log(`\n${item}`);
|
|
310
|
+
console.log("\nApproximate sizes; filesystem sharing can reduce the space actually freed.");
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
const recheck = () => {
|
|
315
|
+
const currentIdentity = fs.lstatSync(data.worktree);
|
|
316
|
+
if (!currentIdentity.isDirectory() ||
|
|
317
|
+
currentIdentity.dev !== workspaceIdentity.dev ||
|
|
318
|
+
currentIdentity.ino !== workspaceIdentity.ino ||
|
|
319
|
+
canonical(data.worktree) !== data.worktree)
|
|
320
|
+
throw new Error("Current workspace directory changed during cleanup");
|
|
321
|
+
const [currentFile, current] = context.store.current();
|
|
322
|
+
if (currentFile !== file || current.key !== data.key)
|
|
323
|
+
throw new Error("Current RL workspace changed during cleanup");
|
|
324
|
+
assertReady(data.worktree, false, data.branch);
|
|
325
|
+
};
|
|
326
|
+
const perform = () => {
|
|
327
|
+
recheck();
|
|
328
|
+
const currentClean = readWorkflowConfig(data.worktree).clean;
|
|
329
|
+
if (JSON.stringify(currentClean) !== JSON.stringify(clean))
|
|
330
|
+
throw new Error("Cleanup configuration changed; retry");
|
|
331
|
+
const targets = plan(data.worktree, clean);
|
|
332
|
+
const estimatedBytes = targets.length
|
|
333
|
+
? measure(targets.map((target) => ({
|
|
334
|
+
path: target.path,
|
|
335
|
+
relative: target.relative,
|
|
336
|
+
})), clean, true).estimatedBytes
|
|
337
|
+
: 0;
|
|
338
|
+
const removedPaths = !values["dry-run"] && targets.length
|
|
339
|
+
? remove(data.worktree, targets, recheck)
|
|
340
|
+
: [];
|
|
341
|
+
const report = {
|
|
342
|
+
workspace,
|
|
343
|
+
dryRun: !!values["dry-run"],
|
|
344
|
+
paths: targets.map((target) => target.relative),
|
|
345
|
+
estimatedBytes,
|
|
346
|
+
removedPaths,
|
|
347
|
+
};
|
|
348
|
+
if (values.json)
|
|
349
|
+
json(report);
|
|
350
|
+
else {
|
|
351
|
+
console.log(`${values["dry-run"] ? "Cleanup preview" : "Cleanup"}: ${visible(data.id)}\n${visible(data.worktree)}`);
|
|
352
|
+
for (const target of targets)
|
|
353
|
+
console.log(` ${values["dry-run"] ? "Would remove" : "Removed"} ${visible(target.relative)} (${size(target.usage.allocatedBytes)})`);
|
|
354
|
+
console.log(`Estimated ${values["dry-run"] ? "space to free" : "cleanup size"}: ${size(estimatedBytes)}`);
|
|
355
|
+
if (!clean.length)
|
|
356
|
+
console.log('Configure "clean": ["node_modules", ".next", ".turbo"] in this workspace\'s .rl/workflows.json first.');
|
|
357
|
+
else if (!targets.length)
|
|
358
|
+
console.log("No configured generated directories exist.");
|
|
359
|
+
}
|
|
360
|
+
return 0;
|
|
361
|
+
};
|
|
362
|
+
// A preview does not create locks or write RL state.
|
|
363
|
+
return values["dry-run"] || !clean.length
|
|
364
|
+
? perform()
|
|
365
|
+
: locked(path.join(context.store.root, GIT_WORKFLOW_DIRECTORY), GIT_WORKFLOW_LOCK, perform);
|
|
366
|
+
}
|