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);