From f73f29d541d6484af4e7ca63a181e38874756362 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:41:02 -0700 Subject: [PATCH] feat(server): package and apply a thread's git work A handoff has to carry the thread's code, not only its conversation: commits not on any remote, plus uncommitted edits, deletes and new files. HandoffGit packs that as one git bundle. The working tree goes in as a snapshot commit on the branch tip, made in a private index as checkpoints are, so the user's staging is untouched; ignored files stay behind. The bundle holds only what no remote has, and over 50 MiB the handoff is refused with "push the branch". Applying it fetches the base from origin when the bundle needs it, creates a new worktree on the branch, restores the snapshot and resets the index to the tip so the changes show as uncommitted. An existing branch only moves forward, with a compare-and-swap ref update, and never while another checkout has it at a different commit. Any failing step undoes the ones before it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/peer/handoff/HandoffGit.test.ts | 399 ++++++++++++++++++ apps/server/src/peer/handoff/HandoffGit.ts | 366 ++++++++++++++++ 2 files changed, 765 insertions(+) create mode 100644 apps/server/src/peer/handoff/HandoffGit.test.ts create mode 100644 apps/server/src/peer/handoff/HandoffGit.ts diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts new file mode 100644 index 0000000000000..61265c55c9fdb --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -0,0 +1,399 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as HandoffGit from "./HandoffGit.ts"; + +// A thread's git work moves from the laptop's checkout to the box's clone of +// the same repository: the commits the remote lacks, the edits, the deletes +// and the new files, exactly, and nothing on the box is overwritten. + +const layer = HandoffGit.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +const sh = (cwd: string, args: ReadonlyArray) => + VcsProcess.VcsProcess.pipe( + Effect.flatMap((processes) => + processes.run({ + operation: "HandoffGit.test", + command: "git", + cwd, + args, + env: { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }), + ), + Effect.map((result) => result.stdout.trim()), + Effect.orDie, + ); + +/** `git status --porcelain`, sorted; untrimmed, since its first column is a space. */ +const status = (cwd: string) => + VcsProcess.VcsProcess.pipe( + Effect.flatMap((processes) => + processes.run({ + operation: "HandoffGit.test", + command: "git", + cwd, + args: ["status", "--porcelain"], + }), + ), + Effect.map((result) => + result.stdout + .split("\n") + .filter((line) => line !== "") + .sort(), + ), + Effect.orDie, + ); + +/** An origin with one commit on main, and the laptop's and the box's clones of it. */ +const repos = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-handoff-git-" }); + const origin = path.join(root, "origin.git"); + const seed = path.join(root, "seed"); + yield* fileSystem.makeDirectory(seed); + yield* sh(root, ["init", "--quiet", "--bare", "--initial-branch=main", origin]); + yield* sh(seed, ["init", "--quiet", "--initial-branch=main"]); + yield* fileSystem.writeFileString(path.join(seed, "app.ts"), "export const version = 1;\n"); + yield* fileSystem.writeFileString(path.join(seed, "old.ts"), "export const gone = true;\n"); + yield* sh(seed, ["add", "."]); + yield* sh(seed, ["commit", "--quiet", "-m", "start"]); + yield* sh(seed, ["remote", "add", "origin", origin]); + yield* sh(seed, ["push", "--quiet", "origin", "main"]); + const laptop = path.join(root, "laptop"); + const box = path.join(root, "box"); + yield* sh(root, ["clone", "--quiet", origin, laptop]); + yield* sh(root, ["clone", "--quiet", origin, box]); + const outDir = path.join(root, "out"); + yield* fileSystem.makeDirectory(outDir); + return { root, origin, laptop, box, outDir }; +}); + +/** The laptop's thread: an unpushed commit on a branch, then uncommitted work. */ +const workOnLaptop = (laptop: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* sh(laptop, ["checkout", "--quiet", "-b", "fix/login"]); + yield* fileSystem.writeFileString(path.join(laptop, "app.ts"), "export const version = 2;\n"); + yield* sh(laptop, ["commit", "--quiet", "-am", "unpushed: bump version"]); + yield* fileSystem.writeFileString(path.join(laptop, "app.ts"), "export const version = 3;\n"); + yield* fileSystem.remove(path.join(laptop, "old.ts")); + yield* fileSystem.writeFileString(path.join(laptop, "new.ts"), "export const fresh = true;\n"); + yield* fileSystem.writeFileString(path.join(laptop, ".env"), "SECRET=1\n"); + yield* fileSystem.writeFileString(path.join(laptop, ".gitignore"), ".env\n"); + yield* sh(laptop, ["add", "new.ts"]); + }); + +it.layer(layer)("handing a thread's git work to another checkout", (it) => { + it.effect("carries an unpushed commit, edits, deletes and new files exactly", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir, root } = yield* repos; + yield* workOnLaptop(laptop); + const stagedBefore = yield* sh(laptop, ["diff", "--cached", "--name-only"]); + + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h1", outDir }); + assert.equal(packed.branch, "fix/login"); + // Packing leaves the laptop's own staging and refs as they were. + assert.equal(yield* sh(laptop, ["diff", "--cached", "--name-only"]), stagedBefore); + assert.equal(yield* sh(laptop, ["for-each-ref", "refs/t3code"]), ""); + + const worktree = path.join(root, "box-worktree"); + const applied = yield* handoff.apply({ + repoRoot: box, + worktreePath: worktree, + handoffId: "h1", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }); + assert.equal(applied.branch, "fix/login"); + // The committed work, unpushed on the laptop, is the branch here. + assert.equal(yield* sh(worktree, ["rev-parse", "HEAD"]), packed.tip); + assert.equal(yield* sh(worktree, ["log", "-1", "--format=%s"]), "unpushed: bump version"); + // The working tree matches, and shows as uncommitted. + assert.equal( + yield* fileSystem.readFileString(path.join(worktree, "app.ts")), + "export const version = 3;\n", + ); + assert.isFalse(yield* fileSystem.exists(path.join(worktree, "old.ts"))); + assert.equal( + yield* fileSystem.readFileString(path.join(worktree, "new.ts")), + "export const fresh = true;\n", + ); + assert.deepEqual(yield* status(worktree), [ + " D old.ts", + " M app.ts", + "?? .gitignore", + "?? new.ts", + ]); + // Ignored files stay where they were. + assert.isFalse(yield* fileSystem.exists(path.join(worktree, ".env"))); + assert.equal(yield* sh(box, ["for-each-ref", "refs/t3code"]), ""); + }), + ), + ); + + it.effect("refuses a branch that moved on here, and leaves everything as it was", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir, root } = yield* repos; + yield* workOnLaptop(laptop); + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h2", outDir }); + + // The box has its own commit on the same branch. + yield* sh(box, ["checkout", "--quiet", "-b", "fix/login"]); + yield* fileSystem.writeFileString(path.join(box, "box.ts"), "export const mine = 1;\n"); + yield* sh(box, ["add", "."]); + yield* sh(box, ["commit", "--quiet", "-m", "box-only work"]); + yield* sh(box, ["checkout", "--quiet", "main"]); + const boxBranch = yield* sh(box, ["rev-parse", "fix/login"]); + const worktreesBefore = yield* sh(box, ["worktree", "list", "--porcelain"]); + + const refused = yield* handoff + .apply({ + repoRoot: box, + worktreePath: path.join(root, "box-worktree"), + handoffId: "h2", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }) + .pipe(Effect.flip); + assert.equal(refused._tag, "HandoffBranchDivergedError"); + assert.equal(yield* sh(box, ["rev-parse", "fix/login"]), boxBranch); + assert.equal(yield* sh(box, ["worktree", "list", "--porcelain"]), worktreesBefore); + assert.equal(yield* sh(box, ["for-each-ref", "refs/t3code"]), ""); + assert.isFalse(yield* fileSystem.exists(path.join(root, "box-worktree"))); + }), + ), + ); + + it.effect("moves a branch that is behind forward, and undoes it when a later step fails", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir, root } = yield* repos; + yield* workOnLaptop(laptop); + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h3", outDir }); + // The box has the branch at main, behind the laptop's tip. + yield* sh(box, ["branch", "fix/login", "main"]); + const before = yield* sh(box, ["rev-parse", "fix/login"]); + + // The worktree path is taken, so adding the worktree fails after the branch moved. + const taken = path.join(root, "taken"); + yield* fileSystem.makeDirectory(taken); + yield* fileSystem.writeFileString(path.join(taken, "keep.txt"), "mine\n"); + const failed = yield* handoff + .apply({ + repoRoot: box, + worktreePath: taken, + handoffId: "h3", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }) + .pipe(Effect.flip); + assert.equal(failed._tag, "HandoffGitStepError"); + assert.equal(yield* sh(box, ["rev-parse", "fix/login"]), before); + assert.equal(yield* sh(box, ["for-each-ref", "refs/t3code"]), ""); + assert.equal(yield* fileSystem.readFileString(path.join(taken, "keep.txt")), "mine\n"); + + // With a free path it moves forward and applies. + const applied = yield* handoff.apply({ + repoRoot: box, + worktreePath: path.join(root, "box-worktree"), + handoffId: "h3", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }); + assert.equal(applied.branch, "fix/login"); + assert.equal(yield* sh(box, ["rev-parse", "fix/login"]), packed.tip); + }), + ), + ); + + it.effect("carries the whole working tree when the thread sits in a subdirectory", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir, root } = yield* repos; + yield* workOnLaptop(laptop); + const packages = path.join(laptop, "packages"); + yield* fileSystem.makeDirectory(packages); + yield* fileSystem.writeFileString(path.join(packages, "lib.ts"), "export const lib = 1;\n"); + + const packed = yield* handoff.pack({ cwd: packages, handoffId: "h6", outDir }); + const worktree = path.join(root, "box-worktree"); + yield* handoff.apply({ + repoRoot: box, + worktreePath: worktree, + handoffId: "h6", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }); + // Edits above the subdirectory travel too, not only those under it. + assert.deepEqual(yield* status(worktree), [ + " D old.ts", + " M app.ts", + "?? .gitignore", + "?? new.ts", + "?? packages/", + ]); + }), + ), + ); + + it.effect("packs the thread's checkout even when the server runs inside another repository", () => + Effect.scoped( + Effect.gen(function* () { + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir } = yield* repos; + yield* workOnLaptop(laptop); + const boxGitDir = yield* sh(box, ["rev-parse", "--path-format=absolute", "--git-dir"]); + // As when the server is started from a git hook in the box's clone. + yield* Effect.acquireRelease( + Effect.sync(() => { + const before = process.env.GIT_DIR; + process.env.GIT_DIR = boxGitDir; + return before; + }), + (before) => + Effect.sync(() => { + if (before === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = before; + }), + ); + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h8", outDir }); + assert.equal(packed.branch, "fix/login"); + }), + ), + ); + + it.effect("keeps a branch it created when someone moved it before a later step failed", () => + Effect.scoped( + Effect.gen(function* () { + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir } = yield* repos; + yield* workOnLaptop(laptop); + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h7", outDir }); + // A hook that fires when the worktree is checked out moves the branch + // the apply just created, then fails the checkout. + const hooks = yield* sh(box, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "hooks", + ]); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const moved = yield* sh(box, ["rev-parse", "main"]); + const hook = path.join(hooks, "post-checkout"); + yield* fileSystem.makeDirectory(hooks, { recursive: true }); + yield* fileSystem.writeFileString( + hook, + `#!/bin/sh\ngit -C "${box}" update-ref refs/heads/fix/login ${moved}\nexit 1\n`, + ); + yield* fileSystem.chmod(hook, 0o755); + + const failed = yield* handoff + .apply({ + repoRoot: box, + worktreePath: path.join(path.dirname(box), "box-worktree"), + handoffId: "h7", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }) + .pipe(Effect.flip); + assert.equal(failed._tag, "HandoffGitStepError"); + // The rollback left the branch where the other process put it. + assert.equal(yield* sh(box, ["rev-parse", "fix/login"]), moved); + }), + ), + ); + + it.effect("fetches the base a bundle builds on, and refuses work over the size limit", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const handoff = yield* HandoffGit.HandoffGit; + const { laptop, box, outDir, root } = yield* repos; + // The laptop's branch builds on a commit pushed after the box cloned. + yield* fileSystem.writeFileString(path.join(laptop, "pushed.ts"), "export const p = 1;\n"); + yield* sh(laptop, ["add", "."]); + yield* sh(laptop, ["commit", "--quiet", "-m", "pushed later"]); + // Pushed through the remote, so the laptop knows origin has it and the + // bundle leaves it out: the box has to fetch it. + yield* sh(laptop, ["push", "--quiet", "origin", "main"]); + yield* workOnLaptop(laptop); + const packed = yield* handoff.pack({ cwd: laptop, handoffId: "h4", outDir }); + assert.isFalse( + (yield* sh(packed.bundlePath.replace(/\/[^/]+$/, ""), [ + "bundle", + "list-heads", + packed.bundlePath, + ])).includes(yield* sh(laptop, ["rev-parse", "main"])), + ); + const applied = yield* handoff.apply({ + repoRoot: box, + worktreePath: path.join(root, "box-worktree"), + handoffId: "h4", + bundlePath: packed.bundlePath, + branch: packed.branch, + tip: packed.tip, + snapshot: packed.snapshot, + }); + assert.equal(applied.branch, "fix/login"); + assert.isTrue(yield* fileSystem.exists(path.join(root, "box-worktree", "pushed.ts"))); + + // An untracked file bigger than a handoff carries, and incompressible. + const crypto = yield* Crypto.Crypto; + yield* fileSystem.writeFile( + path.join(laptop, "big.bin"), + yield* crypto.randomBytes(HandoffGit.MAX_HANDOFF_BUNDLE_BYTES + 1024).pipe(Effect.orDie), + ); + const tooLarge = yield* handoff + .pack({ cwd: laptop, handoffId: "h5", outDir }) + .pipe(Effect.flip); + assert.equal(tooLarge._tag, "HandoffTooLargeError"); + assert.isFalse(yield* fileSystem.exists(path.join(outDir, "h5.bundle"))); + }), + ), + ); +}); diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts new file mode 100644 index 0000000000000..4c1f84a012bbd --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -0,0 +1,366 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as VcsProcess from "../../vcs/VcsProcess.ts"; + +/** A bundle larger than one upload can carry is refused; push the branch first. */ +export const MAX_HANDOFF_BUNDLE_BYTES = 50 * 1024 * 1024; + +/** The work a thread hands off: its branch tip and the working tree on top of it. */ +export interface HandoffPackage { + /** The git bundle: commits not on any remote, plus the snapshot commit. */ + readonly bundlePath: string; + readonly bundleBytes: number; + /** The branch the work is on, or null for a detached checkout. */ + readonly branch: string | null; + /** The branch tip. The thread's committed work ends here. */ + readonly tip: string; + /** A commit on top of `tip` holding the working tree, untracked files included. */ + readonly snapshot: string; + /** The remote commit the bundle builds on, for a peer to fetch when it lacks it. */ + readonly baseRemoteUrl: string | null; +} + +/** `cwd` has no commit to hand off. */ +export class HandoffNotARepositoryError extends Schema.TaggedError()( + "HandoffNotARepositoryError", + { cwd: Schema.String }, +) { + override get message(): string { + return `${this.cwd} is not a git checkout with a commit.`; + } +} + +/** The unpushed work is more than one upload carries. */ +export class HandoffTooLargeError extends Schema.TaggedError()( + "HandoffTooLargeError", + { bundleBytes: Schema.Number }, +) { + override get message(): string { + return `The unpushed work is ${Math.ceil(this.bundleBytes / 1024 / 1024)} MiB, over the ${MAX_HANDOFF_BUNDLE_BYTES / 1024 / 1024} MiB a handoff carries. Push the branch, then hand off again.`; + } +} + +/** The checkout here lacks the commits the bundle builds on, even after fetching. */ +export class HandoffMissingBaseError extends Schema.TaggedError()( + "HandoffMissingBaseError", + {}, +) { + override get message(): string { + return "This checkout lacks the commits the handoff builds on, even after fetching. Push the branch where it came from, then hand off again."; + } +} + +/** The branch is checked out in another worktree here, at another commit. */ +export class HandoffBranchCheckedOutError extends Schema.TaggedError()( + "HandoffBranchCheckedOutError", + { branch: Schema.String }, +) { + override get message(): string { + return `${this.branch} is checked out in another worktree here and differs. Switch that checkout off it, then hand off again.`; + } +} + +/** The branch here has commits the handed-off work lacks. */ +export class HandoffBranchDivergedError extends Schema.TaggedError()( + "HandoffBranchDivergedError", + { branch: Schema.String }, +) { + override get message(): string { + return `${this.branch} here has commits the handed-off work lacks. Reconcile the branches, then hand off again.`; + } +} + +/** + * A git step failed, or left something other than what the handoff needs. + * `step` says which; the git failure, when there is one, is the cause. + */ +export class HandoffGitStepError extends Schema.TaggedError()( + "HandoffGitStepError", + { + step: Schema.String, + detail: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail ?? `git ${this.step} failed.`; + } +} + +export type HandoffGitError = + | HandoffNotARepositoryError + | HandoffTooLargeError + | HandoffMissingBaseError + | HandoffBranchCheckedOutError + | HandoffBranchDivergedError + | HandoffGitStepError; + +/** + * Moves a thread's git work between environments as one git bundle: the + * commits no remote has, and a snapshot commit of the working tree made with + * a temporary index, as checkpoints are. Applying it never touches a + * checkout it does not create, moves an existing branch only forward, and + * undoes everything it did when a step fails. + */ +export class HandoffGit extends Context.Service< + HandoffGit, + { + /** Packages `cwd`'s branch and working tree into a bundle in `outDir`. */ + readonly pack: (input: { + readonly cwd: string; + readonly handoffId: string; + readonly outDir: string; + }) => Effect.Effect; + /** + * Applies a package in `repoRoot`: a new worktree at `worktreePath` on its + * branch, with the working tree as it was. Nothing is left behind on failure. + */ + readonly apply: (input: { + readonly repoRoot: string; + readonly worktreePath: string; + readonly handoffId: string; + readonly bundlePath: string; + readonly branch: string | null; + readonly tip: string; + readonly snapshot: string; + }) => Effect.Effect<{ readonly branch: string | null }, HandoffGitError>; + } +>()("t3/peer/handoff/HandoffGit") {} + +const IDENTITY = { + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", +}; + +const handoffRef = (handoffId: string, name: string) => `refs/t3code/handoff/${handoffId}/${name}`; + +/** + * Repository-scoped variables the server may have inherited would point git + * at another repository, index or object store than `cwd`'s. + */ +const REPOSITORY_ENV = { + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_COMMON_DIR: undefined, + GIT_INDEX_FILE: undefined, + GIT_OBJECT_DIRECTORY: undefined, + GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, + GIT_NAMESPACE: undefined, + GIT_CEILING_DIRECTORIES: undefined, +} satisfies NodeJS.ProcessEnv; + +const make = Effect.gen(function* () { + const processes = yield* VcsProcess.VcsProcess; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const git = ( + cwd: string, + args: ReadonlyArray, + options: { readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean } = {}, + ) => + processes + .run({ + operation: "HandoffGit", + command: "git", + cwd, + args, + timeoutMs: 120_000, + env: { ...process.env, ...REPOSITORY_ENV, ...options.env }, + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), + }) + .pipe( + Effect.mapError( + (cause) => + new HandoffGitStepError({ + step: args[0] ?? "git", + cause, + }), + ), + // The caller sees only the step; git's own reason goes to the log. + Effect.tapError((error) => + Effect.logWarning("handoff-git.step-failed", { step: error.step, cause: error.cause }), + ), + ); + + const out = (cwd: string, args: ReadonlyArray) => + git(cwd, args).pipe(Effect.map((result) => result.stdout.trim())); + + const ok = (cwd: string, args: ReadonlyArray) => + git(cwd, args, { allowNonZeroExit: true }).pipe(Effect.map((result) => result.exitCode === 0)); + + const pack: HandoffGit["Service"]["pack"] = (input) => + Effect.gen(function* () { + if (!(yield* ok(input.cwd, ["rev-parse", "--verify", "HEAD"]))) { + return yield* new HandoffNotARepositoryError({ cwd: input.cwd }); + } + const tip = yield* out(input.cwd, ["rev-parse", "HEAD"]); + const branchName = yield* git(input.cwd, ["symbolic-ref", "--quiet", "--short", "HEAD"], { + allowNonZeroExit: true, + }); + const branch = branchName.exitCode === 0 ? branchName.stdout.trim() : null; + const gitDir = yield* out(input.cwd, ["rev-parse", "--path-format=absolute", "--git-dir"]); + const indexPath = path.join(gitDir, `t3-handoff-index-${input.handoffId}`); + // The working tree as a commit on top of the tip, in a private index so + // the user's staging is untouched. Ignored files stay behind. + const snapshot = yield* Effect.gen(function* () { + const env = { GIT_INDEX_FILE: indexPath, ...IDENTITY }; + yield* git(input.cwd, ["read-tree", "HEAD"], { env }); + // `:/` is the whole working tree, wherever in it the thread's checkout sits. + yield* git(input.cwd, ["add", "--all", "--", ":/"], { env }); + const tree = (yield* git(input.cwd, ["write-tree"], { env })).stdout.trim(); + return (yield* git( + input.cwd, + ["commit-tree", tree, "-p", tip, "-m", `T3 Code handoff ${input.handoffId}`], + { env }, + )).stdout.trim(); + }).pipe( + Effect.ensuring( + Effect.forEach([indexPath, `${indexPath}.lock`], (file) => + fileSystem.remove(file, { force: true }).pipe(Effect.ignore), + ), + ), + ); + const snapshotRef = handoffRef(input.handoffId, "snapshot"); + yield* git(input.cwd, ["update-ref", snapshotRef, snapshot]); + const bundlePath = path.join(input.outDir, `${input.handoffId}.bundle`); + // Only what no remote has: the peer fetches the rest from the remote. + yield* git(input.cwd, [ + "bundle", + "create", + bundlePath, + snapshotRef, + "--not", + "--remotes", + ]).pipe( + Effect.ensuring(git(input.cwd, ["update-ref", "-d", snapshotRef]).pipe(Effect.ignore)), + ); + const bundleBytes = Number( + (yield* fileSystem.stat(bundlePath).pipe( + Effect.mapError( + (cause) => + new HandoffGitStepError({ + step: "bundle", + detail: "The bundle was not written.", + cause, + }), + ), + )).size, + ); + if (bundleBytes > MAX_HANDOFF_BUNDLE_BYTES) { + yield* fileSystem.remove(bundlePath, { force: true }).pipe(Effect.ignore); + return yield* new HandoffTooLargeError({ bundleBytes }); + } + const remote = yield* git(input.cwd, ["remote", "get-url", "origin"], { + allowNonZeroExit: true, + }); + return { + bundlePath, + bundleBytes, + branch, + tip, + snapshot, + baseRemoteUrl: remote.exitCode === 0 ? remote.stdout.trim() : null, + }; + }); + + const apply: HandoffGit["Service"]["apply"] = (input) => + Effect.gen(function* () { + const fetched = handoffRef(input.handoffId, "snapshot"); + const undo: Array> = []; + // Read when it runs: the steps are pushed as the apply goes. + const rollback = Effect.suspend(() => + Effect.forEach(undo.toReversed(), (step) => step, { discard: true }), + ); + return yield* Effect.gen(function* () { + // The bundle builds on commits the remote has; fetch them if missing. + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + yield* git(input.repoRoot, ["fetch", "--quiet", "origin"], { allowNonZeroExit: true }); + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + return yield* new HandoffMissingBaseError(); + } + } + yield* git(input.repoRoot, [ + "fetch", + "--quiet", + "--no-tags", + input.bundlePath, + `${handoffRef(input.handoffId, "snapshot")}:${fetched}`, + ]); + undo.push(git(input.repoRoot, ["update-ref", "-d", fetched]).pipe(Effect.ignore)); + if ((yield* out(input.repoRoot, ["rev-parse", fetched])) !== input.snapshot) { + return yield* new HandoffGitStepError({ + step: "fetch", + detail: "The bundle does not hold the snapshot it was sent with.", + }); + } + // The branch here may only move forward to the tip, and only when no + // checkout has it, so nobody's work is overwritten. + let branch = input.branch; + if (branch !== null) { + const ref = `refs/heads/${branch}`; + const existing = yield* git(input.repoRoot, ["rev-parse", "--verify", "--quiet", ref], { + allowNonZeroExit: true, + }); + if (existing.exitCode === 0) { + const current = existing.stdout.trim(); + const checkedOut = (yield* out(input.repoRoot, ["worktree", "list", "--porcelain"])) + .split("\n") + .some((line) => line === `branch ${ref}`); + if (checkedOut && current !== input.tip) { + return yield* new HandoffBranchCheckedOutError({ branch }); + } + if (current !== input.tip) { + if ( + !(yield* ok(input.repoRoot, ["merge-base", "--is-ancestor", current, input.tip])) + ) { + return yield* new HandoffBranchDivergedError({ branch }); + } + // Compare-and-swap: fails if the branch moved since it was read. + yield* git(input.repoRoot, ["update-ref", ref, input.tip, current]); + undo.push( + git(input.repoRoot, ["update-ref", ref, current, input.tip]).pipe(Effect.ignore), + ); + } + if (checkedOut) branch = null; + } else { + yield* git(input.repoRoot, ["update-ref", ref, input.tip, ""]); + // Only while it is still the tip: a branch someone moved on since keeps their commits. + undo.push( + git(input.repoRoot, ["update-ref", "-d", ref, input.tip]).pipe(Effect.ignore), + ); + } + } + yield* git( + input.repoRoot, + branch === null + ? ["worktree", "add", "--detach", input.worktreePath, input.tip] + : ["worktree", "add", input.worktreePath, branch], + ); + undo.push( + git(input.repoRoot, ["worktree", "remove", "--force", input.worktreePath]).pipe( + Effect.ignore, + ), + ); + // The working tree as it was, untracked files included, then the + // index back on the tip so the changes show as uncommitted. + yield* git(input.worktreePath, ["checkout", "--no-overlay", input.snapshot, "--", "."]); + yield* git(input.worktreePath, ["reset", "--quiet", input.tip]); + yield* git(input.repoRoot, ["update-ref", "-d", fetched]).pipe(Effect.ignore); + return { branch }; + }).pipe(Effect.tapError(() => rollback)); + }); + + return HandoffGit.of({ pack, apply }); +}); + +export const layer = Layer.effect(HandoffGit, make);