From aa1bad8e065119ddb243e325af2faf4a3b5437aa 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 | 294 +++++++++++++++++ apps/server/src/peer/handoff/HandoffGit.ts | 295 ++++++++++++++++++ 2 files changed, 589 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 000000000000..9361148ff176 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -0,0 +1,294 @@ +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.reason, "diverged"); + 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.reason, "git_failed"); + 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("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.reason, "too_large"); + 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 000000000000..f71555871b08 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -0,0 +1,295 @@ +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; +} + +export class HandoffGitError extends Schema.TaggedError()("HandoffGitError", { + reason: Schema.Literals([ + "not_a_repository", + "too_large", + "missing_base", + "diverged", + "checked_out_elsewhere", + "git_failed", + ]), + message: Schema.String, +}) {} + +/** + * 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}`; + +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, + ...(options.env === undefined ? {} : { env: { ...process.env, ...options.env } }), + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), + }) + .pipe( + Effect.mapError( + (error) => + new HandoffGitError({ + reason: "git_failed", + message: `git ${args[0]} failed: ${error.message}`, + }), + ), + ); + + 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 HandoffGitError({ + reason: "not_a_repository", + message: `${input.cwd} is not a git checkout with a commit.`, + }); + } + 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 }); + 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( + () => + new HandoffGitError({ + reason: "git_failed", + message: "The bundle was not written.", + }), + ), + )).size, + ); + if (bundleBytes > MAX_HANDOFF_BUNDLE_BYTES) { + yield* fileSystem.remove(bundlePath, { force: true }).pipe(Effect.ignore); + return yield* new HandoffGitError({ + reason: "too_large", + message: `The unpushed work is ${Math.ceil(bundleBytes / 1024 / 1024)} MiB, over the ${MAX_HANDOFF_BUNDLE_BYTES / 1024 / 1024} MiB a handoff carries. Push the branch, then hand off again.`, + }); + } + 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 HandoffGitError({ + reason: "missing_base", + message: + "This checkout lacks the commits the handoff builds on, even after fetching. Push the branch where it came from, then hand off again.", + }); + } + } + 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 HandoffGitError({ + reason: "git_failed", + message: "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 HandoffGitError({ + reason: "checked_out_elsewhere", + message: `${branch} is checked out in another worktree here and differs. Switch that checkout off it, then hand off again.`, + }); + } + if (current !== input.tip) { + if ( + !(yield* ok(input.repoRoot, ["merge-base", "--is-ancestor", current, input.tip])) + ) { + return yield* new HandoffGitError({ + reason: "diverged", + message: `${branch} here has commits the handed-off work lacks. Reconcile the branches, then hand off again.`, + }); + } + // 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, ""]); + undo.push(git(input.repoRoot, ["update-ref", "-d", ref]).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);