From 5bebefc5aacea1556fc4e97fa84bb4076fd5080c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:10:04 +0000 Subject: [PATCH 1/7] feat(server): transfer Git work with scoped rollback --- .../src/peer/handoff/HandoffGit.test.ts | 371 ++++++++++++++++++ apps/server/src/peer/handoff/HandoffGit.ts | 337 ++++++++++++++++ 2 files changed, 708 insertions(+) create mode 100644 apps/server/src/peer/handoff/HandoffGit.test.ts create mode 100644 apps/server/src/peer/handoff/HandoffGit.ts diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts new file mode 100644 index 0000000000000..e84537963504b --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -0,0 +1,371 @@ +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 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, ["clone", "--quiet", origin, source]); + 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\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* 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(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)]); + + 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.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "secret.txt"))); + assert.equal( + yield* git(f.worktreePath, ["status", "--porcelain"]), + " M app.txt\n D deleted.txt\n?? new.txt\n", + ); + assert.equal(yield* git(f.worktreePath, ["diff", "--cached"]), ""); + + const acceptedBefore = yield* checkoutState(f.worktreePath); + 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"] 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, + }); + 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); + 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 injectResetFailure = false; + const faulted = yield* handoffService.pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fnUntraced( + function* (command: VcsProcess.VcsProcessInput) { + if ( + injectResetFailure && + command.cwd === f.worktreePath && + command.args[0] === "reset" + ) { + injectResetFailure = false; + 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", + ); + 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) { + let advanced: string | 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") { + injectResetFailure = true; + yield* Effect.scoped( + Effect.gen(function* () { + const error = yield* faulted.apply(input).pipe(Effect.flip); + assert.equal(error._tag, "HandoffGitStepError"); + assert.isFalse(injectResetFailure); + // Failure during apply must release ownership even while its caller scope is open. + yield* assertRollback; + }), + ); + } else { + const error = yield* Effect.scoped( + Effect.gen(function* () { + const lease = yield* handoff.apply(input); + assert.equal(lease.branch, branch); + assert.equal((yield* git(f.receiver, ["rev-parse", branch])).trim(), packed.tip); + assert.equal( + (yield* git(f.worktreePath, ["rev-parse", "HEAD"])).trim(), + packed.tip, + ); + if (phase === "concurrent-persistence") { + 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* 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..c7cdf5bfec7ca --- /dev/null +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -0,0 +1,337 @@ +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 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 } = {}, + ) => + 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", "--short", "HEAD"], { + allowNonZeroExit: true, + }); + 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", + }; + // A new index avoids changing staging, merge state, or split-index files in the checkout. + const indexGit = (args: ReadonlyArray) => + git(cwd, ["-c", "core.splitIndex=false", ...args], { env }); + 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"]); + 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: head.exitCode === 0 ? head.stdout.trim() : null, + 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", "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(); + } + } + 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; resetting only the new index leaves edits uncommitted. + yield* git(input.worktreePath, ["checkout", "--no-overlay", input.snapshot, "--", "."]); + yield* git(input.worktreePath, ["reset", "--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); From 3b4d090a0d9911d8ecc835946bde9a1bad231c12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:39:32 +0000 Subject: [PATCH 2/7] fix(server): preserve handoff branch names and reject bad bundles --- .../src/peer/handoff/HandoffGit.test.ts | 19 ++++++++++---- apps/server/src/peer/handoff/HandoffGit.ts | 25 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts index e84537963504b..6fb86b4254c37 100644 --- a/apps/server/src/peer/handoff/HandoffGit.test.ts +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -76,6 +76,7 @@ const fixture = Effect.gen(function* () { 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, ["tag", branch]); yield* write(source, "app.txt", "staged\n"); yield* git(source, ["add", "app.txt"]); yield* write(source, "app.txt", "unstaged\n"); @@ -189,7 +190,7 @@ it.layer(nativeLayer)("HandoffGit", (it) => { }).pipe(Effect.scoped), ); - it.effect.each(["diverged", "checked-out"] as const)( + it.effect.each(["diverged", "checked-out", "corrupt-bundle"] as const)( "refuses a %s destination without overwriting work", (mode) => Effect.gen(function* () { @@ -200,6 +201,9 @@ it.layer(nativeLayer)("HandoffGit", (it) => { 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" @@ -233,10 +237,15 @@ it.layer(nativeLayer)("HandoffGit", (it) => { handoffId: "refuse", }) .pipe(Effect.scoped, Effect.flip); - assert.equal( - error._tag, - mode === "diverged" ? "HandoffBranchDivergedError" : "HandoffBranchCheckedOutError", - ); + 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); diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index c7cdf5bfec7ca..e9d685a14eb33 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -116,6 +116,14 @@ const repositoryEnv = { 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* () { @@ -153,9 +161,14 @@ const make = Effect.gen(function* () { } 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", "--short", "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( @@ -206,7 +219,7 @@ const make = Effect.gen(function* () { return { bundlePath, bundleBytes, - branch: head.exitCode === 0 ? head.stdout.trim() : null, + branch, tip, snapshot, baseRemoteUrl: remote.exitCode === 0 ? remote.stdout.trim() : null, @@ -235,8 +248,14 @@ const make = Effect.gen(function* () { 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 }); + yield* git(input.repoRoot, ["fetch", "--quiet", "origin"], { + allowNonZeroExit: true, + env: nonInteractiveFetchEnv, + }); if (!(yield* ok(input.repoRoot, ["bundle", "verify", input.bundlePath]))) { return yield* new HandoffMissingBaseError(); } From 15fae38f36600bfb31d653e00389f6bc003956c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 22:49:39 +0000 Subject: [PATCH 3/7] fix(server): transfer commits pushed only to other remotes --- apps/server/src/peer/handoff/HandoffGit.test.ts | 4 ++++ apps/server/src/peer/handoff/HandoffGit.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts index 6fb86b4254c37..3d893d6793080 100644 --- a/apps/server/src/peer/handoff/HandoffGit.test.ts +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -56,6 +56,7 @@ const fixture = Effect.gen(function* () { 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"); @@ -63,7 +64,9 @@ const fixture = Effect.gen(function* () { 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"); @@ -76,6 +79,7 @@ const fixture = Effect.gen(function* () { 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"]); diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index e9d685a14eb33..e73ffb6d0d732 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -197,7 +197,7 @@ const make = Effect.gen(function* () { 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"]); + yield* git(cwd, ["bundle", "create", bundlePath, ref, "--not", "--remotes=origin"]); const bundleBytes = Number( (yield* fs .stat(bundlePath) From 6059cdb739becbf704c60bd9bb5e1465d6f4f09f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 23:08:53 +0000 Subject: [PATCH 4/7] fix(server): preserve staged handoff files and concurrent branch advances --- .../src/peer/handoff/HandoffGit.test.ts | 58 ++++++++++++------- apps/server/src/peer/handoff/HandoffGit.ts | 37 ++++++++++-- 2 files changed, 68 insertions(+), 27 deletions(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts index 3d893d6793080..d371f42f7745a 100644 --- a/apps/server/src/peer/handoff/HandoffGit.test.ts +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -70,7 +70,7 @@ const fixture = Effect.gen(function* () { 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\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"]); @@ -87,6 +87,8 @@ const fixture = Effect.gen(function* () { 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"); @@ -170,6 +172,10 @@ it.layer(nativeLayer)("HandoffGit", (it) => { ); assert.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "deleted.txt"))); assert.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "secret.txt"))); + assert.equal( + yield* f.fs.readFileString(f.path.join(f.worktreePath, "forced.txt")), + "source forced\n", + ); assert.equal( yield* git(f.worktreePath, ["status", "--porcelain"]), " M app.txt\n D deleted.txt\n?? new.txt\n", @@ -281,17 +287,19 @@ it.layer(nativeLayer)("HandoffGit", (it) => { worktreePath: f.worktreePath, handoffId: "rollback", }; - let injectResetFailure = false; + 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 ( - injectResetFailure && + indexAction !== undefined && command.cwd === f.worktreePath && - command.args[0] === "reset" + (command.args[0] === "reset" || command.args[0] === "read-tree") ) { - injectResetFailure = false; + const action = indexAction; + indexAction = undefined; assert.equal((yield* git(f.receiver, ["rev-parse", branch])).trim(), packed.tip); assert.equal( yield* f.fs @@ -299,6 +307,18 @@ it.layer(nativeLayer)("HandoffGit", (it) => { .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", @@ -318,7 +338,7 @@ it.layer(nativeLayer)("HandoffGit", (it) => { }), ); for (const phase of ["late-git", "persistence", "concurrent-persistence"] as const) { - let advanced: string | undefined; + advanced = undefined; const assertRollback = Effect.gen(function* () { assert.deepEqual(yield* checkoutState(f.receiver), before); assert.equal( @@ -341,37 +361,31 @@ it.layer(nativeLayer)("HandoffGit", (it) => { assert.equal(yield* refs(f.receiver), expectedRefs); }); if (phase === "late-git") { - injectResetFailure = true; + indexAction = "fail"; yield* Effect.scoped( Effect.gen(function* () { const error = yield* faulted.apply(input).pipe(Effect.flip); assert.equal(error._tag, "HandoffGitStepError"); - assert.isFalse(injectResetFailure); + 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* handoff.apply(input); + const lease = yield* faulted.apply(input); + assert.isUndefined(indexAction); assert.equal(lease.branch, branch); - assert.equal((yield* git(f.receiver, ["rev-parse", branch])).trim(), packed.tip); + assert.equal( + (yield* git(f.receiver, ["rev-parse", branchRef])).trim(), + advanced ?? packed.tip, + ); assert.equal( (yield* git(f.worktreePath, ["rev-parse", "HEAD"])).trim(), - packed.tip, + advanced ?? packed.tip, ); - if (phase === "concurrent-persistence") { - 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* Effect.fail({ _tag: "ReceiverPersistenceFailure" as const }); }), ).pipe(Effect.flip); diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index e73ffb6d0d732..21de411d3e300 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -181,10 +181,37 @@ const make = Effect.gen(function* () { GIT_COMMITTER_NAME: "T3 Code", GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; - // A new index avoids changing staging, merge state, or split-index files in the checkout. const indexGit = (args: ReadonlyArray) => - git(cwd, ["-c", "core.splitIndex=false", ...args], { env }); - yield* indexGit(["read-tree", tip]); + 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( @@ -337,9 +364,9 @@ const make = Effect.gen(function* () { ? ["worktree", "add", "--detach", input.worktreePath, input.tip] : ["worktree", "add", input.worktreePath, branch], ); - // No-overlay checkout carries deletions; resetting only the new index leaves edits uncommitted. + // 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, ["reset", "--quiet", input.tip]); + yield* git(input.worktreePath, ["read-tree", input.tip]); return { branch, commit: Effect.sync(() => { From c52c81946a6fd14f4799085923fc07e74ae4c214 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 23:20:39 +0000 Subject: [PATCH 5/7] fix(server): preserve unchanged files in handoff index --- apps/server/src/peer/handoff/HandoffGit.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index 21de411d3e300..773bb95ba1020 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -366,7 +366,7 @@ const make = Effect.gen(function* () { ); // 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", input.tip]); + yield* git(input.worktreePath, ["read-tree", "-m", input.tip]); return { branch, commit: Effect.sync(() => { From eaeccfc4a1d3ddf136437ec87e0899559824b961 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 23:34:02 +0000 Subject: [PATCH 6/7] fix(server): retain ignored work across thread moves --- .../src/peer/handoff/HandoffGit.test.ts | 30 ++++++++++++++++--- apps/server/src/peer/handoff/HandoffGit.ts | 30 ++++++++++++++++++- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts index d371f42f7745a..1ef88b7047ac8 100644 --- a/apps/server/src/peer/handoff/HandoffGit.test.ts +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -176,13 +176,35 @@ it.layer(nativeLayer)("HandoffGit", (it) => { yield* f.fs.readFileString(f.path.join(f.worktreePath, "forced.txt")), "source forced\n", ); - assert.equal( - yield* git(f.worktreePath, ["status", "--porcelain"]), - " M app.txt\n D deleted.txt\n?? new.txt\n", - ); assert.equal(yield* git(f.worktreePath, ["diff", "--cached"]), ""); + yield* f.write(f.worktreePath, "secret.txt", "receiver secret\n"); 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* () { diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index 773bb95ba1020..ef941803dfe19 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -134,7 +134,13 @@ const make = Effect.gen(function* () { const git = ( cwd: string, args: ReadonlyArray, - options: { readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean } = {}, + options: { + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly stdin?: string; + readonly maxOutputBytes?: number; + readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; + } = {}, ) => processes .run({ @@ -367,6 +373,28 @@ const make = Effect.gen(function* () { // 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]); + // Keep carried force-added ignored files visible to the next commit or handoff. + const ignored = yield* git( + input.worktreePath, + ["ls-files", "-z", "--others", "--ignored", "--exclude-standard"], + { maxOutputBytes: 16 * 1024 * 1024, outputMode: "error" }, + ); + 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(() => { From d2082ef09d2ea651897a9327ca0f2d7e9eb4f78b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 23:44:33 +0000 Subject: [PATCH 7/7] fix(server): exclude receiver hook output from handoff --- .../src/peer/handoff/HandoffGit.test.ts | 12 +++- apps/server/src/peer/handoff/HandoffGit.ts | 57 +++++++++++++------ 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/apps/server/src/peer/handoff/HandoffGit.test.ts b/apps/server/src/peer/handoff/HandoffGit.test.ts index 1ef88b7047ac8..0bcda54ca11d0 100644 --- a/apps/server/src/peer/handoff/HandoffGit.test.ts +++ b/apps/server/src/peer/handoff/HandoffGit.test.ts @@ -143,6 +143,12 @@ it.layer(nativeLayer)("HandoffGit", (it) => { 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, @@ -171,14 +177,16 @@ it.layer(nativeLayer)("HandoffGit", (it) => { "library\n", ); assert.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "deleted.txt"))); - assert.isFalse(yield* f.fs.exists(f.path.join(f.worktreePath, "secret.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"]), ""); - yield* f.write(f.worktreePath, "secret.txt", "receiver secret\n"); const acceptedBefore = yield* checkoutState(f.worktreePath); const repacked = yield* handoff.pack({ cwd: f.worktreePath, diff --git a/apps/server/src/peer/handoff/HandoffGit.ts b/apps/server/src/peer/handoff/HandoffGit.ts index ef941803dfe19..1320e6d3b937a 100644 --- a/apps/server/src/peer/handoff/HandoffGit.ts +++ b/apps/server/src/peer/handoff/HandoffGit.ts @@ -373,27 +373,52 @@ const make = Effect.gen(function* () { // 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]); - // Keep carried force-added ignored files visible to the next commit or handoff. - const ignored = yield* git( + // 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, - ["ls-files", "-z", "--others", "--ignored", "--exclude-standard"], + [ + "diff", + "-z", + "--name-only", + "--line-prefix=./", + "--no-renames", + "--diff-filter=A", + input.tip, + input.snapshot, + ], { maxOutputBytes: 16 * 1024 * 1024, outputMode: "error" }, ); - if (ignored.stdout !== "") { - yield* git( + if (added.stdout !== "") { + const ignored = yield* git( input.worktreePath, - [ - "--literal-pathspecs", - "add", - "--intent-to-add", - "--force", - "--pathspec-from-file=-", - "--pathspec-file-nul", - ], - { stdin: ignored.stdout }, + ["check-ignore", "-z", "--stdin", "--no-index"], + { + stdin: added.stdout, + allowNonZeroExit: true, + maxOutputBytes: 16 * 1024 * 1024, + outputMode: "error", + }, ); - // An intent entry can displace a tracked ancestor in a file-to-directory replacement. - yield* git(input.worktreePath, ["diff", "--cached", "--quiet", input.tip]); + 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,