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..0bcda54ca11d0 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -0,0 +1,428 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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"; + +const nativeLayer = VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer)); +const handoffService = HandoffGit.HandoffGit.pipe(Effect.provide(HandoffGit.layer)); +const branch = "fix/login"; +const branchRef = `refs/heads/${branch}`; + +const git = Effect.fnUntraced(function* (cwd: string, args: ReadonlyArray) { + const processes = yield* VcsProcess.VcsProcess; + return (yield* processes.run({ + operation: "HandoffGit.test", + command: "git", + cwd, + args, + env: { + GIT_DIR: undefined, + GIT_WORK_TREE: undefined, + GIT_INDEX_FILE: undefined, + GIT_OPTIONAL_LOCKS: "0", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + })).stdout; +}); + +const refs = (cwd: string) => git(cwd, ["for-each-ref", "--format=%(refname) %(objectname)"]); + +const checkoutState = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gitDir = (yield* git(cwd, ["rev-parse", "--path-format=absolute", "--git-dir"])).trim(); + return { + head: yield* git(cwd, ["rev-parse", "HEAD"]), + status: yield* git(cwd, ["status", "--porcelain"]), + staged: yield* git(cwd, ["diff", "--cached", "--binary"]), + unstaged: yield* git(cwd, ["diff", "--binary"]), + index: yield* fs.readFile(path.join(gitDir, "index")), + indexFiles: (yield* fs.readDirectory(gitDir)).filter((name) => name.includes("index")).sort(), + app: yield* fs.readFileString(path.join(cwd, "app.txt")), + newFile: yield* fs.readFileString(path.join(cwd, "new.txt")), + }; +}); + +const fixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-handoff-git-" }); + const origin = path.join(root, "origin.git"); + const backup = path.join(root, "backup.git"); + const source = path.join(root, "source"); + const receiver = path.join(root, "receiver"); + const outDir = path.join(root, "out"); + const worktreePath = path.join(root, "received"); + const write = (cwd: string, name: string, text: string) => + fs.writeFileString(path.join(cwd, name), text); + yield* git(root, ["init", "--quiet", "--bare", "--initial-branch=main", origin]); + yield* git(root, ["init", "--quiet", "--bare", "--initial-branch=main", backup]); + yield* git(root, ["clone", "--quiet", origin, source]); + yield* git(source, ["remote", "add", "backup", backup]); + yield* fs.makeDirectory(path.join(source, "packages")); + yield* write(source, "app.txt", "base\n"); + yield* write(source, "deleted.txt", "keep until deleted\n"); + yield* write(source, ".gitignore", "secret.txt\nforced.txt\n"); + yield* write(source, "packages/lib.txt", "library\n"); + yield* git(source, ["add", "."]); + yield* git(source, ["commit", "--quiet", "-m", "base"]); + yield* git(source, ["push", "--quiet", "origin", "main"]); + yield* git(root, ["clone", "--quiet", origin, receiver]); + yield* git(source, ["checkout", "--quiet", "-b", branch]); + yield* write(source, "app.txt", "committed\n"); + yield* git(source, ["commit", "--quiet", "-am", "unpushed work"]); + yield* git(source, ["push", "--quiet", "backup", branchRef]); + yield* git(source, ["tag", branch]); + yield* write(source, "app.txt", "staged\n"); + yield* git(source, ["add", "app.txt"]); + yield* write(source, "app.txt", "unstaged\n"); + yield* fs.remove(path.join(source, "deleted.txt")); + yield* write(source, "new.txt", "source new\n"); + yield* write(source, "secret.txt", "source secret\n"); + yield* write(source, "forced.txt", "source forced\n"); + yield* git(source, ["add", "--force", "forced.txt"]); + yield* write(receiver, "app.txt", "receiver staged\n"); + yield* git(receiver, ["add", "app.txt"]); + yield* write(receiver, "app.txt", "receiver unstaged\n"); + yield* write(receiver, "new.txt", "receiver new\n"); + yield* fs.makeDirectory(outDir); + return { fs, path, root, origin, source, receiver, outDir, worktreePath, write }; +}); + +it.layer(nativeLayer)("HandoffGit", (it) => { + it.effect( + "round trips the whole checkout without changing either existing index or worktree", + () => + Effect.gen(function* () { + const f = yield* fixture; + const native = yield* VcsProcess.VcsProcess; + // Model inherited hook variables without mutating the test runner's process.env. + const handoff = yield* handoffService.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + native.run({ + ...input, + env: { + GIT_DIR: f.path.join(f.receiver, ".git"), + GIT_WORK_TREE: f.receiver, + GIT_INDEX_FILE: f.path.join(f.receiver, ".git", "index"), + ...input.env, + }, + }), + }), + ); + const sourceBefore = yield* checkoutState(f.source); + const sourceRefs = yield* refs(f.source); + const receiverBefore = yield* checkoutState(f.receiver); + const receiverRefs = yield* refs(f.receiver); + const packed = yield* handoff.pack({ + cwd: f.path.join(f.source, "packages"), + handoffId: "roundtrip", + outDir: f.outDir, + }); + assert.equal(packed.branch, branch); + assert.equal(packed.tip, sourceBefore.head.trim()); + assert.equal(packed.baseRemoteUrl, f.origin); + assert.equal(packed.bundleBytes, Number((yield* f.fs.stat(packed.bundlePath)).size)); + assert.isAbove(packed.bundleBytes, 0); + assert.equal(yield* git(f.source, ["show", `${packed.tip}:app.txt`]), "committed\n"); + assert.equal(yield* git(f.source, ["show", `${packed.snapshot}:app.txt`]), "unstaged\n"); + assert.deepEqual(yield* checkoutState(f.source), sourceBefore); + assert.equal(yield* refs(f.source), sourceRefs); + assert.equal(yield* refs(f.receiver), receiverRefs); + assert.deepEqual(yield* checkoutState(f.receiver), receiverBefore); + const missingTip = yield* git(f.receiver, ["cat-file", "-e", packed.tip]).pipe(Effect.flip); + assert.equal(missingTip._tag, "VcsProcessExitError"); + assert.deepEqual(yield* f.fs.readDirectory(f.outDir), [f.path.basename(packed.bundlePath)]); + + yield* f.write( + f.receiver, + ".git/hooks/post-checkout", + "#!/bin/sh\nprintf 'receiver-only hook output\\n' > secret.txt\n", + ); + yield* f.fs.chmod(f.path.join(f.receiver, ".git/hooks/post-checkout"), 0o755); + const input = { + ...packed, + repoRoot: f.receiver, + worktreePath: f.worktreePath, + handoffId: "roundtrip", + }; + yield* Effect.scoped( + Effect.gen(function* () { + const lease = yield* handoff.apply(input); + assert.equal(lease.branch, branch); + yield* lease.commit; + }), + ); + assert.equal((yield* git(f.worktreePath, ["rev-parse", "HEAD"])).trim(), packed.tip); + assert.equal(yield* git(f.worktreePath, ["show", "HEAD:app.txt"]), "committed\n"); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "app.txt")), + "unstaged\n", + ); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "new.txt")), + "source new\n", + ); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "packages/lib.txt")), + "library\n", + ); + assert.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "deleted.txt"))); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "secret.txt")), + "receiver-only hook output\n", + ); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "forced.txt")), + "source forced\n", + ); + assert.equal(yield* git(f.worktreePath, ["diff", "--cached"]), ""); + + const acceptedBefore = yield* checkoutState(f.worktreePath); + const repacked = yield* handoff.pack({ + cwd: f.worktreePath, + handoffId: "roundtrip-again", + outDir: f.outDir, + }); + assert.deepEqual(yield* checkoutState(f.source), sourceBefore); + assert.deepEqual(yield* checkoutState(f.worktreePath), acceptedBefore); + assert.deepEqual(yield* checkoutState(f.receiver), receiverBefore); + assert.equal( + yield* git(f.worktreePath, ["ls-tree", repacked.snapshot, "--", "secret.txt"]), + "", + ); + const repackedForced = yield* native.run({ + operation: "HandoffGit.test", + command: "git", + cwd: f.worktreePath, + args: ["show", `${repacked.snapshot}:forced.txt`], + allowNonZeroExit: true, + }); + assert.equal(repackedForced.exitCode, 0, repackedForced.stderr); + assert.equal(repackedForced.stdout, "source forced\n"); + assert.match( + acceptedBefore.status, + /^ M app\.txt\n D deleted\.txt\n A forced\.txt\n(?: A|\?\?) new\.txt\n$/, + ); + const detachedPath = f.path.join(f.root, "detached"); + yield* Effect.scoped( + Effect.gen(function* () { + const lease = yield* handoff.apply({ ...input, worktreePath: detachedPath }); + assert.isNull(lease.branch); + assert.equal(yield* git(detachedPath, ["rev-parse", "--abbrev-ref", "HEAD"]), "HEAD\n"); + assert.equal((yield* git(detachedPath, ["rev-parse", "HEAD"])).trim(), packed.tip); + }), + ); + assert.isFalse(yield* f.fs.exists(detachedPath)); + assert.deepEqual(yield* checkoutState(f.worktreePath), acceptedBefore); + assert.deepEqual(yield* checkoutState(f.receiver), receiverBefore); + assert.equal((yield* git(f.receiver, ["rev-parse", branch])).trim(), packed.tip); + assert.equal(yield* git(f.receiver, ["for-each-ref", "refs/t3code"]), ""); + }).pipe(Effect.scoped), + ); + + it.effect.each(["diverged", "checked-out", "corrupt-bundle"] as const)( + "refuses a %s destination without overwriting work", + (mode) => + Effect.gen(function* () { + const f = yield* fixture; + const handoff = yield* handoffService; + const packed = yield* handoff.pack({ + cwd: f.source, + handoffId: "refuse", + outDir: f.outDir, + }); + if (mode === "corrupt-bundle") { + yield* f.fs.writeFileString(packed.bundlePath, "corrupted transfer\n"); + } + const base = (yield* git(f.receiver, ["rev-parse", "HEAD"])).trim(); + const destination = + mode === "diverged" + ? (yield* git(f.receiver, [ + "commit-tree", + "HEAD^{tree}", + "-p", + base, + "-m", + "receiver-only commit", + ])).trim() + : base; + yield* git(f.receiver, ["branch", branch, destination]); + const occupied = f.path.join(f.root, "occupied"); + if (mode === "checked-out") { + yield* git(f.receiver, ["worktree", "add", occupied, branch]); + yield* f.write(occupied, "app.txt", "occupied staged\n"); + yield* git(occupied, ["add", "app.txt"]); + yield* f.write(occupied, "app.txt", "occupied unstaged\n"); + yield* f.write(occupied, "new.txt", "occupied new\n"); + } + const occupiedBefore = mode === "checked-out" ? yield* checkoutState(occupied) : null; + const before = yield* checkoutState(f.receiver); + const refsBefore = yield* refs(f.receiver); + const worktreesBefore = yield* git(f.receiver, ["worktree", "list", "--porcelain"]); + const error = yield* handoff + .apply({ + ...packed, + repoRoot: f.receiver, + worktreePath: f.worktreePath, + handoffId: "refuse", + }) + .pipe(Effect.scoped, Effect.flip); + if (mode === "corrupt-bundle") { + assert.equal(error._tag, "HandoffGitStepError"); + assert.propertyVal(error, "step", "bundle-read"); + } else { + assert.equal( + error._tag, + mode === "diverged" ? "HandoffBranchDivergedError" : "HandoffBranchCheckedOutError", + ); + } + assert.deepEqual(yield* checkoutState(f.receiver), before); + assert.equal(yield* refs(f.receiver), refsBefore); + assert.equal(yield* git(f.receiver, ["worktree", "list", "--porcelain"]), worktreesBefore); + assert.isFalse(yield* f.fs.exists(f.worktreePath)); + if (occupiedBefore !== null) + assert.deepEqual(yield* checkoutState(occupied), occupiedBefore); + }).pipe(Effect.scoped), + ); + + it.effect.each(["new", "fast-forward"] as const)( + "rolls back a %s branch until receiver acceptance, preserving concurrent advances", + (mode) => + Effect.gen(function* () { + const f = yield* fixture; + const handoff = yield* handoffService; + const native = yield* VcsProcess.VcsProcess; + const packed = yield* handoff.pack({ + cwd: f.source, + handoffId: "rollback", + outDir: f.outDir, + }); + if (mode === "fast-forward") yield* git(f.receiver, ["branch", branch, "main"]); + const before = yield* checkoutState(f.receiver); + const refsBefore = yield* refs(f.receiver); + const worktreesBefore = yield* git(f.receiver, ["worktree", "list", "--porcelain"]); + const input = { + ...packed, + repoRoot: f.receiver, + worktreePath: f.worktreePath, + handoffId: "rollback", + }; + let indexAction: "fail" | "advance" | undefined; + let advanced: string | undefined; + const faulted = yield* handoffService.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fnUntraced( + function* (command: VcsProcess.VcsProcessInput) { + if ( + indexAction !== undefined && + command.cwd === f.worktreePath && + (command.args[0] === "reset" || command.args[0] === "read-tree") + ) { + const action = indexAction; + indexAction = undefined; + assert.equal((yield* git(f.receiver, ["rev-parse", branch])).trim(), packed.tip); + assert.equal( + yield* f.fs + .readFileString(f.path.join(f.worktreePath, "app.txt")) + .pipe(Effect.orDie), + "unstaged\n", + ); + if (action === "advance") { + advanced = (yield* git(f.receiver, [ + "commit-tree", + `${packed.tip}^{tree}`, + "-p", + packed.tip, + "-m", + "concurrent receiver commit", + ])).trim(); + yield* git(f.receiver, ["update-ref", branchRef, advanced, packed.tip]); + return yield* native.run(command); + } + const index = (yield* git(f.worktreePath, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ])).trim(); + // A real Git failure after branch creation/advance and snapshot checkout. + yield* f.fs.writeFileString(`${index}.lock`, "busy\n").pipe(Effect.orDie); + return yield* native + .run(command) + .pipe(Effect.ensuring(f.fs.remove(`${index}.lock`).pipe(Effect.orDie))); + } + return yield* native.run(command); + }, + Effect.provideService(VcsProcess.VcsProcess, native), + ), + }), + ); + for (const phase of ["late-git", "persistence", "concurrent-persistence"] as const) { + advanced = undefined; + const assertRollback = Effect.gen(function* () { + assert.deepEqual(yield* checkoutState(f.receiver), before); + assert.equal( + yield* git(f.receiver, ["worktree", "list", "--porcelain"]), + worktreesBefore, + ); + assert.isFalse(yield* f.fs.exists(f.worktreePath)); + const expectedRefs = + advanced === undefined + ? refsBefore + : [ + ...refsBefore + .trim() + .split("\n") + .filter((line) => !line.startsWith(`${branchRef} `)), + `${branchRef} ${advanced}`, + ] + .sort() + .join("\n") + "\n"; + assert.equal(yield* refs(f.receiver), expectedRefs); + }); + if (phase === "late-git") { + indexAction = "fail"; + yield* Effect.scoped( + Effect.gen(function* () { + const error = yield* faulted.apply(input).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + assert.isUndefined(indexAction); + // Failure during apply must release ownership even while its caller scope is open. + yield* assertRollback; + }), + ); + } else { + indexAction = phase === "concurrent-persistence" ? "advance" : undefined; + const error = yield* Effect.scoped( + Effect.gen(function* () { + const lease = yield* faulted.apply(input); + assert.isUndefined(indexAction); + assert.equal(lease.branch, branch); + assert.equal( + (yield* git(f.receiver, ["rev-parse", branchRef])).trim(), + advanced ?? packed.tip, + ); + assert.equal( + (yield* git(f.worktreePath, ["rev-parse", "HEAD"])).trim(), + advanced ?? packed.tip, + ); + return yield* Effect.fail({ _tag: "ReceiverPersistenceFailure" as const }); + }), + ).pipe(Effect.flip); + assert.equal(error._tag, "ReceiverPersistenceFailure"); + yield* assertRollback; + } + } + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts new file mode 100644 index 0000000000000..1320e6d3b937a --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -0,0 +1,436 @@ +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 type * as Scope from "effect/Scope"; + +import * as VcsProcess from "../../vcs/VcsProcess.ts"; + +const MAX_HANDOFF_BUNDLE_BYTES = 50 * 1024 * 1024; + +export interface HandoffPackage { + readonly bundlePath: string; + readonly bundleBytes: number; + readonly branch: string | null; + readonly tip: string; + readonly snapshot: string; + readonly baseRemoteUrl: string | null; +} + +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.`; + } +} + +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 50 MiB a handoff carries. Push the branch, then hand off again.`; + } +} + +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."; + } +} + +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.`; + } +} + +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.`; + } +} + +export class HandoffGitStepError extends Schema.TaggedError()( + "HandoffGitStepError", + { step: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return `The handoff Git step ${this.step} failed.`; + } +} + +export type HandoffGitError = + | HandoffNotARepositoryError + | HandoffTooLargeError + | HandoffMissingBaseError + | HandoffBranchCheckedOutError + | HandoffBranchDivergedError + | HandoffGitStepError; + +export class HandoffGit extends Context.Service< + HandoffGit, + { + readonly pack: (input: { + readonly cwd: string; + readonly handoffId: string; + readonly outDir: string; + }) => Effect.Effect; + 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; readonly commit: Effect.Effect }, + HandoffGitError, + Scope.Scope + >; + } +>()("t3/peer/handoff/HandoffGit") {} + +const repositoryEnv = { + 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 nonInteractiveFetchEnv = Object.freeze({ + GCM_INTERACTIVE: "never", + GIT_ASKPASS: "", + GIT_TERMINAL_PROMPT: "0", + SSH_ASKPASS: "", + SSH_ASKPASS_REQUIRE: "never", +} satisfies NodeJS.ProcessEnv); + +const snapshotRef = (handoffId: string) => `refs/t3code/handoff/${handoffId}/snapshot`; + +const make = Effect.gen(function* () { + const processes = yield* VcsProcess.VcsProcess; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const git = ( + cwd: string, + args: ReadonlyArray, + options: { + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; + } = {}, + ) => + processes + .run({ + operation: "HandoffGit", + command: "git", + cwd, + args, + timeoutMs: 120_000, + ...options, + env: { ...repositoryEnv, ...options.env }, + }) + .pipe(Effect.mapError((cause) => new HandoffGitStepError({ step: args[0] ?? "git", 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"] = Effect.fn("HandoffGit.pack")(function* (input) { + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* ok(input.cwd, ["rev-parse", "--verify", "HEAD"]))) { + return yield* new HandoffNotARepositoryError({ cwd: input.cwd }); + } + const cwd = yield* out(input.cwd, ["rev-parse", "--show-toplevel"]); + const tip = yield* out(cwd, ["rev-parse", "HEAD"]); + const head = yield* git(cwd, ["symbolic-ref", "--quiet", "HEAD"], { + allowNonZeroExit: true, + }); + const headRef = head.stdout.trim(); + const branch = + head.exitCode === 0 && headRef.startsWith("refs/heads/") + ? headRef.slice("refs/heads/".length) + : null; + const directory = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-handoff-index-" }) + .pipe( + Effect.mapError((cause) => new HandoffGitStepError({ step: "private-index", cause })), + ); + const env = { + GIT_INDEX_FILE: path.join(directory, "index"), + 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 indexGit = (args: ReadonlyArray) => + git( + cwd, + [ + "-c", + "core.splitIndex=false", + "-c", + "splitIndex.sharedIndexExpire=never", + "-c", + "core.fsmonitor=false", + ...args, + ], + { env }, + ); + const sourceIndex = yield* out(cwd, [ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ]); + const reusedIndex = yield* Effect.gen(function* () { + if (!(yield* fs.exists(sourceIndex))) return false; + // Retain force-added ignored paths without writing the source or its shared indexes. + yield* fs.copyFile(sourceIndex, env.GIT_INDEX_FILE); + // A nonzero past timestamp makes Git recheck same-size edits in the source index's second. + yield* fs.utimes(env.GIT_INDEX_FILE, 1, 1); + return true; + }).pipe( + Effect.mapError((cause) => new HandoffGitStepError({ step: "private-index", cause })), + ); + if (!reusedIndex) yield* indexGit(["read-tree", tip]); + yield* indexGit(["add", "--all", "--", ":/"]); + const tree = (yield* indexGit(["write-tree"])).stdout.trim(); + const snapshot = (yield* git( + cwd, + ["commit-tree", tree, "-p", tip, "-m", `T3 Code handoff ${input.handoffId}`], + { env }, + )).stdout.trim(); + const ref = snapshotRef(input.handoffId); + yield* Effect.acquireRelease(git(cwd, ["update-ref", ref, snapshot, ""]), () => + git(cwd, ["update-ref", "-d", ref, snapshot]).pipe(Effect.ignore), + ); + const bundlePath = path.resolve(input.outDir, `${input.handoffId}.bundle`); + yield* git(cwd, ["bundle", "create", bundlePath, ref, "--not", "--remotes=origin"]); + const bundleBytes = Number( + (yield* fs + .stat(bundlePath) + .pipe( + Effect.mapError((cause) => new HandoffGitStepError({ step: "bundle-stat", cause })), + )).size, + ); + if (bundleBytes > MAX_HANDOFF_BUNDLE_BYTES) { + yield* fs + .remove(bundlePath, { force: true }) + .pipe( + Effect.mapError((cause) => new HandoffGitStepError({ step: "bundle-remove", cause })), + ); + return yield* new HandoffTooLargeError({ bundleBytes }); + } + const remote = yield* git(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"] = Effect.fn("HandoffGit.apply")(function* (input) { + let committed = false; + let released = false; + let ownsWorktree = false; + let restoreBranch: Effect.Effect = Effect.void; + const rollback = Effect.gen(function* () { + if (committed || released) return; + released = true; + if (ownsWorktree) { + yield* git(input.repoRoot, ["worktree", "remove", "--force", input.worktreePath]).pipe( + Effect.ignore, + ); + yield* fs.remove(input.worktreePath, { recursive: true, force: true }).pipe(Effect.ignore); + } + yield* restoreBranch; + }); + yield* Effect.addFinalizer(() => rollback); + + return yield* Effect.scoped( + Effect.gen(function* () { + if (!(yield* ok(input.repoRoot, ["bundle", "list-heads", input.bundlePath]))) { + return yield* new HandoffGitStepError({ step: "bundle-read" }); + } + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + yield* git(input.repoRoot, ["fetch", "--quiet", "origin"], { + allowNonZeroExit: true, + env: nonInteractiveFetchEnv, + }); + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + return yield* new HandoffMissingBaseError(); + } + } + const directory = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-handoff-fetch-" }) + .pipe( + Effect.mapError((cause) => new HandoffGitStepError({ step: "fetch-directory", cause })), + ); + const fetched = `refs/t3code/handoff/${input.handoffId}/${path.basename(directory)}`; + yield* Effect.addFinalizer(() => + git(input.repoRoot, ["update-ref", "-d", fetched]).pipe(Effect.ignore), + ); + yield* git(input.repoRoot, [ + "fetch", + "--quiet", + "--no-tags", + "--no-write-fetch-head", + input.bundlePath, + `${snapshotRef(input.handoffId)}:${fetched}`, + ]); + if ((yield* out(input.repoRoot, ["rev-parse", fetched])) !== input.snapshot) { + return yield* new HandoffGitStepError({ step: "snapshot-verify" }); + } + if ((yield* out(input.repoRoot, ["rev-parse", `${fetched}^`])) !== input.tip) { + return yield* new HandoffGitStepError({ step: "snapshot-parent-verify" }); + } + let branch = input.branch; + if (branch !== null) { + const ref = `refs/heads/${branch}`; + const existing = yield* git(input.repoRoot, ["show-ref", "--verify", "--hash", ref], { + allowNonZeroExit: true, + }); + const current = existing.exitCode === 0 ? existing.stdout.trim() : ""; + const checkedOut = (yield* out(input.repoRoot, ["worktree", "list", "--porcelain"])) + .split("\n") + .includes(`branch ${ref}`); + if (checkedOut && current !== input.tip) { + return yield* new HandoffBranchCheckedOutError({ branch }); + } + if (current !== input.tip) { + if ( + current !== "" && + !(yield* ok(input.repoRoot, ["merge-base", "--is-ancestor", current, input.tip])) + ) { + return yield* new HandoffBranchDivergedError({ branch }); + } + // Keep the successful CAS and its ownership record inseparable under interruption. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* git(input.repoRoot, ["update-ref", ref, input.tip, current]); + restoreBranch = git( + input.repoRoot, + current === "" + ? ["update-ref", "-d", ref, input.tip] + : ["update-ref", ref, current, input.tip], + ).pipe(Effect.asVoid, Effect.ignore); + }), + ); + } + if (checkedOut) branch = null; + } + // Reserve the directory before cancellable setup so a failed add cannot leave owned files. + yield* Effect.uninterruptible( + Effect.gen(function* () { + yield* fs + .makeDirectory(input.worktreePath) + .pipe( + Effect.mapError( + (cause) => new HandoffGitStepError({ step: "worktree-directory", cause }), + ), + ); + ownsWorktree = true; + }), + ); + yield* git( + input.repoRoot, + branch === null + ? ["worktree", "add", "--detach", input.worktreePath, input.tip] + : ["worktree", "add", input.worktreePath, branch], + ); + // No-overlay checkout carries deletions; read-tree unstages edits without moving the branch. + yield* git(input.worktreePath, ["checkout", "--no-overlay", input.snapshot, "--", "."]); + yield* git(input.worktreePath, ["read-tree", "-m", input.tip]); + // Only snapshot additions qualify; checkout hooks can also produce ignored local files. + // Prefix paths so check-ignore does not interpret literal names as pathspec magic. + const added = yield* git( + input.worktreePath, + [ + "diff", + "-z", + "--name-only", + "--line-prefix=./", + "--no-renames", + "--diff-filter=A", + input.tip, + input.snapshot, + ], + { maxOutputBytes: 16 * 1024 * 1024, outputMode: "error" }, + ); + if (added.stdout !== "") { + const ignored = yield* git( + input.worktreePath, + ["check-ignore", "-z", "--stdin", "--no-index"], + { + stdin: added.stdout, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + outputMode: "error", + }, + ); + if (ignored.exitCode !== 0 && ignored.exitCode !== 1) { + return yield* new HandoffGitStepError({ step: "check-ignore", cause: ignored }); + } + if (ignored.stdout !== "") { + yield* git( + input.worktreePath, + [ + "--literal-pathspecs", + "add", + "--intent-to-add", + "--force", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + { stdin: ignored.stdout }, + ); + // An intent entry can displace a tracked ancestor in a file-to-directory replacement. + yield* git(input.worktreePath, ["diff", "--cached", "--quiet", input.tip]); + } + } + return { + branch, + commit: Effect.sync(() => { + committed = true; + }), + }; + }), + ).pipe(Effect.onError(() => rollback)); + }); + + return HandoffGit.of({ pack, apply }); +}); + +export const layer = Layer.effect(HandoffGit, make);