rl-workspaces 0.0.0-stage → 0.6.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 +735 -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 +70 -0
- package/dist/agent-registry.js +8 -0
- package/dist/agent-resume.js +96 -0
- package/dist/agent-runner.js +386 -0
- package/dist/agent-workflows.js +275 -0
- package/dist/agents.js +73 -0
- package/dist/automation-config.js +381 -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 +89 -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 +650 -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-collaboration.js +531 -0
- package/dist/workflow-config.js +177 -0
- package/dist/workflow.js +211 -0
- package/lib/rl.zsh +329 -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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import { canonical, object, sessionId, timestamp } from "./common.js";
|
|
4
|
+
const SOURCES = ["cli", "vscode", "exec", "appServer"];
|
|
5
|
+
// Ask Codex for workspace-filtered metadata only. Never read transcripts or
|
|
6
|
+
// request a model turn, and never fall back to an unfiltered history query.
|
|
7
|
+
async function discover(cwd, roots) {
|
|
8
|
+
const child = spawn("codex", ["app-server"], {
|
|
9
|
+
cwd,
|
|
10
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
11
|
+
});
|
|
12
|
+
const lines = createInterface({ input: child.stdout });
|
|
13
|
+
let requestId = 0;
|
|
14
|
+
let pending;
|
|
15
|
+
let failure;
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
const fail = (error) => {
|
|
18
|
+
failure = error;
|
|
19
|
+
pending?.reject(error);
|
|
20
|
+
pending = undefined;
|
|
21
|
+
child.kill();
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => fail(new Error("Codex session discovery timed out")), 3000);
|
|
24
|
+
child.on("error", fail);
|
|
25
|
+
child.on("close", () => fail(new Error("Codex session discovery closed")));
|
|
26
|
+
child.stdin.on("error", fail);
|
|
27
|
+
child.stdout.on("data", (chunk) => {
|
|
28
|
+
bytes += chunk.length;
|
|
29
|
+
if (bytes > 8 * 1024 * 1024)
|
|
30
|
+
fail(new Error("Codex session metadata is too large"));
|
|
31
|
+
});
|
|
32
|
+
lines.on("line", (line) => {
|
|
33
|
+
try {
|
|
34
|
+
const reply = JSON.parse(line);
|
|
35
|
+
if (!object(reply) || reply.id !== pending?.id || !pending)
|
|
36
|
+
return;
|
|
37
|
+
if (reply.error !== undefined || !object(reply.result))
|
|
38
|
+
throw new Error("Codex rejected session discovery");
|
|
39
|
+
const request = pending;
|
|
40
|
+
pending = undefined;
|
|
41
|
+
request.resolve(reply.result);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const request = (method, params) => new Promise((resolve, reject) => {
|
|
48
|
+
if (failure)
|
|
49
|
+
return reject(failure);
|
|
50
|
+
pending = { id: ++requestId, resolve, reject };
|
|
51
|
+
child.stdin.write(JSON.stringify({ id: requestId, method, params }) + "\n");
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await request("initialize", { clientInfo: { name: "rl", version: "1" } });
|
|
55
|
+
child.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
|
|
56
|
+
const sessions = [];
|
|
57
|
+
// A string cwd also works with older app-server versions.
|
|
58
|
+
for (const root of roots) {
|
|
59
|
+
let cursor;
|
|
60
|
+
const cursors = new Set();
|
|
61
|
+
do {
|
|
62
|
+
const result = await request("thread/list", {
|
|
63
|
+
cwd: root,
|
|
64
|
+
useStateDbOnly: true,
|
|
65
|
+
sourceKinds: SOURCES,
|
|
66
|
+
limit: 100,
|
|
67
|
+
...(cursor ? { cursor } : {}),
|
|
68
|
+
});
|
|
69
|
+
if (!Array.isArray(result.data))
|
|
70
|
+
throw new Error("Invalid Codex thread list");
|
|
71
|
+
for (const thread of result.data) {
|
|
72
|
+
if (!object(thread) ||
|
|
73
|
+
thread.ephemeral === true ||
|
|
74
|
+
(thread.source !== undefined &&
|
|
75
|
+
(typeof thread.source !== "string" ||
|
|
76
|
+
!SOURCES.includes(thread.source))) ||
|
|
77
|
+
typeof thread.cwd !== "string" ||
|
|
78
|
+
canonical(thread.cwd) !== root)
|
|
79
|
+
continue;
|
|
80
|
+
// Only UUIDs can be passed unambiguously to the installed resume CLI.
|
|
81
|
+
try {
|
|
82
|
+
if (typeof thread.createdAt !== "number" ||
|
|
83
|
+
typeof thread.updatedAt !== "number")
|
|
84
|
+
continue;
|
|
85
|
+
const createdAt = new Date(thread.createdAt * 1000).toISOString();
|
|
86
|
+
const lastUsedAt = new Date(thread.updatedAt * 1000).toISOString();
|
|
87
|
+
sessions.push({
|
|
88
|
+
id: sessionId(thread.id),
|
|
89
|
+
cwd: root,
|
|
90
|
+
createdAt,
|
|
91
|
+
lastUsedAt,
|
|
92
|
+
branch: object(thread.gitInfo) &&
|
|
93
|
+
typeof thread.gitInfo.branch === "string"
|
|
94
|
+
? thread.gitInfo.branch
|
|
95
|
+
: undefined,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* Unsupported identifiers or invalid timestamps are not resume targets. */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (result.nextCursor !== null &&
|
|
103
|
+
result.nextCursor !== undefined &&
|
|
104
|
+
typeof result.nextCursor !== "string")
|
|
105
|
+
throw new Error("Invalid Codex thread cursor");
|
|
106
|
+
cursor = result.nextCursor;
|
|
107
|
+
if (cursor && cursors.has(cursor))
|
|
108
|
+
throw new Error("Repeated Codex thread cursor");
|
|
109
|
+
if (cursor)
|
|
110
|
+
cursors.add(cursor);
|
|
111
|
+
} while (cursor);
|
|
112
|
+
}
|
|
113
|
+
return sessions;
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
lines.close();
|
|
118
|
+
child.stdin.end();
|
|
119
|
+
child.kill();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export async function syncCodexSessions(store, cwd) {
|
|
123
|
+
const records = await store.locked(() => cwd
|
|
124
|
+
? [store.current(cwd)]
|
|
125
|
+
: store.records().filter(([, data]) => data.status === "active"));
|
|
126
|
+
const eligible = records.filter(([, data]) => {
|
|
127
|
+
try {
|
|
128
|
+
return store.current(data.worktree)[1].key === data.key;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
if (!eligible.length)
|
|
135
|
+
return;
|
|
136
|
+
let sessions;
|
|
137
|
+
try {
|
|
138
|
+
sessions = await discover(eligible[0][1].worktree, eligible.map(([, data]) => data.worktree));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// Missing/older Codex or a temporarily unavailable server must not hide RL
|
|
142
|
+
// status or erase already saved sessions. Hooks/manual save still work.
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
await store.locked(() => {
|
|
146
|
+
for (const [file, snapshot] of eligible) {
|
|
147
|
+
let current;
|
|
148
|
+
try {
|
|
149
|
+
current = store.current(snapshot.worktree);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const [currentFile, data] = current;
|
|
155
|
+
if (currentFile !== file || data.key !== snapshot.key)
|
|
156
|
+
continue;
|
|
157
|
+
let changed = false;
|
|
158
|
+
for (const session of sessions) {
|
|
159
|
+
if (session.cwd !== data.worktree ||
|
|
160
|
+
(session.branch !== undefined && session.branch !== data.branch) ||
|
|
161
|
+
(session.branch === undefined &&
|
|
162
|
+
timestamp(session.createdAt) < timestamp(data.createdAt)))
|
|
163
|
+
continue;
|
|
164
|
+
try {
|
|
165
|
+
store.assertSessionOwner(file, "codex", session.id);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const saved = data.sessions.codex.find((item) => item.id === session.id);
|
|
171
|
+
if (!saved) {
|
|
172
|
+
data.sessions.codex.push({
|
|
173
|
+
id: session.id,
|
|
174
|
+
createdAt: session.createdAt,
|
|
175
|
+
lastUsedAt: session.lastUsedAt,
|
|
176
|
+
});
|
|
177
|
+
changed = true;
|
|
178
|
+
}
|
|
179
|
+
else if (timestamp(session.lastUsedAt) > timestamp(saved.lastUsedAt)) {
|
|
180
|
+
saved.lastUsedAt = session.lastUsedAt;
|
|
181
|
+
changed = true;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (changed)
|
|
185
|
+
store.save(file, data);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { canonical, inside, required } from "./common.js";
|
|
5
|
+
import { candidates } from "./picker.js";
|
|
6
|
+
import { Store } from "./store.js";
|
|
7
|
+
export const contextOptions = ["repo", "state-dir", "base-dir", "default-base"];
|
|
8
|
+
export function writeWorkspaceOutput(workspace, output) {
|
|
9
|
+
if (output === undefined) {
|
|
10
|
+
console.log(workspace);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const target = path.resolve(output);
|
|
14
|
+
if (!inside(canonical(target), canonical(os.tmpdir())))
|
|
15
|
+
throw new Error("Shell output file must be inside the temporary directory");
|
|
16
|
+
const fd = fs.openSync(target, fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW);
|
|
17
|
+
try {
|
|
18
|
+
const stat = fs.fstatSync(fd);
|
|
19
|
+
if (!stat.isFile() || stat.size !== 0 || stat.nlink !== 1)
|
|
20
|
+
throw new Error("Shell output file must be an empty regular file");
|
|
21
|
+
fs.writeFileSync(fd, workspace);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
fs.closeSync(fd);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Settings have already been selected by the public CLI's repository router. */
|
|
28
|
+
export function commandContext(values) {
|
|
29
|
+
const repo = required(values, "repo"), stateDir = required(values, "state-dir"), managedDir = required(values, "base-dir");
|
|
30
|
+
if (![repo, stateDir, managedDir].every((value) => path.isAbsolute(value)))
|
|
31
|
+
throw new Error("Repository, state and managed workspace paths must be absolute");
|
|
32
|
+
const store = new Store(repo, stateDir);
|
|
33
|
+
return {
|
|
34
|
+
repo: store.repo,
|
|
35
|
+
stateDir: store.stateDir,
|
|
36
|
+
managedDir: canonical(managedDir),
|
|
37
|
+
defaultBase: String(values["default-base"] ?? "origin/test"),
|
|
38
|
+
store,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Resolve only the current checkout or registered managed worktrees, never scan. */
|
|
42
|
+
export function resolveWorkspace(context, selector) {
|
|
43
|
+
if (!selector) {
|
|
44
|
+
const [root, branch] = context.store.worktree(process.cwd());
|
|
45
|
+
return { path: root, name: path.basename(root), branch };
|
|
46
|
+
}
|
|
47
|
+
if (selector === "@" ||
|
|
48
|
+
(path.isAbsolute(selector) && canonical(selector) === context.repo)) {
|
|
49
|
+
const [root, branch] = context.store.worktree(context.repo);
|
|
50
|
+
return { path: root, name: "@", branch };
|
|
51
|
+
}
|
|
52
|
+
const choices = candidates(context.repo, context.managedDir);
|
|
53
|
+
const matches = choices.filter((item) => item.name === selector ||
|
|
54
|
+
item.branch === selector ||
|
|
55
|
+
(path.isAbsolute(selector) && item.path === canonical(selector)));
|
|
56
|
+
if (matches.length !== 1)
|
|
57
|
+
throw new Error(matches.length
|
|
58
|
+
? `Ambiguous workspace: ${selector}; use its managed directory name`
|
|
59
|
+
: `Workspace not found: ${selector}. Use rl list or rl switch --create.`);
|
|
60
|
+
const item = matches[0];
|
|
61
|
+
// Revalidate repository identity and branch. Worktree metadata alone can be
|
|
62
|
+
// stale after the directory has been removed or replaced.
|
|
63
|
+
const [root, branch] = context.store.worktree(item.path);
|
|
64
|
+
if (branch !== item.branch)
|
|
65
|
+
throw new Error("Workspace branch changed during selection; retry the command");
|
|
66
|
+
return { path: root, name: item.name, branch };
|
|
67
|
+
}
|
package/dist/common.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import lockfile from "proper-lockfile";
|
|
8
|
+
import { readJsonObject } from "./json.js";
|
|
9
|
+
export const now = () => new Date().toISOString();
|
|
10
|
+
export const message = (error) => error instanceof Error ? error.message : String(error);
|
|
11
|
+
export const json = (data) => console.log(JSON.stringify(data, null, 2));
|
|
12
|
+
export function canonical(value) {
|
|
13
|
+
const absolute = path.resolve(value);
|
|
14
|
+
try {
|
|
15
|
+
return fs.realpathSync(absolute);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (!["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
|
|
19
|
+
throw error;
|
|
20
|
+
const parent = path.dirname(absolute);
|
|
21
|
+
return parent === absolute
|
|
22
|
+
? absolute
|
|
23
|
+
: path.join(canonical(parent), path.basename(absolute));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const expandHome = (value) => value === "~"
|
|
27
|
+
? os.homedir()
|
|
28
|
+
: value.startsWith("~/")
|
|
29
|
+
? path.join(os.homedir(), value.slice(2))
|
|
30
|
+
: value;
|
|
31
|
+
export function inside(child, parent) {
|
|
32
|
+
const relative = path.relative(parent, child);
|
|
33
|
+
return (!!relative &&
|
|
34
|
+
relative !== ".." &&
|
|
35
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
36
|
+
!path.isAbsolute(relative));
|
|
37
|
+
}
|
|
38
|
+
export function gitEnv() {
|
|
39
|
+
const env = { ...process.env };
|
|
40
|
+
for (const key of [
|
|
41
|
+
"GIT_DIR",
|
|
42
|
+
"GIT_WORK_TREE",
|
|
43
|
+
"GIT_COMMON_DIR",
|
|
44
|
+
"GIT_INDEX_FILE",
|
|
45
|
+
])
|
|
46
|
+
delete env[key];
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
export function run(argv, cwd, raw = false) {
|
|
50
|
+
const result = spawnSync(argv[0], argv.slice(1), {
|
|
51
|
+
cwd,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: argv[0] === "git" ? gitEnv() : process.env,
|
|
54
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
if (result.error)
|
|
57
|
+
throw result.error;
|
|
58
|
+
if (result.status !== 0)
|
|
59
|
+
throw new Error(result.stderr.trim() || `Command failed: ${argv.join(" ")}`);
|
|
60
|
+
return raw ? result.stdout : result.stdout.trim();
|
|
61
|
+
}
|
|
62
|
+
export const git = (cwd, ...args) => run(["git", "-C", cwd, ...args], cwd);
|
|
63
|
+
export const gitRaw = (cwd, ...args) => run(["git", "-C", cwd, ...args], cwd, true);
|
|
64
|
+
export async function execute(argv, cwd, options = {}) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
67
|
+
cwd,
|
|
68
|
+
timeout: options.timeoutMs,
|
|
69
|
+
env: options.env ?? (argv[0] === "git" ? gitEnv() : process.env),
|
|
70
|
+
detached: options.processGroup,
|
|
71
|
+
stdio: options.interactive
|
|
72
|
+
? "inherit"
|
|
73
|
+
: ["pipe", "pipe", options.terminalError ? "inherit" : "pipe"],
|
|
74
|
+
});
|
|
75
|
+
let stdout = "", stderr = "";
|
|
76
|
+
let outputBytes = 0;
|
|
77
|
+
let overflow = false;
|
|
78
|
+
let forceKill;
|
|
79
|
+
const kill = (signal) => {
|
|
80
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
81
|
+
return;
|
|
82
|
+
try {
|
|
83
|
+
if (options.processGroup && child.pid)
|
|
84
|
+
process.kill(-child.pid, signal);
|
|
85
|
+
else
|
|
86
|
+
child.kill(signal);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const code = error.code;
|
|
90
|
+
if (code === "EPERM" && options.processGroup)
|
|
91
|
+
child.kill(signal);
|
|
92
|
+
else if (code !== "ESRCH")
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const abort = () => {
|
|
97
|
+
kill("SIGTERM");
|
|
98
|
+
forceKill ??= setTimeout(() => kill("SIGKILL"), 1000);
|
|
99
|
+
forceKill.unref();
|
|
100
|
+
};
|
|
101
|
+
const append = (chunk, target) => {
|
|
102
|
+
outputBytes += chunk.length;
|
|
103
|
+
if (options.maxOutputBytes && outputBytes > options.maxOutputBytes) {
|
|
104
|
+
overflow = true;
|
|
105
|
+
abort();
|
|
106
|
+
}
|
|
107
|
+
else if (target === "stdout")
|
|
108
|
+
stdout += chunk;
|
|
109
|
+
else
|
|
110
|
+
stderr += chunk;
|
|
111
|
+
};
|
|
112
|
+
child.stdout?.on("data", (chunk) => {
|
|
113
|
+
append(chunk, "stdout");
|
|
114
|
+
});
|
|
115
|
+
child.stderr?.on("data", (chunk) => {
|
|
116
|
+
append(chunk, "stderr");
|
|
117
|
+
});
|
|
118
|
+
child.stdin?.on("error", () => { });
|
|
119
|
+
child.stdin?.end(options.input);
|
|
120
|
+
const interrupt = () => child.kill("SIGINT");
|
|
121
|
+
const terminate = () => child.kill("SIGTERM");
|
|
122
|
+
process.on("SIGINT", interrupt);
|
|
123
|
+
process.on("SIGTERM", terminate);
|
|
124
|
+
const cleanup = () => {
|
|
125
|
+
if (forceKill)
|
|
126
|
+
clearTimeout(forceKill);
|
|
127
|
+
options.signal?.removeEventListener("abort", abort);
|
|
128
|
+
process.off("SIGINT", interrupt);
|
|
129
|
+
process.off("SIGTERM", terminate);
|
|
130
|
+
};
|
|
131
|
+
child.on("error", (error) => {
|
|
132
|
+
cleanup();
|
|
133
|
+
reject(error);
|
|
134
|
+
});
|
|
135
|
+
child.on("close", (code, signal) => {
|
|
136
|
+
cleanup();
|
|
137
|
+
if (overflow) {
|
|
138
|
+
reject(new Error("Command output exceeded the configured size limit"));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
resolve({
|
|
142
|
+
code: code ?? 128 + (os.constants.signals[signal] ?? 1),
|
|
143
|
+
stdout,
|
|
144
|
+
stderr,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
148
|
+
if (options.signal?.aborted)
|
|
149
|
+
abort();
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function object(value) {
|
|
153
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
154
|
+
}
|
|
155
|
+
export function readJson(file) {
|
|
156
|
+
return readJsonObject(file);
|
|
157
|
+
}
|
|
158
|
+
export function atomicWrite(file, text) {
|
|
159
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
160
|
+
const temporary = path.join(path.dirname(file), `.rl-${randomUUID()}`);
|
|
161
|
+
try {
|
|
162
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
163
|
+
try {
|
|
164
|
+
fs.writeFileSync(fd, text);
|
|
165
|
+
fs.fsyncSync(fd);
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
fs.closeSync(fd);
|
|
169
|
+
}
|
|
170
|
+
fs.renameSync(temporary, file);
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (fs.existsSync(temporary))
|
|
174
|
+
fs.unlinkSync(temporary);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
export const writeJson = (file, data) => atomicWrite(file, JSON.stringify(data, null, 2) + "\n");
|
|
178
|
+
export async function locked(directory, name, action) {
|
|
179
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
180
|
+
const release = await lockfile.lock(directory, {
|
|
181
|
+
realpath: false,
|
|
182
|
+
lockfilePath: path.join(directory, name),
|
|
183
|
+
stale: 120000,
|
|
184
|
+
retries: { retries: 300, factor: 1, minTimeout: 25, maxTimeout: 100 },
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
return await action();
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
await release();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export function shellQuote(value) {
|
|
194
|
+
return /^[\w@%+=:,./-]+$/.test(value)
|
|
195
|
+
? value
|
|
196
|
+
: "'" + value.replaceAll("'", "'\"'\"'") + "'";
|
|
197
|
+
}
|
|
198
|
+
export function sessionId(value) {
|
|
199
|
+
if (typeof value !== "string" ||
|
|
200
|
+
!/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value))
|
|
201
|
+
throw new Error("Expected the agent's full session UUID, not a name or prefix");
|
|
202
|
+
return value.toLowerCase();
|
|
203
|
+
}
|
|
204
|
+
// Normalize timezone offsets while retaining Python's microsecond timestamp precision.
|
|
205
|
+
export function timestamp(value) {
|
|
206
|
+
if (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) ||
|
|
207
|
+
!Number.isFinite(Date.parse(value)))
|
|
208
|
+
throw new Error("Invalid timestamp");
|
|
209
|
+
const fraction = /\.(\d+)/.exec(value)?.[1] ?? "";
|
|
210
|
+
return (BigInt(Date.parse(value)) * 1000000n +
|
|
211
|
+
BigInt(fraction.slice(3, 9).padEnd(6, "0")));
|
|
212
|
+
}
|
|
213
|
+
export function args(argv, strings, booleans = [], short = {}) {
|
|
214
|
+
const options = {};
|
|
215
|
+
for (const name of strings)
|
|
216
|
+
options[name] = {
|
|
217
|
+
type: "string",
|
|
218
|
+
...(short[name] ? { short: short[name] } : {}),
|
|
219
|
+
};
|
|
220
|
+
for (const name of booleans)
|
|
221
|
+
options[name] = {
|
|
222
|
+
type: "boolean",
|
|
223
|
+
...(short[name] ? { short: short[name] } : {}),
|
|
224
|
+
};
|
|
225
|
+
return parseArgs({
|
|
226
|
+
args: argv,
|
|
227
|
+
options,
|
|
228
|
+
allowPositionals: true,
|
|
229
|
+
strict: true,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
export function required(values, key) {
|
|
233
|
+
const value = values[key];
|
|
234
|
+
if (typeof value !== "string" || !value)
|
|
235
|
+
throw new Error(`Missing --${key}`);
|
|
236
|
+
return value;
|
|
237
|
+
}
|
|
238
|
+
export function arity(values, min, max = min) {
|
|
239
|
+
if (values.length < min || values.length > max)
|
|
240
|
+
throw new Error("Invalid command arguments; run rl --help");
|
|
241
|
+
}
|
|
242
|
+
export async function pooled(items, limit, action) {
|
|
243
|
+
const results = new Array(items.length);
|
|
244
|
+
let next = 0;
|
|
245
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
246
|
+
while (next < items.length) {
|
|
247
|
+
const index = next++;
|
|
248
|
+
results[index] = await action(items[index]);
|
|
249
|
+
}
|
|
250
|
+
}));
|
|
251
|
+
return results;
|
|
252
|
+
}
|
|
253
|
+
export const isSymlink = (file) => {
|
|
254
|
+
try {
|
|
255
|
+
return fs.lstatSync(file).isSymbolicLink();
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error.code === "ENOENT")
|
|
259
|
+
return false;
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
};
|
package/dist/delete.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { args, canonical, execute, git, inside, locked, message, pooled, required, } from "./common.js";
|
|
3
|
+
import { Store } from "./store.js";
|
|
4
|
+
import { worktrees } from "./repos.js";
|
|
5
|
+
import { candidates, select } from "./picker.js";
|
|
6
|
+
import { assertReady, GIT_WORKFLOW_DIRECTORY, GIT_WORKFLOW_LOCK, } from "./git-safety.js";
|
|
7
|
+
export async function main(argv) {
|
|
8
|
+
const { values, positionals } = args(argv, ["repo", "state-dir", "base-dir", "jobs", "default-base"], ["dry-run", "force"]);
|
|
9
|
+
const jobs = Number(values.jobs ?? 4);
|
|
10
|
+
if (!Number.isInteger(jobs) || jobs < 1 || jobs > 32)
|
|
11
|
+
throw new Error("jobs must be an integer from 1 to 32");
|
|
12
|
+
const repo = canonical(required(values, "repo")), base = canonical(required(values, "base-dir")), registered = worktrees(repo), items = new Map();
|
|
13
|
+
for (const item of candidates(repo, base))
|
|
14
|
+
items.set(item.name, item);
|
|
15
|
+
let names = positionals;
|
|
16
|
+
if (!names.length)
|
|
17
|
+
names = (await select(new Store(repo, required(values, "state-dir")), [...items.values()], String(values["default-base"] ?? "origin/test"), true)).map((item) => item.name);
|
|
18
|
+
names = [...new Set(names)];
|
|
19
|
+
if (!names.length)
|
|
20
|
+
return 0;
|
|
21
|
+
const unknown = names.filter((name) => !items.has(name));
|
|
22
|
+
if (unknown.length)
|
|
23
|
+
throw new Error("Not registered managed worktrees: " + unknown.join(", "));
|
|
24
|
+
const selected = names.map((name) => items.get(name));
|
|
25
|
+
for (const item of selected)
|
|
26
|
+
if (registered.some((other) => inside(canonical(other.worktree), item.path)))
|
|
27
|
+
throw new Error(`${item.name} contains another registered worktree; delete its children first`);
|
|
28
|
+
for (const item of selected)
|
|
29
|
+
console.log(`${values["dry-run"] ? "Would delete" : "Selected"}: ${item.name} [branch: ${item.branch || "(detached)"}]${item.locked ? " [locked: will be skipped]" : ""}`);
|
|
30
|
+
if (values["dry-run"])
|
|
31
|
+
return 0;
|
|
32
|
+
const store = new Store(repo, required(values, "state-dir"));
|
|
33
|
+
return locked(path.join(store.root, GIT_WORKFLOW_DIRECTORY), GIT_WORKFLOW_LOCK, async () => {
|
|
34
|
+
await store.locked(() => store.records());
|
|
35
|
+
process.chdir(repo);
|
|
36
|
+
console.log(`Removing ${selected.length} workspace(s), up to ${jobs} at a time...`);
|
|
37
|
+
const removed = [], failed = [];
|
|
38
|
+
await pooled(selected, jobs, async (item) => {
|
|
39
|
+
try {
|
|
40
|
+
const current = worktrees(repo).find((entry) => canonical(entry.worktree) === item.path);
|
|
41
|
+
if (!current ||
|
|
42
|
+
(current.branch ?? "").replace(/^refs\/heads\//, "") !== item.branch)
|
|
43
|
+
throw new Error("Workspace branch changed; inspect it and retry");
|
|
44
|
+
if (!values.force && !item.branch)
|
|
45
|
+
throw new Error("Detached workspace may contain unmerged commits; inspect it or use --force");
|
|
46
|
+
if (!values.force)
|
|
47
|
+
assertReady(item.path, true, item.branch);
|
|
48
|
+
if (!values.force && item.branch) {
|
|
49
|
+
const upstream = git(repo, "for-each-ref", "--format=%(upstream)", `refs/heads/${item.branch}`);
|
|
50
|
+
const merged = await execute([
|
|
51
|
+
"git",
|
|
52
|
+
"-C",
|
|
53
|
+
repo,
|
|
54
|
+
"merge-base",
|
|
55
|
+
"--is-ancestor",
|
|
56
|
+
`refs/heads/${item.branch}`,
|
|
57
|
+
upstream || "HEAD",
|
|
58
|
+
], repo);
|
|
59
|
+
if (merged.code)
|
|
60
|
+
throw new Error("Branch has unmerged commits; merge it first or use --force");
|
|
61
|
+
}
|
|
62
|
+
const result = await execute([
|
|
63
|
+
"git",
|
|
64
|
+
"-C",
|
|
65
|
+
repo,
|
|
66
|
+
"worktree",
|
|
67
|
+
"remove",
|
|
68
|
+
...(values.force ? ["--force"] : []),
|
|
69
|
+
item.path,
|
|
70
|
+
], repo);
|
|
71
|
+
if (result.code)
|
|
72
|
+
throw new Error(result.stderr.trim());
|
|
73
|
+
removed.push(item);
|
|
74
|
+
console.log(`Removed worktree: ${item.name}`);
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
failed.push(item);
|
|
78
|
+
console.error(`Failed: ${item.name}: ${message(error)}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
await store.locked(() => store.archiveWorktrees(removed.map((item) => item.path)));
|
|
82
|
+
let cleanupFailed = false;
|
|
83
|
+
const branches = [
|
|
84
|
+
...new Set(removed.map((item) => item.branch).filter(Boolean)),
|
|
85
|
+
];
|
|
86
|
+
if (branches.length) {
|
|
87
|
+
const result = await execute([
|
|
88
|
+
"git",
|
|
89
|
+
"-C",
|
|
90
|
+
repo,
|
|
91
|
+
"branch",
|
|
92
|
+
values.force ? "-D" : "-d",
|
|
93
|
+
"--",
|
|
94
|
+
...branches,
|
|
95
|
+
], repo);
|
|
96
|
+
process.stdout.write(result.stdout);
|
|
97
|
+
if (result.code) {
|
|
98
|
+
cleanupFailed = true;
|
|
99
|
+
console.error("Some worktrees were removed but local branch cleanup failed:\n" +
|
|
100
|
+
result.stderr.trim());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (removed.length) {
|
|
104
|
+
const result = await execute(["git", "-C", repo, "worktree", "prune"], repo);
|
|
105
|
+
if (result.code) {
|
|
106
|
+
cleanupFailed = true;
|
|
107
|
+
console.error("Worktree pruning failed: " + result.stderr.trim());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
console.log(`Removed ${removed.length} worktree(s); failed ${failed.length}. Saved documentation and session history retained.`);
|
|
111
|
+
return failed.length || cleanupFailed ? 1 : 0;
|
|
112
|
+
});
|
|
113
|
+
}
|