@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 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 { loadDraftComments, serveReview } from "./server.js";
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 a branch of commits
18
- status show the session's plans, reviews and branch
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] review the latest plan commit by commit
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 { port };
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") ?? `${session.anchor.branch}-split`;
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
- async function serve(session, mode, latest, args) {
273
- const { version, plan } = latest;
274
- const reviewsDir = join(session.dir, "reviews");
275
- return serveReview({
276
- mode,
277
- anchor: session.anchor,
278
- version,
279
- planHash: plan.hash,
280
- commits: plan.commits,
281
- units: [...session.diff.units.values()],
282
- comments: await loadDraftComments(reviewsDir, version),
283
- }, { reviewsDir, open: !args.flags.has("no-open"), ...portOf(args) });
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 latest = latestPlan(session);
294
- if (latest === null)
295
- return 1;
296
- if (alreadyReviewed(session, latest.version, args))
297
- return 1;
298
- const review = await serve(session, "split", latest, args);
299
- say(`review v${latest.version} saved with ${plural(review.comments.length, "comment")}`);
300
- return 0;
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 1;
465
+ return null;
352
466
  }
353
467
  const drafted = await draftWithApi(session, cwd, "walk", args);
354
468
  if (drafted === null)
355
- return 1;
469
+ return null;
356
470
  latest = { version: session.freeze(drafted), plan: drafted };
357
471
  }
358
- if (alreadyReviewed(session, latest.version, args))
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 dir = await mkdtemp(join(tmpdir(), "follow-me-index-"));
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 = { GIT_INDEX_FILE: join(dir, "index") };
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
  }