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..83cba0307cd35 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -0,0 +1,489 @@ +// @effect-diagnostics nodeBuiltinImport:off - FileSystem accepts only string paths; this fixture needs invalid UTF-8 bytes. +import * as NodeFSP from "node:fs/promises"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; + +import * as VcsProcess from "../../vcs/VcsProcess.ts"; +import * as HandoffGit from "./HandoffGit.ts"; + +const layer = HandoffGit.layer.pipe( + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); +const branch = "fix/login"; +const handoffId = "transfer"; + +const git = Effect.fnUntraced(function* (cwd: string, args: ReadonlyArray) { + const processes = yield* VcsProcess.VcsProcess; + const result = yield* 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", + }, + }); + return result.stdout.trimEnd(); +}); + +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 sender = path.join(root, "sender"); + const receiver = path.join(root, "receiver"); + const outDir = path.join(root, "out"); + yield* git(root, ["init", "--bare", "--initial-branch=main", origin]); + yield* git(root, ["clone", origin, sender]); + yield* fs.writeFileString(path.join(sender, "app.ts"), "initial\n"); + yield* fs.writeFileString(path.join(sender, "old.ts"), "delete me\n"); + yield* fs.writeFileString(path.join(sender, "settings.txt"), "public configuration\n"); + yield* fs.writeFileString(path.join(sender, "local.cfg"), "shared configuration\n"); + yield* fs.writeFileString(path.join(sender, ".gitignore"), ".env\n*.local\n"); + yield* git(sender, ["add", "."]); + yield* git(sender, ["commit", "-m", "base"]); + yield* git(sender, ["push", "origin", "main"]); + yield* git(root, ["clone", origin, receiver]); + yield* fs.makeDirectory(outDir); + return { fs, path, origin, sender, receiver, outDir, worktree: path.join(root, "received") }; +}); + +const editSender = Effect.fnUntraced(function* (sender: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* git(sender, ["checkout", "-b", branch]); + yield* fs.writeFileString(path.join(sender, "app.ts"), "unpushed\n"); + yield* git(sender, ["commit", "-am", "unpushed"]); + yield* fs.writeFileString(path.join(sender, "app.ts"), "staged\n"); + yield* git(sender, ["add", "app.ts"]); + yield* fs.writeFileString(path.join(sender, "app.ts"), "unstaged\n"); + yield* fs.remove(path.join(sender, "old.ts")); + yield* fs.makeDirectory(path.join(sender, "sub")); + yield* fs.writeFileString(path.join(sender, "sub", "new.ts"), "untracked\n"); + yield* fs.writeFileString(path.join(sender, ".env"), "SECRET=private\n"); + yield* fs.writeFileString(path.join(sender, "conf.local"), "force-staged work\n"); + yield* git(sender, ["add", "-f", "conf.local"]); + yield* git(sender, ["rm", "--cached", "settings.txt"]); + yield* fs.writeFileString(path.join(sender, ".gitignore"), ".env\n*.local\nsettings.txt\n"); + yield* fs.writeFileString(path.join(sender, "settings.txt"), "private configuration\n"); +}); + +const repositoryState = Effect.fnUntraced(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const status = yield* git(cwd, ["status", "--porcelain"]); + const gitDir = yield* git(cwd, ["rev-parse", "--absolute-git-dir"]); + return { + status, + head: yield* git(cwd, ["rev-parse", "HEAD"]), + refs: yield* git(cwd, ["show-ref"]), + index: yield* fs.readFile(path.join(gitDir, "index")), + indexFiles: (yield* fs.readDirectory(gitDir)).filter((name) => name.includes("index")).sort(), + }; +}); + +const applyInput = (repo: Effect.Success, packed: HandoffGit.HandoffPackage) => ({ + ...packed, + handoffId, + repoRoot: repo.receiver, + worktreePath: repo.worktree, +}); + +// Real Git performs every mutation; the gate exposes a deterministic cancellation boundary. +const pauseGit = Effect.fnUntraced(function* ( + matches: (input: VcsProcess.VcsProcessInput) => boolean, + timing: "before" | "after" = "before", +) { + const processes = yield* VcsProcess.VcsProcess; + const reached = yield* Deferred.make(); + const handoff = yield* HandoffGit.HandoffGit.pipe( + Effect.provide(Layer.fresh(HandoffGit.layer)), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + const run = processes.run(input); + if (!matches(input)) return run; + const pause = Deferred.succeed(reached, undefined).pipe(Effect.andThen(Effect.never)); + return timing === "before" + ? pause.pipe(Effect.andThen(run)) + : run.pipe(Effect.tap(() => pause)); + }, + }), + ); + return { handoff, reached }; +}); + +it.layer(layer)("Git bundle pack/apply", (it) => { + it.effect.each(["branch", "detached subdirectory", "sparse checkout", "sparse receiver"])( + "round-trips %s without changing either existing checkout", + (mode) => + Effect.gen(function* () { + const repo = yield* fixture; + const { path, fs, sender, receiver, outDir, worktree } = repo; + const handoff = yield* HandoffGit.HandoffGit; + if (mode === "sparse checkout" || mode === "sparse receiver") { + yield* fs.makeDirectory(path.join(sender, "excluded")); + yield* fs.writeFileString(path.join(sender, "excluded", "file"), "outside cone\n"); + yield* fs.writeFileString(path.join(sender, "excluded", "gone"), "delete outside cone\n"); + yield* git(sender, ["add", "."]); + yield* git(sender, ["commit", "-m", "outside cone"]); + } + yield* editSender(sender); + if (mode === "sparse receiver") yield* fs.remove(path.join(sender, "excluded", "gone")); + yield* git(sender, ["tag", branch]); + if (mode === "detached subdirectory") yield* git(sender, ["checkout", "--detach"]); + if (mode === "sparse checkout") { + yield* git(sender, ["sparse-checkout", "set", "sub"]); + yield* fs.makeDirectory(path.join(sender, "excluded"), { recursive: true }); + yield* fs.writeFileString(path.join(sender, "excluded", "new.txt"), "outside work\n"); + } + if (mode !== "sparse checkout") { + yield* git(sender, ["update-index", "--skip-worktree", "local.cfg"]); + yield* fs.writeFileString( + path.join(sender, "local.cfg"), + "hidden private configuration\n", + ); + } + if (mode === "branch") { + yield* git(sender, ["config", "core.splitIndex", "true"]); + yield* git(sender, ["config", "splitIndex.sharedIndexExpire", "now"]); + yield* git(sender, ["update-index", "--split-index"]); + } + yield* fs.writeFileString(path.join(receiver, "app.ts"), "receiver work\n"); + if (mode === "sparse checkout" || mode === "sparse receiver") + yield* git(receiver, ["sparse-checkout", "set", "sub"]); + const senderBefore = yield* repositoryState(sender); + const receiverBefore = yield* repositoryState(receiver); + // A server launched by a Git hook can inherit another checkout's GIT_DIR. + const packed = yield* Effect.scoped( + Effect.gen(function* () { + const gitDir = yield* git(receiver, ["rev-parse", "--absolute-git-dir"]); + yield* Effect.acquireRelease( + Effect.sync(() => { + const before = process.env.GIT_DIR; + process.env.GIT_DIR = gitDir; + return before; + }), + (before) => + Effect.sync(() => { + if (before === undefined) delete process.env.GIT_DIR; + else process.env.GIT_DIR = before; + }), + ); + return yield* handoff.pack({ + cwd: mode === "detached subdirectory" ? path.join(sender, "sub") : sender, + handoffId, + outDir, + }); + }), + ); + assert.deepEqual(yield* repositoryState(sender), senderBefore); + assert.equal(packed.branch, mode === "detached subdirectory" ? null : branch); + yield* handoff.apply(applyInput(repo, packed)); + assert.equal(yield* git(worktree, ["rev-parse", "HEAD"]), senderBefore.head); + assert.equal(yield* git(worktree, ["show", "HEAD:app.ts"]), "unpushed"); + assert.equal(yield* fs.readFileString(path.join(worktree, "app.ts")), "unstaged\n"); + assert.equal(yield* fs.readFileString(path.join(worktree, "sub", "new.ts")), "untracked\n"); + assert.isFalse(yield* fs.exists(path.join(worktree, "old.ts"))); + assert.isFalse(yield* fs.exists(path.join(worktree, ".env"))); + assert.equal( + yield* fs.readFileString(path.join(worktree, "local.cfg")), + "shared configuration\n", + ); + assert.isFalse(yield* fs.exists(path.join(worktree, "settings.txt"))); + if (mode === "sparse checkout" || mode === "sparse receiver") { + assert.equal( + yield* git(worktree, ["show", `${packed.snapshot}:excluded/file`]), + "outside cone", + ); + } + if (mode === "sparse checkout") + assert.equal( + yield* fs.readFileString(path.join(worktree, "excluded", "new.txt")), + "outside work\n", + ); + assert.equal( + yield* fs.readFileString(path.join(worktree, "conf.local")), + "force-staged work\n", + ); + assert.equal( + yield* git(worktree, ["status", "--porcelain"]), + mode === "sparse receiver" + ? " M .gitignore\n M app.ts\n D excluded/gone\n D old.ts\n D settings.txt\n?? sub/" + : mode === "sparse checkout" + ? " M .gitignore\n M app.ts\n D old.ts\n D settings.txt\n?? excluded/new.txt\n?? sub/" + : " M .gitignore\n M app.ts\n D old.ts\n D settings.txt\n?? sub/", + ); + const receiverAfter = yield* repositoryState(receiver); + assert.deepEqual({ ...receiverAfter, refs: receiverBefore.refs }, receiverBefore); + assert.equal(yield* fs.readFileString(path.join(receiver, "app.ts")), "receiver work\n"); + assert.equal(yield* git(receiver, ["for-each-ref", "refs/t3code"]), ""); + }).pipe(Effect.scoped), + ); + + it.effect.each(["unused behind", "diverged", "checked out behind", "checked out at tip"])( + "handles a receiver branch that is %s", + (mode) => + Effect.gen(function* () { + const repo = yield* fixture; + const { path, sender, receiver, outDir, worktree, fs } = repo; + const handoff = yield* HandoffGit.HandoffGit; + yield* editSender(sender); + const packed = yield* handoff.pack({ cwd: sender, handoffId, outDir }); + if (mode === "checked out at tip") + yield* git(receiver, ["fetch", sender, `${branch}:${branch}`]); + else yield* git(receiver, ["branch", branch]); + if (mode !== "unused behind") yield* git(receiver, ["checkout", branch]); + if (mode === "diverged") { + yield* git(receiver, ["commit", "--allow-empty", "-m", "receiver only"]); + yield* git(receiver, ["checkout", "main"]); + } + const before = yield* repositoryState(receiver); + const worktrees = yield* git(receiver, ["worktree", "list", "--porcelain"]); + const apply = handoff.apply(applyInput(repo, packed)); + if (mode === "diverged" || mode === "checked out behind") { + const error = yield* apply.pipe(Effect.flip); + assert.equal( + error._tag, + mode === "diverged" ? "HandoffBranchDivergedError" : "HandoffBranchCheckedOutError", + ); + assert.deepEqual(yield* repositoryState(receiver), before); + assert.equal(yield* git(receiver, ["worktree", "list", "--porcelain"]), worktrees); + assert.isFalse(yield* fs.exists(worktree)); + } else { + assert.equal((yield* apply).branch, mode === "unused behind" ? branch : null); + assert.equal(yield* git(receiver, ["rev-parse", branch]), packed.tip); + assert.equal(yield* git(worktree, ["rev-parse", "HEAD"]), packed.tip); + assert.equal(yield* fs.readFileString(path.join(worktree, "app.ts")), "unstaged\n"); + } + assert.equal(yield* git(receiver, ["for-each-ref", "refs/t3code"]), ""); + }).pipe(Effect.scoped), + ); + + it.effect.each([ + "new branch", + "fast-forward", + "concurrent branch move", + "successful concurrent branch move", + ])("rolls back an invalid checkout: %s", (mode) => + Effect.gen(function* () { + const repo = yield* fixture; + const { path, fs, sender, receiver, outDir, worktree } = repo; + const handoff = yield* HandoffGit.HandoffGit; + yield* editSender(sender); + const packed = yield* handoff.pack({ cwd: sender, handoffId, outDir }); + if (mode === "fast-forward") yield* git(receiver, ["branch", branch]); + const before = yield* git(receiver, ["show-ref"]); + const worktrees = yield* git(receiver, ["worktree", "list", "--porcelain"]); + if (mode === "fast-forward") { + yield* fs.makeDirectory(worktree); + yield* fs.writeFileString(path.join(worktree, "keep.txt"), "existing work\n"); + const error = yield* handoff.apply(applyInput(repo, packed)).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + assert.equal(yield* git(receiver, ["show-ref"]), before); + assert.equal(yield* git(receiver, ["worktree", "list", "--porcelain"]), worktrees); + assert.equal(yield* fs.readFileString(path.join(worktree, "keep.txt")), "existing work\n"); + yield* fs.remove(worktree, { recursive: true }); + } + const moved = yield* git(receiver, ["rev-parse", "main"]); + const hook = path.join(receiver, ".git", "hooks", "post-checkout"); + yield* fs.writeFileString( + hook, + `#!/bin/sh\n${mode.includes("concurrent branch move") ? `git update-ref refs/heads/${branch} ${moved}\n` : ""}exit ${mode === "successful concurrent branch move" ? 0 : 1}\n`, + ); + yield* fs.chmod(hook, 0o755); + const error = yield* handoff.apply(applyInput(repo, packed)).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + if (mode.includes("concurrent branch move")) + assert.equal(yield* git(receiver, ["rev-parse", branch]), moved); + else assert.equal(yield* git(receiver, ["show-ref"]), before); + assert.equal(yield* git(receiver, ["for-each-ref", "refs/t3code"]), ""); + assert.equal(yield* git(receiver, ["worktree", "list", "--porcelain"]), worktrees); + assert.isFalse(yield* fs.exists(worktree)); + }).pipe(Effect.scoped), + ); + + it.effect.each(["new branch", "fast-forward"])("rolls back a cancelled apply: %s", (mode) => + Effect.gen(function* () { + const repo = yield* fixture; + const { path, sender, receiver, outDir, worktree, fs } = repo; + yield* editSender(sender); + if (mode === "fast-forward") yield* git(receiver, ["branch", branch]); + const before = yield* repositoryState(receiver); + const worktrees = yield* git(receiver, ["worktree", "list", "--porcelain"]); + const { handoff, reached } = yield* pauseGit( + (input) => input.cwd === worktree && input.args[0] === "checkout", + ); + const packed = yield* handoff.pack({ cwd: sender, handoffId, outDir }); + const fiber = yield* handoff.apply(applyInput(repo, packed)).pipe(Effect.forkScoped); + yield* Deferred.await(reached); + assert.isTrue(yield* fs.exists(path.join(worktree, ".git"))); + yield* Fiber.interrupt(fiber); + assert.deepEqual( + { + repository: yield* repositoryState(receiver), + worktrees: yield* git(receiver, ["worktree", "list", "--porcelain"]), + worktreeExists: yield* fs.exists(worktree), + }, + { repository: before, worktrees, worktreeExists: false }, + ); + }).pipe(Effect.scoped), + ); + + it.effect.each(["existing origin", "no origin", "fork remote"])( + "resolves a missing bundle base with %s", + (mode) => + Effect.gen(function* () { + const repo = yield* fixture; + const { path, sender, receiver, origin, outDir, worktree, fs } = repo; + const handoff = yield* HandoffGit.HandoffGit; + yield* fs.writeFileString(path.join(sender, "later.ts"), "pushed later\n"); + yield* git(sender, ["add", "."]); + yield* git(sender, ["commit", "-m", "later base"]); + if (mode === "fork remote") { + const fork = path.join(path.dirname(sender), "fork.git"); + yield* git(sender, ["clone", "--bare", origin, fork]); + yield* git(sender, ["remote", "add", "fork", fork]); + } + yield* git(sender, ["push", mode === "fork remote" ? "fork" : "origin", "main"]); + yield* editSender(sender); + const packed = yield* handoff.pack({ cwd: sender, handoffId, outDir }); + const processes = yield* VcsProcess.VcsProcess; + assert.equal( + (yield* processes.run({ + operation: "test.verify", + command: "git", + cwd: receiver, + args: ["bundle", "verify", packed.bundlePath], + allowNonZeroExit: true, + })).exitCode === 0, + mode === "fork remote", + ); + if (mode === "no origin") { + yield* git(receiver, ["remote", "remove", "origin"]); + yield* git(receiver, ["remote", "add", "other", origin]); + const before = yield* repositoryState(receiver); + const error = yield* handoff.apply(applyInput(repo, packed)).pipe(Effect.flip); + assert.equal(error._tag, "HandoffMissingBaseError"); + assert.deepEqual(yield* repositoryState(receiver), before); + assert.isFalse(yield* fs.exists(worktree)); + } else { + yield* handoff.apply(applyInput(repo, packed)); + assert.equal(yield* fs.readFileString(path.join(worktree, "later.ts")), "pushed later\n"); + } + }).pipe(Effect.scoped), + ); + + it.effect.each(["submodule", "nested repository"])("refuses omitted work in a %s", (kind) => + Effect.gen(function* () { + const { path, fs, sender, origin, outDir } = yield* fixture; + const handoff = yield* HandoffGit.HandoffGit; + const nested = path.join(sender, "nested"); + if (kind === "submodule") { + yield* git(sender, [ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + origin, + "nested", + ]); + yield* git(sender, ["commit", "-am", "submodule"]); + } else { + yield* git(sender, ["clone", origin, nested]); + } + yield* fs.writeFileString(path.join(nested, "app.ts"), "uncommitted nested work\n"); + const cwd = path.join(sender, "subdirectory"); + yield* fs.makeDirectory(cwd); + const before = yield* repositoryState(sender); + const error = yield* handoff.pack({ cwd, handoffId, outDir }).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + assert.include(error.message, "does not carry submodules or nested repositories"); + assert.deepEqual(yield* repositoryState(sender), before); + assert.deepEqual(yield* fs.readDirectory(outDir), []); + assert.equal( + yield* fs.readFileString(path.join(nested, "app.ts")), + "uncommitted nested work\n", + ); + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")( + "refuses invalid UTF-8 force-staged names", + () => + Effect.gen(function* () { + const { sender, outDir, fs } = yield* fixture; + const handoff = yield* HandoffGit.HandoffGit; + const name = Buffer.concat([ + Buffer.from(`${sender}/`), + Buffer.from([0xff]), + Buffer.from(".local"), + ]); + yield* Effect.tryPromise(() => NodeFSP.writeFile(name, "staged work\n")); + yield* git(sender, ["add", "-f", "--", ":/"]); + const before = yield* repositoryState(sender); + const error = yield* handoff.pack({ cwd: sender, handoffId, outDir }).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + assert.include(error.message, "UTF-8"); + assert.deepEqual(yield* repositoryState(sender), before); + assert.deepEqual(yield* fs.readDirectory(outDir), []); + assert.equal( + yield* Effect.tryPromise(() => NodeFSP.readFile(name, "utf8")), + "staged work\n", + ); + }).pipe(Effect.scoped), + ); + + it.effect("rejects an incompressible bundle over 50 MiB and removes its artifacts", () => + Effect.gen(function* () { + const { path, fs, sender, outDir } = yield* fixture; + const handoff = yield* HandoffGit.HandoffGit; + const crypto = yield* Crypto.Crypto; + yield* fs.writeFile( + path.join(sender, "large.bin"), + yield* crypto.randomBytes(50 * 1024 * 1024 + 1024), + ); + const before = yield* repositoryState(sender); + const error = yield* handoff.pack({ cwd: sender, handoffId, outDir }).pipe(Effect.flip); + assert.equal(error._tag, "HandoffTooLargeError"); + assert.deepEqual(yield* fs.readDirectory(outDir), []); + assert.deepEqual(yield* repositoryState(sender), before); + }).pipe(Effect.scoped), + ); + + it.effect.each(["private index", "written bundle"])("cleans a cancelled pack at %s", (stage) => + Effect.gen(function* () { + const { path, fs, sender, outDir } = yield* fixture; + yield* editSender(sender); + const before = yield* repositoryState(sender); + const { handoff, reached } = yield* pauseGit( + (input) => + stage === "private index" + ? input.args[0] === "write-tree" + : input.args[0] === "bundle" && input.args[1] === "create", + stage === "private index" ? "before" : "after", + ); + const fiber = yield* handoff.pack({ cwd: sender, handoffId, outDir }).pipe(Effect.forkScoped); + yield* Deferred.await(reached); + if (stage === "written bundle") + assert.isTrue(yield* fs.exists(path.join(outDir, `${handoffId}.bundle`))); + yield* Fiber.interrupt(fiber); + assert.deepEqual(yield* repositoryState(sender), before); + assert.deepEqual(yield* fs.readDirectory(outDir), []); + }).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..49bb11086bcd9 --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -0,0 +1,455 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +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 HostProcess from "@t3tools/shared/HostProcess"; + +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 Git package is ${Math.ceil(this.bundleBytes / 1024 / 1024)} MiB, over the ${MAX_HANDOFF_BUNDLE_BYTES / 1024 / 1024} MiB a handoff carries. Commit and push large files, or reduce uncommitted changes, then try 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, + reason: Schema.optional( + Schema.Literals([ + "bundle-not-written", + "snapshot-mismatch", + "gitlinks", + "index-too-large", + "invalid-path", + ]), + ), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "bundle-not-written": + return "The bundle was not written."; + case "snapshot-mismatch": + return "The bundle does not hold the snapshot it was sent with."; + case "gitlinks": + return "Git handoff does not carry submodules or nested repositories. Transfer this checkout manually."; + case "index-too-large": + return "This checkout has too many indexed files for Git handoff. Transfer it manually."; + case "invalid-path": + return "Git handoff requires UTF-8 names for indexed files. Rename them, then try again."; + default: + return `git ${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 }, 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 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 platform = yield* HostProcess.Platform; + + const git = ( + cwd: string, + args: ReadonlyArray, + options: { + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly maxOutputBytes?: number; + readonly stdin?: string; + } = {}, + ) => + processes + .run({ + operation: "HandoffGit", + command: "git", + cwd, + args, + timeoutMs: 120_000, + env: { ...process.env, ...REPOSITORY_ENV, ...options.env }, + ...(options.allowNonZeroExit === undefined + ? {} + : { allowNonZeroExit: options.allowNonZeroExit }), + ...(options.maxOutputBytes === undefined ? {} : { maxOutputBytes: options.maxOutputBytes }), + ...(options.stdin === undefined ? {} : { stdin: options.stdin }), + }) + .pipe( + Effect.mapError( + (cause) => + new HandoffGitStepError({ + step: args[0] ?? "git", + cause, + }), + ), + 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 = Effect.fn("HandoffGit.pack")(function* ( + input: Parameters[0], + ) { + 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", "HEAD"], { + allowNonZeroExit: true, + }); + const branch = + branchName.exitCode === 0 ? branchName.stdout.trim().replace(/^refs\/heads\//, "") : null; + const gitDir = yield* out(input.cwd, ["rev-parse", "--path-format=absolute", "--git-dir"]); + const indexDir = yield* fileSystem + .makeTempDirectoryScoped({ + directory: gitDir, + prefix: "t3-handoff-index-", + }) + .pipe(Effect.mapError((cause) => new HandoffGitStepError({ step: "read-tree", cause }))); + const env = { + GIT_INDEX_FILE: path.join(indexDir, "index"), + ...IDENTITY, + ...VcsProcess.gitConfigEnv(process.env, "core.splitIndex", "false", platform), + }; + const staged = yield* git( + input.cwd, + ["ls-files", "--cached", "--stage", "-t", "--full-name", "-z", "--", ":/"], + { maxOutputBytes: 16 * 1024 * 1024 }, + ); + if (staged.stdoutTruncated) { + return yield* new HandoffGitStepError({ step: "pack", reason: "index-too-large" }); + } + if (staged.stdoutInvalidUtf8) { + return yield* new HandoffGitStepError({ step: "pack", reason: "invalid-path" }); + } + // Keep indexed contents for deliberately skipped files; refresh ordinary working edits. + yield* git(input.cwd, ["read-tree", "--empty"], { env }); + if (staged.stdout !== "") { + yield* git(input.cwd, ["update-index", "-z", "--index-info"], { + env, + stdin: staged.stdout + .split("\0") + .filter((entry) => entry !== "") + .map((entry) => entry.slice(2) + "\0") + .join(""), + }); + } + const skipped = staged.stdout + .split("\0") + .filter((entry) => entry.startsWith("S ")) + .map((entry) => entry.slice(entry.indexOf("\t") + 1) + "\0") + .join(""); + if (skipped !== "") { + const root = yield* out(input.cwd, ["rev-parse", "--show-toplevel"]); + yield* git(root, ["update-index", "-z", "--skip-worktree", "--stdin"], { + env, + stdin: skipped, + }); + } + // Include untracked files outside sparse cones without touching the sender index. + yield* git(input.cwd, ["-c", "core.sparseCheckout=false", "add", "--all", "--", ":/"], { env }); + const entries = yield* git(input.cwd, ["ls-files", "--stage", "-z", "--", ":/"], { + env, + maxOutputBytes: 16 * 1024 * 1024, + }); + if (entries.stdoutTruncated) { + return yield* new HandoffGitStepError({ step: "pack", reason: "index-too-large" }); + } + if (entries.stdout.split("\0").some((entry) => entry.startsWith("160000 "))) { + return yield* new HandoffGitStepError({ step: "pack", reason: "gitlinks" }); + } + const tree = (yield* git(input.cwd, ["write-tree"], { env })).stdout.trim(); + const snapshot = (yield* git( + input.cwd, + ["commit-tree", tree, "-p", tip, "-m", `T3 Code handoff ${input.handoffId}`], + { env }, + )).stdout.trim(); + const snapshotRef = handoffRef(input.handoffId, "snapshot"); + yield* Effect.acquireRelease(git(input.cwd, ["update-ref", snapshotRef, snapshot, ""]), () => + git(input.cwd, ["update-ref", "-d", snapshotRef, snapshot]).pipe(Effect.ignore), + ); + const bundlePath = path.resolve(input.outDir, `${input.handoffId}.bundle`); + yield* Effect.acquireRelease( + fileSystem + .writeFileString(bundlePath, "", { flag: "wx" }) + .pipe(Effect.mapError((cause) => new HandoffGitStepError({ step: "bundle", cause }))), + (_, exit) => + Exit.isFailure(exit) + ? fileSystem.remove(bundlePath, { force: true }).pipe(Effect.ignore) + : Effect.void, + ); + yield* git(input.cwd, [ + "bundle", + "create", + bundlePath, + snapshotRef, + "--not", + "--remotes=origin", + ]); + const bundleBytes = Number( + (yield* fileSystem.stat(bundlePath).pipe( + Effect.mapError( + (cause) => + new HandoffGitStepError({ + step: "bundle", + reason: "bundle-not-written", + cause, + }), + ), + )).size, + ); + if (bundleBytes > MAX_HANDOFF_BUNDLE_BYTES) { + 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, + }; + }, Effect.scoped); + + const apply = Effect.fn("HandoffGit.apply")(function* ( + input: Parameters[0], + ) { + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + if (!(yield* ok(input.repoRoot, ["remote", "get-url", "origin"]))) { + return yield* new HandoffMissingBaseError(); + } + yield* git(input.repoRoot, ["fetch", "--quiet", "origin"], { allowNonZeroExit: true }); + if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { + return yield* new HandoffMissingBaseError(); + } + } + const snapshotRef = handoffRef(input.handoffId, "snapshot"); + const heads = yield* out(input.repoRoot, [ + "bundle", + "list-heads", + input.bundlePath, + snapshotRef, + ]); + if (heads !== `${input.snapshot} ${snapshotRef}`) { + return yield* new HandoffGitStepError({ step: "fetch", reason: "snapshot-mismatch" }); + } + // Import objects without changing receiver refs or FETCH_HEAD. + yield* git(input.repoRoot, [ + "fetch", + "--quiet", + "--no-tags", + "--no-write-fetch-head", + input.bundlePath, + snapshotRef, + ]); + const requestedPath = path.resolve(input.worktreePath); + const parent = yield* fileSystem + .realPath(path.dirname(requestedPath)) + .pipe(Effect.mapError((cause) => new HandoffGitStepError({ step: "worktree", cause }))); + const worktreePath = path.join(parent, path.basename(requestedPath)); + const worktreeListArgs = ["worktree", "list", "--porcelain"]; + const listed = yield* git(input.repoRoot, [...worktreeListArgs, "-z"], { + allowNonZeroExit: true, + }); + // Git before 2.36 only supports the line-delimited form. + const worktrees = + listed.exitCode === 0 + ? listed.stdout.split("\0") + : (yield* out(input.repoRoot, worktreeListArgs)).split("\n"); + if ( + worktrees.some( + (field) => field.startsWith("worktree ") && path.resolve(field.slice(9)) === worktreePath, + ) + ) { + return yield* new HandoffGitStepError({ step: "worktree" }); + } + 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, + }); + const current = existing.exitCode === 0 ? existing.stdout.trim() : ""; + const checkedOut = worktrees.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 }); + } + yield* Effect.acquireRelease( + git(input.repoRoot, ["update-ref", ref, input.tip, current]), + (_, exit) => + Exit.isFailure(exit) + ? git( + input.repoRoot, + current === "" + ? ["update-ref", "-d", ref, input.tip] + : ["update-ref", ref, current, input.tip], + ).pipe(Effect.ignore) + : Effect.void, + ); + } + if (checkedOut) branch = null; + } + // Reserve the path before Git can partially create a checkout or run a failing hook. + yield* Effect.acquireRelease( + fileSystem + .makeDirectory(worktreePath) + .pipe(Effect.mapError((cause) => new HandoffGitStepError({ step: "worktree", cause }))), + (_, exit) => + Exit.isFailure(exit) + ? git(input.repoRoot, ["worktree", "remove", "--force", worktreePath]).pipe( + Effect.ignore, + Effect.andThen( + fileSystem + .remove(worktreePath, { recursive: true, force: true }) + .pipe(Effect.ignore), + ), + ) + : Effect.void, + ); + yield* git( + input.repoRoot, + branch === null + ? ["worktree", "add", "--detach", worktreePath, input.tip] + : ["worktree", "add", worktreePath, branch], + ); + // Import the full snapshot, including deletions outside a receiver's sparse cone. + yield* git(worktreePath, [ + "checkout", + "--ignore-skip-worktree-bits", + "--no-overlay", + input.snapshot, + "--", + ".", + ]); + // Preserve sparse entries without resetting a branch another process may have moved. + yield* git(worktreePath, ["read-tree", "-m", input.tip]); + if ((yield* out(worktreePath, ["rev-parse", "HEAD"])) !== input.tip) { + return yield* new HandoffGitStepError({ step: "worktree" }); + } + return { branch }; + }, Effect.scoped); + + return HandoffGit.of({ pack, apply }); +}); + +export const layer = Layer.effect(HandoffGit, make);