@shiyuphan/follow-me 0.1.0 → 0.1.1
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/README.md +11 -0
- package/dist/src/cli.js +244 -41
- package/dist/src/git.js +85 -23
- package/dist/src/harness.js +74 -0
- package/dist/src/host.js +293 -0
- package/dist/src/install.js +131 -0
- package/dist/src/server.js +320 -94
- package/dist/src/session.js +213 -6
- package/dist/src/types.js +2 -1
- package/dist/ui/app.js +28 -1
- package/dist/ui/index.html +1 -0
- package/dist/ui/style.css +35 -0
- package/package.json +1 -1
- package/skills/follow-me/SKILL.md +3 -13
package/README.md
CHANGED
|
@@ -81,6 +81,17 @@ SHAs and re-running `apply` is a no-op.
|
|
|
81
81
|
- `apply` refuses to move a branch it did not create unless `--force`.
|
|
82
82
|
- The review server binds `127.0.0.1` only and rejects cross-origin requests.
|
|
83
83
|
- Everything under `.follow-me/` is ignored by git.
|
|
84
|
+
- Outside the repo, follow-me writes only its state dir (`~/.local/state/follow-me`, or
|
|
85
|
+
`$XDG_STATE_HOME/follow-me`), which holds the review server's `server.json` and `server.log`.
|
|
86
|
+
- One review server per user hosts every session. `follow-me server stop` signals it only after
|
|
87
|
+
it answers as follow-me on its recorded port, so a reused pid is never killed.
|
|
88
|
+
|
|
89
|
+
Three guarantees hold across sessions. The original branch is never written. `apply` puts the
|
|
90
|
+
split on `<branch>-split` or `--name`, and refuses the branch being split and any branch checked
|
|
91
|
+
out in a worktree, even with `--force`. Sessions are isolated per worktree. Each worktree keeps
|
|
92
|
+
its own `.follow-me/`, each branch its own directory, and a lock stops two agents from applying or
|
|
93
|
+
reviewing one session at once. `follow-me clean` removes leftovers: sessions of deleted branches,
|
|
94
|
+
review drafts no review can resume, and files of processes that died.
|
|
84
95
|
|
|
85
96
|
## Development
|
|
86
97
|
|
package/dist/src/cli.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { join, relative } from "node:path";
|
|
3
4
|
import { PlanRequestError, requestPlan } from "./anthropic.js";
|
|
4
5
|
import { DiffParseError, formatUnitRef } from "./diff.js";
|
|
5
6
|
import { BranchRefusedError, GitError, treeOf, writeBranch } from "./git.js";
|
|
7
|
+
import { findServer, idleMs, listSessions, openBrowser, portFallbackMessage, probeHealth, registerWith, sessionUrl, spawnDetached, startOrAttach, stateDir, stopServer, } from "./host.js";
|
|
8
|
+
import { runInstall, runUninstall } from "./install.js";
|
|
6
9
|
import { serializePlanFile, validatePlan } from "./plan.js";
|
|
7
10
|
import { renderFeedback, renderPrompt } from "./prompt.js";
|
|
8
|
-
import {
|
|
9
|
-
import { BranchMovedError, extractSession, openSession, SessionFileError, } from "./session.js";
|
|
11
|
+
import { RegistrationError, sessionIdOf } from "./server.js";
|
|
12
|
+
import { BranchMovedError, cleanSessions, extractSession, openSession, SessionFileError, SessionLockedError, } from "./session.js";
|
|
10
13
|
class UsageError extends Error {
|
|
11
14
|
name = "UsageError";
|
|
12
15
|
}
|
|
@@ -14,18 +17,34 @@ const USAGE = `usage: follow-me <command>
|
|
|
14
17
|
|
|
15
18
|
extract [--base <ref>] [--branch <name>] anchor a session and list the units
|
|
16
19
|
check [path] [--branch <name>] validate draft.json or the given plan file
|
|
17
|
-
apply [--name <branch>] [--force] write the draft as
|
|
18
|
-
|
|
20
|
+
apply [--name <branch>] [--force] write the draft as commits on <branch>-split or
|
|
21
|
+
--name; never on <branch> or a checked-out branch
|
|
22
|
+
status show the session's plans, reviews, lock and the
|
|
23
|
+
branch apply writes
|
|
19
24
|
prompt [--walk] [--branch <name>] print the planner prompt for a coding agent
|
|
20
25
|
plan [--walk] [--model <id>] [--branch <name>]
|
|
21
26
|
ask the Anthropic API to write draft.json
|
|
22
|
-
review [--port <n>] [--no-open]
|
|
23
|
-
walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--no-open]
|
|
27
|
+
review [--port <n>] [--detach] [--no-open] review the latest plan commit by commit
|
|
28
|
+
walk [--base <ref>] [--branch <name>] [--model <id>] [--port <n>] [--detach] [--no-open]
|
|
24
29
|
watch a branch being written, narrated, and
|
|
25
30
|
review its code; nothing is applied
|
|
31
|
+
server status show the review server and the sessions it hosts
|
|
32
|
+
server stop stop the review server; drafts are kept
|
|
26
33
|
feedback [--branch <name>] print the latest review as markdown
|
|
34
|
+
clean [--all] [--dry-run] remove sessions of deleted branches, finished
|
|
35
|
+
review drafts and leftovers of dead processes;
|
|
36
|
+
--all removes .follow-me/ entirely
|
|
37
|
+
install [--harness <ids>|--all] [--user] [--dry-run]
|
|
38
|
+
copy the skill into coding agents' skill dirs
|
|
39
|
+
install --list show each harness, its skill dirs and docs
|
|
40
|
+
uninstall [--harness <ids>|--all] [--user] [--dry-run]
|
|
41
|
+
remove the skill files install writes
|
|
27
42
|
|
|
28
|
-
--walk writes the prompt for a reviewer: a story with narration instead of a split
|
|
43
|
+
--walk writes the prompt for a reviewer: a story with narration instead of a split.
|
|
44
|
+
review and walk share one server per user: a running one hosts the session and the command
|
|
45
|
+
prints its URL; otherwise the command serves until its review ends, or with --detach starts the
|
|
46
|
+
server in the background. --port taken by another program falls back to a free port.
|
|
47
|
+
--force lets apply move a branch follow-me did not create; it never allows <branch> itself.`;
|
|
29
48
|
function parseArgs(argv, spec) {
|
|
30
49
|
const positionals = [];
|
|
31
50
|
const values = new Map();
|
|
@@ -159,12 +178,12 @@ function branchFlag(args) {
|
|
|
159
178
|
function portOf(args) {
|
|
160
179
|
const raw = args.values.get("port");
|
|
161
180
|
if (raw === undefined)
|
|
162
|
-
return
|
|
181
|
+
return undefined;
|
|
163
182
|
const port = Number(raw);
|
|
164
183
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
165
184
|
throw new UsageError(`--port ${raw} is not a port number`);
|
|
166
185
|
}
|
|
167
|
-
return
|
|
186
|
+
return port === 0 ? undefined : port;
|
|
168
187
|
}
|
|
169
188
|
function modeOf(args) {
|
|
170
189
|
return args.flags.has("walk") ? "walk" : "split";
|
|
@@ -197,16 +216,33 @@ async function check(cwd, args) {
|
|
|
197
216
|
say(formatWarning(w));
|
|
198
217
|
return 0;
|
|
199
218
|
}
|
|
219
|
+
/** Runs one command under the session's lock, so two agents never apply or review it at once. */
|
|
220
|
+
async function locked(session, command, run) {
|
|
221
|
+
const held = session.lock(command);
|
|
222
|
+
try {
|
|
223
|
+
return await run(held);
|
|
224
|
+
}
|
|
225
|
+
finally {
|
|
226
|
+
held.release();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function splitBranch(session) {
|
|
230
|
+
return `${session.anchor.branch}-split`;
|
|
231
|
+
}
|
|
200
232
|
async function apply(cwd, args) {
|
|
201
233
|
const session = await openSession(cwd);
|
|
234
|
+
return locked(session, "apply", () => applyLocked(session, args));
|
|
235
|
+
}
|
|
236
|
+
async function applyLocked(session, args) {
|
|
202
237
|
const verdict = validate(session, session.readDraft());
|
|
203
238
|
if (!verdict.ok)
|
|
204
239
|
return 1;
|
|
205
240
|
const version = session.freeze(verdict.plan);
|
|
206
|
-
const name = args.values.get("name") ??
|
|
241
|
+
const name = args.values.get("name") ?? splitBranch(session);
|
|
207
242
|
const last = session.applied();
|
|
208
243
|
const result = await writeBranch(session.root, session.diff, verdict.plan, {
|
|
209
244
|
name,
|
|
245
|
+
source: session.anchor.branch,
|
|
210
246
|
expectedOld: last?.branch === name ? last.head : null,
|
|
211
247
|
force: args.flags.has("force"),
|
|
212
248
|
});
|
|
@@ -269,18 +305,87 @@ function latestPlan(session) {
|
|
|
269
305
|
return null;
|
|
270
306
|
return { version: session.freeze(verdict.plan), plan: verdict.plan };
|
|
271
307
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
308
|
+
function announce(mode, url, args) {
|
|
309
|
+
say(`follow-me ${mode === "walk" ? "code review" : "review"}: ${url}`);
|
|
310
|
+
if (!args.flags.has("no-open"))
|
|
311
|
+
openBrowser(url);
|
|
312
|
+
}
|
|
313
|
+
/** The server that already runs for this user, or one on --port that answers as ours. */
|
|
314
|
+
async function runningServer(home, port) {
|
|
315
|
+
const live = await findServer(home);
|
|
316
|
+
if (live !== null)
|
|
317
|
+
return live.port;
|
|
318
|
+
if (port !== undefined && (await probeHealth(port)) !== null)
|
|
319
|
+
return port;
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Hosts the session on the user's one review server. A running server gets the session and the
|
|
324
|
+
* CLI exits; otherwise this process becomes the server, in the foreground or, with --detach, as
|
|
325
|
+
* a background process. The CLI prepares the plan under the session lock and hands the lock to
|
|
326
|
+
* whichever process hosts it. report runs when a foreground review ends.
|
|
327
|
+
*/
|
|
328
|
+
async function hostReview(session, mode, args, prepare, report) {
|
|
329
|
+
const home = stateDir();
|
|
330
|
+
const lock = session.lockState();
|
|
331
|
+
const live = await findServer(home);
|
|
332
|
+
if (live !== null && lock.kind === "held" && lock.pid === live.pid) {
|
|
333
|
+
const id = sessionIdOf(session.dir);
|
|
334
|
+
const open = (await listSessions(live.port)).find((s) => s.id === id && s.mode === mode);
|
|
335
|
+
if (open !== undefined) {
|
|
336
|
+
announce(mode, sessionUrl(live.port, open), args);
|
|
337
|
+
return { kind: "elsewhere" };
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return locked(session, mode === "walk" ? "walk" : "review", async (held) => {
|
|
341
|
+
const prepared = await prepare();
|
|
342
|
+
if (prepared === null)
|
|
343
|
+
return { kind: "refused" };
|
|
344
|
+
const registration = {
|
|
345
|
+
dir: session.dir,
|
|
346
|
+
mode,
|
|
347
|
+
version: prepared.version,
|
|
348
|
+
hash: prepared.plan.hash,
|
|
349
|
+
lockToken: held.token,
|
|
350
|
+
};
|
|
351
|
+
const port = portOf(args);
|
|
352
|
+
let started;
|
|
353
|
+
const running = await runningServer(home, port);
|
|
354
|
+
if (running !== null)
|
|
355
|
+
started = { kind: "attached", port: running };
|
|
356
|
+
else if (args.flags.has("detach")) {
|
|
357
|
+
const server = await spawnDetached(home, port);
|
|
358
|
+
if (port !== undefined && server.port !== port)
|
|
359
|
+
say(portFallbackMessage(port, server.port));
|
|
360
|
+
started = { kind: "attached", port: server.port };
|
|
361
|
+
}
|
|
362
|
+
else
|
|
363
|
+
started = await startOrAttach(home, port, say);
|
|
364
|
+
if (started.kind === "attached") {
|
|
365
|
+
const summary = await registerWith(started.port, registration);
|
|
366
|
+
announce(mode, sessionUrl(started.port, summary), args);
|
|
367
|
+
return { kind: "elsewhere" };
|
|
368
|
+
}
|
|
369
|
+
const { host } = started;
|
|
370
|
+
const { summary, done } = await host.register(registration);
|
|
371
|
+
announce(mode, sessionUrl(host.port, summary), args);
|
|
372
|
+
const review = await done;
|
|
373
|
+
if (review === null)
|
|
374
|
+
return { kind: "stopped" };
|
|
375
|
+
report(review);
|
|
376
|
+
const others = host.sessions().length;
|
|
377
|
+
say(others > 0
|
|
378
|
+
? `still serving ${plural(others, "other session")} at ${host.url}; this process exits after they end`
|
|
379
|
+
: `the server exits in ${Math.ceil(idleMs() / 1000)}s unless another review starts; ctrl-c stops it now`);
|
|
380
|
+
await host.closed;
|
|
381
|
+
return { kind: "ended" };
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
function exitCode(hosted) {
|
|
385
|
+
if (hosted.kind === "stopped") {
|
|
386
|
+
console.error("follow-me: the server stopped before the review ended; its comments are kept");
|
|
387
|
+
}
|
|
388
|
+
return hosted.kind === "refused" || hosted.kind === "stopped" ? 1 : 0;
|
|
284
389
|
}
|
|
285
390
|
function alreadyReviewed(session, version, args) {
|
|
286
391
|
if (!session.reviews().some((r) => r.version === version))
|
|
@@ -290,14 +395,14 @@ function alreadyReviewed(session, version, args) {
|
|
|
290
395
|
}
|
|
291
396
|
async function review(cwd, args) {
|
|
292
397
|
const session = await openSession(cwd);
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
say(`review v${
|
|
300
|
-
return
|
|
398
|
+
const prepare = async () => {
|
|
399
|
+
const latest = latestPlan(session);
|
|
400
|
+
if (latest === null || alreadyReviewed(session, latest.version, args))
|
|
401
|
+
return null;
|
|
402
|
+
return latest;
|
|
403
|
+
};
|
|
404
|
+
const report = (review) => say(`review v${review.planVersion} saved with ${plural(review.comments.length, "comment")}`);
|
|
405
|
+
return exitCode(await hostReview(session, "split", args, prepare, report));
|
|
301
406
|
}
|
|
302
407
|
/** Opens the reviewer's session, re-extracting instead of failing when the branch moved. */
|
|
303
408
|
async function openForWalk(cwd, args) {
|
|
@@ -341,6 +446,15 @@ function hasApiCredentials() {
|
|
|
341
446
|
}
|
|
342
447
|
async function walk(cwd, args) {
|
|
343
448
|
const session = await openForWalk(cwd, args);
|
|
449
|
+
const report = (review) => {
|
|
450
|
+
const saved = join(session.dir, "reviews", `v${review.planVersion}.json`);
|
|
451
|
+
say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
|
|
452
|
+
say(`\`follow-me feedback${branchFlag(args)}\` prints it as a code review for the author`);
|
|
453
|
+
};
|
|
454
|
+
const hosted = await hostReview(session, "walk", args, () => walkPlan(session, cwd, args), report);
|
|
455
|
+
return exitCode(hosted);
|
|
456
|
+
}
|
|
457
|
+
async function walkPlan(session, cwd, args) {
|
|
344
458
|
let latest = walkablePlan(session);
|
|
345
459
|
if (latest === null) {
|
|
346
460
|
if (!hasApiCredentials()) {
|
|
@@ -348,20 +462,14 @@ async function walk(cwd, args) {
|
|
|
348
462
|
say("Have a coding agent follow the prompt, then walk again:");
|
|
349
463
|
say(` follow-me prompt --walk${branchFlag(args)}`);
|
|
350
464
|
say(` follow-me walk${branchFlag(args)}`);
|
|
351
|
-
return
|
|
465
|
+
return null;
|
|
352
466
|
}
|
|
353
467
|
const drafted = await draftWithApi(session, cwd, "walk", args);
|
|
354
468
|
if (drafted === null)
|
|
355
|
-
return
|
|
469
|
+
return null;
|
|
356
470
|
latest = { version: session.freeze(drafted), plan: drafted };
|
|
357
471
|
}
|
|
358
|
-
|
|
359
|
-
return 1;
|
|
360
|
-
const review = await serve(session, "walk", latest, args);
|
|
361
|
-
const saved = join(session.dir, "reviews", `v${latest.version}.json`);
|
|
362
|
-
say(`code review saved to ${relative(cwd, saved)} with ${plural(review.comments.length, "comment")}`);
|
|
363
|
-
say(`\`follow-me feedback${branchFlag(args)}\` prints it as a code review for the author`);
|
|
364
|
-
return 0;
|
|
472
|
+
return alreadyReviewed(session, latest.version, args) ? null : latest;
|
|
365
473
|
}
|
|
366
474
|
async function feedback(cwd, args) {
|
|
367
475
|
const session = await openSession(cwd, sessionOpts(args));
|
|
@@ -378,12 +486,77 @@ async function feedback(cwd, args) {
|
|
|
378
486
|
process.stdout.write(renderFeedback(stored.review, plan.file, session.diff.units));
|
|
379
487
|
return 0;
|
|
380
488
|
}
|
|
489
|
+
function describeLock(lock) {
|
|
490
|
+
if (lock.kind === "free")
|
|
491
|
+
return "free";
|
|
492
|
+
const who = `pid ${lock.pid} (follow-me ${lock.command})`;
|
|
493
|
+
return lock.kind === "held"
|
|
494
|
+
? `held by ${who}`
|
|
495
|
+
: `stale, ${who} is gone; follow-me clean removes it`;
|
|
496
|
+
}
|
|
497
|
+
/** The URL of this session on the user's server, when that server is what holds its lock. */
|
|
498
|
+
async function hostedPage(session, holder) {
|
|
499
|
+
const live = await findServer(stateDir());
|
|
500
|
+
if (live === null || live.pid !== holder)
|
|
501
|
+
return null;
|
|
502
|
+
const id = sessionIdOf(session.dir);
|
|
503
|
+
const open = (await listSessions(live.port)).find((s) => s.id === id);
|
|
504
|
+
return open === undefined ? null : sessionUrl(live.port, open);
|
|
505
|
+
}
|
|
506
|
+
async function serverCommand(argv) {
|
|
507
|
+
const [verb, ...rest] = argv;
|
|
508
|
+
const home = stateDir();
|
|
509
|
+
switch (verb) {
|
|
510
|
+
case "status": {
|
|
511
|
+
parseArgs(rest, {});
|
|
512
|
+
const live = await findServer(home);
|
|
513
|
+
if (live === null) {
|
|
514
|
+
say("no server running");
|
|
515
|
+
return 0;
|
|
516
|
+
}
|
|
517
|
+
say(`server pid ${live.pid} at http://127.0.0.1:${live.port}/ since ${live.startedAt}`);
|
|
518
|
+
const sessions = await listSessions(live.port);
|
|
519
|
+
if (sessions.length === 0)
|
|
520
|
+
say(" no sessions");
|
|
521
|
+
for (const s of sessions) {
|
|
522
|
+
const what = s.mode === "walk" ? "walk" : "review";
|
|
523
|
+
say(` ${sessionUrl(live.port, s)} ${s.branch} ${what} of plan v${s.version}, ${plural(s.comments, "comment")} ${s.repo}`);
|
|
524
|
+
}
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
case "stop": {
|
|
528
|
+
parseArgs(rest, {});
|
|
529
|
+
const stopped = await stopServer(home);
|
|
530
|
+
say(stopped === null ? "no server running" : `stopped server pid ${stopped.pid}`);
|
|
531
|
+
return 0;
|
|
532
|
+
}
|
|
533
|
+
// What `--detach` runs in the background; not meant to be typed.
|
|
534
|
+
case "run": {
|
|
535
|
+
const started = await startOrAttach(home, portOf(parseArgs(rest, { values: ["port"] })), say);
|
|
536
|
+
if (started.kind === "attached") {
|
|
537
|
+
say(`a follow-me server already runs on port ${started.port}`);
|
|
538
|
+
return 0;
|
|
539
|
+
}
|
|
540
|
+
say(`follow-me server: ${started.host.url}`);
|
|
541
|
+
await started.host.closed;
|
|
542
|
+
return 0;
|
|
543
|
+
}
|
|
544
|
+
default:
|
|
545
|
+
throw new UsageError("usage: follow-me server status|stop");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
381
548
|
async function status(cwd) {
|
|
382
549
|
const session = await openSession(cwd);
|
|
383
550
|
const { anchor, diff } = session;
|
|
384
551
|
const plans = session.plans();
|
|
385
552
|
const reviews = session.reviews();
|
|
386
553
|
say(`${anchor.branch} ${short(anchor.tip)} onto ${anchor.baseRef} ${short(anchor.base)}`);
|
|
554
|
+
say(`writes to: ${splitBranch(session)}`);
|
|
555
|
+
const lock = session.lockState();
|
|
556
|
+
say(`lock: ${describeLock(lock)}`);
|
|
557
|
+
const page = lock.kind === "held" ? await hostedPage(session, lock.pid) : null;
|
|
558
|
+
if (page !== null)
|
|
559
|
+
say(`review page: ${page}`);
|
|
387
560
|
say(`units: ${plural(diff.units.size, "unit")}`);
|
|
388
561
|
let hasDraft = true;
|
|
389
562
|
try {
|
|
@@ -412,6 +585,14 @@ async function status(cwd) {
|
|
|
412
585
|
: `applied: plan v${applied.version} as ${applied.branch} at ${short(applied.head)}`);
|
|
413
586
|
return 0;
|
|
414
587
|
}
|
|
588
|
+
function skillTargets(args) {
|
|
589
|
+
return {
|
|
590
|
+
harness: args.values.get("harness"),
|
|
591
|
+
all: args.flags.has("all"),
|
|
592
|
+
user: args.flags.has("user"),
|
|
593
|
+
dryRun: args.flags.has("dry-run"),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
415
596
|
export async function main(argv, cwd) {
|
|
416
597
|
const [command, ...rest] = argv;
|
|
417
598
|
try {
|
|
@@ -437,13 +618,33 @@ export async function main(argv, cwd) {
|
|
|
437
618
|
case "plan":
|
|
438
619
|
return await plan(cwd, parseArgs(rest, { values: ["model", "branch"], flags: ["walk"] }));
|
|
439
620
|
case "review":
|
|
440
|
-
return await review(cwd, parseArgs(rest, { values: ["port"], flags: ["no-open"] }));
|
|
621
|
+
return await review(cwd, parseArgs(rest, { values: ["port"], flags: ["no-open", "detach"] }));
|
|
441
622
|
case "walk": {
|
|
442
|
-
const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open"] };
|
|
623
|
+
const spec = { values: ["base", "branch", "model", "port"], flags: ["no-open", "detach"] };
|
|
443
624
|
return await walk(cwd, parseArgs(rest, spec));
|
|
444
625
|
}
|
|
626
|
+
case "server":
|
|
627
|
+
return await serverCommand(rest);
|
|
445
628
|
case "feedback":
|
|
446
629
|
return await feedback(cwd, parseArgs(rest, { values: ["branch"] }));
|
|
630
|
+
case "clean": {
|
|
631
|
+
const args = parseArgs(rest, { flags: ["all", "dry-run"] });
|
|
632
|
+
const opts = { all: args.flags.has("all"), dryRun: args.flags.has("dry-run") };
|
|
633
|
+
for (const path of await cleanSessions(cwd, opts))
|
|
634
|
+
say(relative(cwd, path));
|
|
635
|
+
return 0;
|
|
636
|
+
}
|
|
637
|
+
case "install": {
|
|
638
|
+
const args = parseArgs(rest, {
|
|
639
|
+
values: ["harness"],
|
|
640
|
+
flags: ["all", "user", "dry-run", "list"],
|
|
641
|
+
});
|
|
642
|
+
return runInstall({ ...skillTargets(args), list: args.flags.has("list") }, homedir(), cwd, say);
|
|
643
|
+
}
|
|
644
|
+
case "uninstall": {
|
|
645
|
+
const args = parseArgs(rest, { values: ["harness"], flags: ["all", "user", "dry-run"] });
|
|
646
|
+
return runUninstall(skillTargets(args), homedir(), cwd, say);
|
|
647
|
+
}
|
|
447
648
|
case undefined:
|
|
448
649
|
case "help":
|
|
449
650
|
case "--help":
|
|
@@ -459,6 +660,8 @@ export async function main(argv, cwd) {
|
|
|
459
660
|
BranchMovedError,
|
|
460
661
|
BranchRefusedError,
|
|
461
662
|
SessionFileError,
|
|
663
|
+
SessionLockedError,
|
|
664
|
+
RegistrationError,
|
|
462
665
|
GitError,
|
|
463
666
|
PlanRequestError,
|
|
464
667
|
];
|
package/dist/src/git.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
2
|
+
import { link, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { isSha, neededBlobs, parseDiff } from "./diff.js";
|
|
6
5
|
export class GitError extends Error {
|
|
@@ -24,6 +23,13 @@ function git(repo, args, opts = {}) {
|
|
|
24
23
|
else
|
|
25
24
|
resolve(stdout);
|
|
26
25
|
});
|
|
26
|
+
// git can exit before reading its input (a bad ref, a missing repo). On Linux the
|
|
27
|
+
// pending write then fails with EPIPE; the callback above already carries git's
|
|
28
|
+
// real error, so that write failure is noise.
|
|
29
|
+
child.stdin?.on("error", (error) => {
|
|
30
|
+
if (error.code !== "EPIPE")
|
|
31
|
+
reject(error);
|
|
32
|
+
});
|
|
27
33
|
child.stdin?.end(opts.input ?? "");
|
|
28
34
|
});
|
|
29
35
|
}
|
|
@@ -64,6 +70,22 @@ export async function currentBranch(repo) {
|
|
|
64
70
|
throw error;
|
|
65
71
|
}
|
|
66
72
|
}
|
|
73
|
+
/** Each branch checked out in some worktree of the repo, mapped to that worktree's path. */
|
|
74
|
+
export async function checkedOutBranches(repo) {
|
|
75
|
+
const out = (await git(repo, ["worktree", "list", "--porcelain", "-z"])).toString("utf8");
|
|
76
|
+
const found = new Map();
|
|
77
|
+
let path = "";
|
|
78
|
+
for (const field of out.split("\0")) {
|
|
79
|
+
if (field.startsWith("worktree "))
|
|
80
|
+
path = field.slice("worktree ".length);
|
|
81
|
+
if (field.startsWith("branch refs/heads/"))
|
|
82
|
+
found.set(field.slice("branch refs/heads/".length), path);
|
|
83
|
+
}
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
export async function branchExists(repo, name) {
|
|
87
|
+
return (await resolveOptional(repo, `refs/heads/${name}`)) !== null;
|
|
88
|
+
}
|
|
67
89
|
export async function defaultBaseRef(repo) {
|
|
68
90
|
for (const name of ["main", "master"]) {
|
|
69
91
|
if ((await resolveOptional(repo, `refs/heads/${name}`)) !== null)
|
|
@@ -132,7 +154,7 @@ function identityEnv(who, back) {
|
|
|
132
154
|
GIT_COMMITTER_DATE: date(who.committer),
|
|
133
155
|
};
|
|
134
156
|
}
|
|
135
|
-
async function indexLine(repo, change, zero) {
|
|
157
|
+
async function indexLine(repo, change, zero, env) {
|
|
136
158
|
const entry = async () => {
|
|
137
159
|
if (change.op === "delete")
|
|
138
160
|
return `0 ${zero}`;
|
|
@@ -140,6 +162,7 @@ async function indexLine(repo, change, zero) {
|
|
|
140
162
|
return `${change.entry.mode} ${change.entry.blob}`;
|
|
141
163
|
const blob = await gitText(repo, ["hash-object", "-w", "--stdin"], {
|
|
142
164
|
input: Buffer.from(change.bytes, "latin1"),
|
|
165
|
+
env,
|
|
143
166
|
});
|
|
144
167
|
return `${change.mode} ${toSha(blob, change.path)}`;
|
|
145
168
|
};
|
|
@@ -149,9 +172,35 @@ async function indexLine(repo, change, zero) {
|
|
|
149
172
|
Buffer.from([0]),
|
|
150
173
|
]);
|
|
151
174
|
}
|
|
175
|
+
/** Prefix of the quarantine directories writeBranch creates inside the object store. */
|
|
176
|
+
export const QUARANTINE_PREFIX = "follow-me-incoming-";
|
|
177
|
+
export async function objectsDir(repo) {
|
|
178
|
+
return gitText(repo, ["rev-parse", "--path-format=absolute", "--git-path", "objects"]);
|
|
179
|
+
}
|
|
180
|
+
/** Hard-links the quarantined loose objects into the store; an object already there stays. */
|
|
181
|
+
async function adoptObjects(quarantine, objects) {
|
|
182
|
+
for (const fanout of await readdir(quarantine)) {
|
|
183
|
+
if (!/^[0-9a-f]{2}$/.test(fanout))
|
|
184
|
+
continue;
|
|
185
|
+
await mkdir(join(objects, fanout), { recursive: true });
|
|
186
|
+
for (const name of await readdir(join(quarantine, fanout))) {
|
|
187
|
+
if (name.startsWith("tmp_"))
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
await link(join(quarantine, fanout, name), join(objects, fanout, name));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST"))
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
152
199
|
/**
|
|
153
200
|
* Writes the plan as commits on top of the merge-base through a temporary index, so the
|
|
154
201
|
* user's index and worktree are never touched, then moves refs/heads/<name> by compare-and-swap.
|
|
202
|
+
* New objects go to a quarantine inside the object store and are adopted only right before the
|
|
203
|
+
* ref moves, so a refused or failed write leaves neither refs nor objects behind.
|
|
155
204
|
*/
|
|
156
205
|
export async function writeBranch(repo, diff, plan, opts) {
|
|
157
206
|
const ref = `refs/heads/${opts.name}`;
|
|
@@ -161,15 +210,27 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
161
210
|
catch {
|
|
162
211
|
throw new BranchRefusedError(`"${opts.name}" is not a valid branch name`);
|
|
163
212
|
}
|
|
213
|
+
if (opts.name === opts.source) {
|
|
214
|
+
throw new BranchRefusedError(`${opts.name} is the branch being split; the split always lands on a separate branch, so pick another --name (--force does not change this)`);
|
|
215
|
+
}
|
|
216
|
+
const worktree = (await checkedOutBranches(repo)).get(opts.name);
|
|
217
|
+
if (worktree !== undefined) {
|
|
218
|
+
throw new BranchRefusedError(`branch ${opts.name} is checked out in ${worktree}; moving it would leave that worktree out of sync, so pick another --name (--force does not change this)`);
|
|
219
|
+
}
|
|
164
220
|
const who = await identities(repo, diff.tip);
|
|
165
221
|
const zero = "0".repeat(diff.base.length);
|
|
166
222
|
const n = plan.commits.length;
|
|
167
|
-
const
|
|
223
|
+
const objects = await objectsDir(repo);
|
|
224
|
+
const dir = await mkdtemp(join(objects, `${QUARANTINE_PREFIX}${process.pid}-`));
|
|
168
225
|
const commits = [];
|
|
169
226
|
let head = diff.base;
|
|
170
227
|
let tree = diff.baseTree;
|
|
171
228
|
try {
|
|
172
|
-
const env = {
|
|
229
|
+
const env = {
|
|
230
|
+
GIT_INDEX_FILE: join(dir, "index"),
|
|
231
|
+
GIT_OBJECT_DIRECTORY: dir,
|
|
232
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: objects,
|
|
233
|
+
};
|
|
173
234
|
await git(repo, ["read-tree", diff.baseTree], { env });
|
|
174
235
|
for (const commit of plan.commits) {
|
|
175
236
|
// Deletes go first so a file can give way to a directory of the same name.
|
|
@@ -177,34 +238,35 @@ export async function writeBranch(repo, diff, plan, opts) {
|
|
|
177
238
|
...commit.changes.filter((c) => c.op === "delete"),
|
|
178
239
|
...commit.changes.filter((c) => c.op !== "delete"),
|
|
179
240
|
];
|
|
180
|
-
const lines = await Promise.all(ordered.map((c) => indexLine(repo, c, zero)));
|
|
241
|
+
const lines = await Promise.all(ordered.map((c) => indexLine(repo, c, zero, env)));
|
|
181
242
|
await git(repo, ["update-index", "-z", "--index-info"], { env, input: Buffer.concat(lines) });
|
|
182
243
|
tree = toSha(await gitText(repo, ["write-tree"], { env }), "write-tree");
|
|
183
244
|
const message = commit.message.endsWith("\n") ? commit.message : `${commit.message}\n`;
|
|
184
245
|
head = toSha(await gitText(repo, ["commit-tree", "--no-gpg-sign", tree, "-p", head, "-F", "-"], {
|
|
185
246
|
input: message,
|
|
186
|
-
env: identityEnv(who, n - 1 - commit.index),
|
|
247
|
+
env: { ...env, ...identityEnv(who, n - 1 - commit.index) },
|
|
187
248
|
}), "commit-tree");
|
|
188
249
|
commits.push(head);
|
|
189
250
|
}
|
|
251
|
+
if (tree !== diff.tipTree) {
|
|
252
|
+
throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
|
|
253
|
+
}
|
|
254
|
+
const current = await resolveOptional(repo, ref);
|
|
255
|
+
if (current === head)
|
|
256
|
+
return { kind: "unchanged", branch: opts.name, head, commits };
|
|
257
|
+
if (current !== null && !opts.force) {
|
|
258
|
+
if (opts.expectedOld === null) {
|
|
259
|
+
throw new BranchRefusedError(`branch ${opts.name} already exists and follow-me did not write it; pass --force to overwrite`);
|
|
260
|
+
}
|
|
261
|
+
if (current !== opts.expectedOld) {
|
|
262
|
+
throw new BranchRefusedError(`branch ${opts.name} moved since the last apply (expected ${opts.expectedOld}, found ${current}); pass --force to overwrite`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
await adoptObjects(dir, objects);
|
|
266
|
+
await git(repo, ["update-ref", "-m", "follow-me apply", ref, head, current ?? zero]);
|
|
267
|
+
return { kind: current === null ? "created" : "updated", branch: opts.name, head, commits };
|
|
190
268
|
}
|
|
191
269
|
finally {
|
|
192
270
|
await rm(dir, { recursive: true, force: true });
|
|
193
271
|
}
|
|
194
|
-
if (tree !== diff.tipTree) {
|
|
195
|
-
throw new Error(`the last commit's tree ${tree} differs from the tip tree ${diff.tipTree}`);
|
|
196
|
-
}
|
|
197
|
-
const current = await resolveOptional(repo, ref);
|
|
198
|
-
if (current === head)
|
|
199
|
-
return { kind: "unchanged", branch: opts.name, head, commits };
|
|
200
|
-
if (current !== null && !opts.force) {
|
|
201
|
-
if (opts.expectedOld === null) {
|
|
202
|
-
throw new BranchRefusedError(`branch ${opts.name} already exists and follow-me did not write it; pass --force to overwrite`);
|
|
203
|
-
}
|
|
204
|
-
if (current !== opts.expectedOld) {
|
|
205
|
-
throw new BranchRefusedError(`branch ${opts.name} moved since the last apply (expected ${opts.expectedOld}, found ${current}); pass --force to overwrite`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
await git(repo, ["update-ref", "-m", "follow-me apply", ref, head, current ?? zero]);
|
|
209
|
-
return { kind: current === null ? "created" : "updated", branch: opts.name, head, commits };
|
|
210
272
|
}
|