From cc1246183f0429b1ca955cca1eca10800d5cb983 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:30:56 +0200 Subject: [PATCH] fix(stack): restore upstream Git ref refresh (#4727) over Tim conflict Blind theirs on fork/tim dropped main's vcs resource-storm fix while leaving main's vcs tests, so integration Test failed. Restore main's VCS client/server files and prefer ours for those paths on future Tim rebases. --- .github/pr-stack.json | 18 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 617 ++++++++++++++---- apps/server/src/vcs/GitVcsDriverCore.ts | 182 +----- .../BranchToolbarBranchSelector.tsx | 186 ++---- packages/client-runtime/src/state/vcs.ts | 253 ++++--- .../src/state/vcsAction.test.ts | 53 -- .../client-runtime/src/state/vcsAction.ts | 43 +- .../src/state/vcsCommandScheduler.ts | 8 - 8 files changed, 752 insertions(+), 608 deletions(-) diff --git a/.github/pr-stack.json b/.github/pr-stack.json index 36f18acb790..18cdf3e3a76 100644 --- a/.github/pr-stack.json +++ b/.github/pr-stack.json @@ -126,37 +126,37 @@ "branch": "fork/tim", "commit": "*", "path": "apps/server/src/vcs/GitVcsDriverCore.test.ts", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/tim", "commit": "17c1de988fcc3db7fbb98cd2544c959ed5a9590a", "path": "apps/server/src/vcs/GitVcsDriverCore.test.ts", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/tim", "commit": "*", "path": "packages/client-runtime/src/state/vcs.ts", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/tim", "commit": "1b0f3a0e84537949b552e71005410416bbc09440", "path": "packages/client-runtime/src/state/vcs.ts", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/tim", "commit": "*", "path": "apps/web/src/components/BranchToolbarBranchSelector.tsx", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/tim", "commit": "38fa39edcfe2fff33eb966541c80403fcc17b97c", "path": "apps/web/src/components/BranchToolbarBranchSelector.tsx", - "strategy": "theirs" + "strategy": "ours" }, { "branch": "fork/candidates", @@ -205,6 +205,12 @@ "commit": "d21149c3666035d6afd61fbc87b4e6ceac2314e8", "path": "package.json", "strategy": "theirs" + }, + { + "branch": "fork/tim", + "commit": "*", + "path": "apps/server/src/vcs/GitVcsDriverCore.ts", + "strategy": "ours" } ] } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 86e4dce674f..941d92cdbff 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1,27 +1,22 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it, describe } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { - DirenvEnvironment, - identityDirenvEnvironmentResolver, -} from "../provider/DirenvEnvironment.ts"; -import { - isCommitSigningFailureStderr, - makeGitVcsDriverCore, - redactGitOutput, - splitNullSeparatedGitStdoutPaths, -} from "./GitVcsDriverCore.ts"; +import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -47,6 +42,21 @@ const makeNonRepositoryHandle = () => getOutputFd: () => Stream.empty, }); +const makeSuccessfulHandle = (stdout: string) => + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + const makeTmpDir = ( prefix = "git-vcs-driver-test-", ): Effect.Effect => @@ -97,7 +107,6 @@ const initRepoWithCommit = ( yield* driver.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); - yield* git(cwd, ["config", "commit.gpgSign", "false"]); yield* writeTextFile(cwd, "README.md", "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); @@ -139,11 +148,434 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () assert.deepStrictEqual(commands, [ { args: ["status", "--porcelain=2", "--branch"], lcAll: "C" }, { args: ["rev-parse", "--abbrev-ref", "HEAD"], lcAll: "C" }, - { args: ["branch", "--no-color", "--no-column"], lcAll: "C" }, + { args: ["rev-parse", "--git-common-dir"], lcAll: "C" }, ]); }).pipe(Effect.provide(layer)); }); +it.effect("coalesces concurrent ref pages into one repository snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawnedArgs = yield* Ref.make>>([]); + const firstWorktreeScanStarted = yield* Deferred.make(); + const remoteNamesScanCompleted = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const countingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + yield* Ref.update(spawnedArgs, (current) => [...current, command.args]); + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + const shouldDelay = + isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false)); + if (shouldDelay) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Effect.sleep("8 seconds"); + } + const handle = yield* delegate.spawn(command); + const isRemoteNamesScan = + command.args.length === 3 && + command.args[0] === "--git-dir" && + command.args[2] === "remote"; + return isRemoteNamesScan + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(remoteNamesScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, countingSpawner), + ); + const cwd = yield* makeTmpDir(); + const runGit = (args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.coalescedListRefs", + cwd, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(["config", "user.email", "test@test.com"]); + yield* runGit(["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(["add", "."]); + yield* runGit(["commit", "-m", "initial commit"]); + yield* Ref.set(spawnedArgs, []); + + const initialRequest = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(remoteNamesScanCompleted); + yield* TestClock.adjust("6 seconds"); + const laterRequests = yield* Effect.all( + Array.from({ length: 30 }, (_, index) => + driver.listRefs({ + cwd, + refresh: true, + query: `missing-${index}`, + limit: 100, + }), + ), + { concurrency: "unbounded" }, + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(initialRequest); + yield* Fiber.join(laterRequests); + yield* driver.listRefs({ cwd, cursor: 1, limit: 100 }); + + const firstSnapshotCommands = yield* Ref.get(spawnedArgs); + const snapshotRefScans = firstSnapshotCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ); + const worktreeScans = firstSnapshotCommands.filter( + (args) => args.includes("worktree") && args.includes("--porcelain"), + ); + assert.equal(snapshotRefScans.length, 1); + assert.equal(worktreeScans.length, 1); + + yield* driver.createRef({ cwd, refName: "feature/cache-invalidation" }); + const refreshed = yield* driver.listRefs({ cwd, limit: 100 }); + assert.equal( + refreshed.refs.some((ref) => ref.name === "feature/cache-invalidation"), + true, + ); + const allCommands = yield* Ref.get(spawnedArgs); + assert.equal( + allCommands.filter( + (args) => + args.includes("for-each-ref") && + args.includes("refs/heads") && + args.includes("refs/remotes"), + ).length, + 2, + ); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("retries an in-flight ref snapshot invalidated by a mutation", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const firstWorktreeScanStarted = yield* Deferred.make(); + const firstRefScanCompleted = yield* Deferred.make(); + const releaseFirstWorktreeScan = yield* Deferred.make(); + const delayFirstWorktreeScan = yield* Ref.make(true); + const refScans = yield* Ref.make(0); + const coordinatingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeScan = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeScan && (yield* Ref.getAndSet(delayFirstWorktreeScan, false))) { + yield* Deferred.succeed(firstWorktreeScanStarted, undefined); + yield* Deferred.await(releaseFirstWorktreeScan); + } + const handle = yield* delegate.spawn(command); + const isRefScan = + command.args.includes("for-each-ref") && + command.args.includes("refs/heads") && + command.args.includes("refs/remotes"); + if (!isRefScan) return handle; + const scan = yield* Ref.updateAndGet(refScans, (count) => count + 1); + return scan === 1 + ? ChildProcessSpawner.makeHandle({ + ...handle, + exitCode: handle.exitCode.pipe( + Effect.tap(() => Deferred.succeed(firstRefScanCompleted, undefined)), + ), + }) + : handle; + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, coordinatingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const inFlight = yield* driver + .listRefs({ cwd, refresh: true, limit: 100 }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(firstWorktreeScanStarted); + yield* Deferred.await(firstRefScanCompleted); + + yield* driver.createRef({ cwd, refName: "feature/during-refresh" }); + yield* Deferred.succeed(releaseFirstWorktreeScan, undefined); + + const refs = yield* Fiber.join(inFlight); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/during-refresh")); + assert.equal(yield* Ref.get(refScans), 2); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("invalidates a ref snapshot when a mutation fails after changing Git", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const partiallyFailingSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args[0] === "branch" && command.args[1] === "feature/partial-failure") { + const handle = yield* delegate.spawn(command); + yield* handle.exitCode; + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, partiallyFailingSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* driver.listRefs({ cwd, refresh: true }); + + yield* driver.createRef({ cwd, refName: "feature/partial-failure" }).pipe(Effect.flip); + + const refs = yield* driver.listRefs({ cwd }); + assert.isTrue(refs.refs.some((ref) => ref.name === "feature/partial-failure")); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("fails a ref snapshot when for-each-ref exits unsuccessfully", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const snapshotAttempts = yield* Ref.make(0); + const failingSnapshotSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("for-each-ref")) { + yield* Ref.update(snapshotAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingSnapshotSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + + const error = yield* driver.listRefs({ cwd, refresh: true }).pipe(Effect.flip); + + assert.deepInclude(error, { + _tag: "GitCommandError", + operation: "GitVcsDriver.listRefs.snapshotRefs", + detail: "Git ref snapshot enumeration failed.", + exitCode: 128, + }); + assert.equal(yield* Ref.get(snapshotAttempts), 1); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("marks the current branch when worktree metadata is unavailable", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const incompleteMetadataSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeRoot = + command.args.includes("rev-parse") && command.args.includes("--show-toplevel"); + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeRoot || isWorktreeList) { + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, incompleteMetadataSpawner), + ); + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.isTrue(refs.isRepo); + assert.isTrue(refs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("ignores worktree metadata for directories that no longer exist", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const missingWorktreePath = "/missing/deleted-worktree"; + const staleWorktreeSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + const isWorktreeList = + command.args.includes("worktree") && command.args.includes("--porcelain"); + if (isWorktreeList) { + return makeSuccessfulHandle( + `worktree ${missingWorktreePath}\0HEAD deadbeef\0branch refs/heads/stale-worktree\0\0`, + ); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner), + ); + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver)); + yield* git(cwd, ["branch", "stale-worktree"]).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, driver), + ); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + + assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + +it.effect("refreshes the current branch after an external checkout", () => + Effect.scoped( + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "external-checkout"]); + + const initialRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current); + + // Raw execute intentionally bypasses the driver's mutation invalidation, + // matching a checkout performed by another process. + yield* driver.execute({ + operation: "GitVcsDriver.test.externalCheckout", + cwd, + args: ["checkout", "external-checkout"], + timeoutMs: 10_000, + }); + yield* TestClock.adjust("6 seconds"); + + const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true }); + assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current); + assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("backs off failed upstream refreshes across linked worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const fetchAttempts = yield* Ref.make(0); + const failingFetchSpawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (!ChildProcess.isStandardCommand(command)) { + return yield* Effect.die("expected a standard Git command"); + } + if (command.args.includes("fetch") && command.args.includes("--quiet")) { + yield* Ref.update(fetchAttempts, (count) => count + 1); + return makeNonRepositoryHandle(); + } + return yield* delegate.spawn(command); + }), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingFetchSpawner), + ); + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-remote-"); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked"); + const runGit = (workingDirectory: string, args: ReadonlyArray) => + driver.execute({ + operation: "GitVcsDriver.test.upstreamRefreshBackoff", + cwd: workingDirectory, + args, + timeoutMs: 10_000, + }); + + yield* driver.initRepo({ cwd }); + yield* runGit(cwd, ["config", "user.email", "test@test.com"]); + yield* runGit(cwd, ["config", "user.name", "Test"]); + yield* writeTextFile(cwd, "README.md", "# test\n"); + yield* runGit(cwd, ["add", "."]); + yield* runGit(cwd, ["commit", "-m", "initial commit"]); + const initialBranch = (yield* runGit(cwd, ["branch", "--show-current"])).stdout.trim(); + yield* runGit(remote, ["init", "--bare"]); + yield* runGit(cwd, ["remote", "add", "origin", remote]); + yield* runGit(cwd, ["push", "-u", "origin", initialBranch]); + yield* runGit(cwd, ["worktree", "add", "-b", "feature/linked", worktreePath]); + yield* runGit(worktreePath, [ + "branch", + "--set-upstream-to", + `origin/${initialBranch}`, + "feature/linked", + ]); + const rootCommonDir = (yield* runGit(cwd, ["rev-parse", "--git-common-dir"])).stdout.trim(); + const linkedCommonDir = (yield* runGit(worktreePath, [ + "rev-parse", + "--git-common-dir", + ])).stdout.trim(); + assert.equal( + yield* fileSystem.realPath(pathService.resolve(cwd, rootCommonDir)), + yield* fileSystem.realPath(pathService.resolve(worktreePath, linkedCommonDir)), + ); + yield* Ref.set(fetchAttempts, 0); + + yield* driver.statusDetailsRemote(cwd); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("29 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 1); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("59 seconds"); + yield* driver.statusDetailsRemote(worktreePath); + assert.equal(yield* Ref.get(fetchAttempts), 2); + + yield* TestClock.adjust("1 second"); + yield* driver.statusDetailsRemote(cwd); + assert.equal(yield* Ref.get(fetchAttempts), 3); + }), + ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), +); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => @@ -671,6 +1103,33 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }); describe("worktree operations", () => { + it.effect("preserves newline characters in worktree paths when listing refs", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const worktreesRoot = yield* makeTmpDir("git-vcs-driver-worktrees-"); + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const worktreePath = pathService.join(worktreesRoot, "linked\nworktree"); + const driver = yield* GitVcsDriver.GitVcsDriver; + + yield* git(cwd, ["worktree", "add", "-b", "feature/newline-path", worktreePath]); + + const refs = yield* driver.listRefs({ cwd, refresh: true }); + const listedPath = refs.refs.find( + (ref) => ref.name === "feature/newline-path", + )?.worktreePath; + + if (typeof listedPath !== "string") { + return assert.fail("expected the linked branch to include its worktree path"); + } + assert.equal( + yield* fileSystem.realPath(listedPath), + yield* fileSystem.realPath(worktreePath), + ); + }), + ); + it.effect("creates and removes a worktree for a new refName", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); @@ -681,19 +1140,8 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { "feature-worktree", ); const driver = yield* GitVcsDriver.GitVcsDriver; - const approvedWorktrees: Array = []; - const driverWithApprovalSpy = yield* makeGitVcsDriverCore().pipe( - Effect.provide(ServerConfigLayer), - Effect.provideService(DirenvEnvironment, { - allow: ({ cwd }) => - Effect.sync(() => { - approvedWorktrees.push(cwd); - }), - resolve: identityDirenvEnvironmentResolver, - }), - ); - const created = yield* driverWithApprovalSpy.createWorktree({ + const created = yield* driver.createWorktree({ cwd, path: worktreePath, refName: initialBranch, @@ -703,7 +1151,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.equal(created.worktree.path, worktreePath); assert.equal(created.worktree.refName, "feature/worktree"); assert.equal(yield* git(worktreePath, ["branch", "--show-current"]), "feature/worktree"); - assert.deepStrictEqual(approvedWorktrees, [worktreePath]); yield* driver.removeWorktree({ cwd, path: worktreePath }); const fileSystem = yield* FileSystem.FileSystem; @@ -808,76 +1255,6 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.include(status, "?? selected1.txt"); }), ); - - it("recognizes representative GPG, pinentry, and SSH signing diagnostics", () => { - assert.isTrue(isCommitSigningFailureStderr("error: gpg failed to sign the data")); - assert.isTrue( - isCommitSigningFailureStderr( - "gpg: signing failed: Inappropriate ioctl for device\nfatal: failed to write commit object", - ), - ); - assert.isTrue(isCommitSigningFailureStderr("error: ssh-keygen failed to sign the data")); - assert.isTrue( - isCommitSigningFailureStderr( - "error: Couldn't load public key /tmp/missing.pub: No such file or directory", - ), - ); - assert.isFalse(isCommitSigningFailureStderr("fatal: failed to write commit object")); - }); - - it.effect("classifies signing failures and can commit unsigned for one attempt", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const pathService = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const signerPath = pathService.join(cwd, "failing-signer.sh"); - yield* fileSystem.writeFileString( - signerPath, - "#!/bin/sh\necho 'gpg: signing failed: No secret key' >&2\nexit 1\n", - ); - yield* fileSystem.chmod(signerPath, 0o755); - yield* git(cwd, ["config", "commit.gpgSign", "true"]); - yield* git(cwd, ["config", "gpg.format", "openpgp"]); - yield* git(cwd, ["config", "gpg.program", signerPath]); - yield* writeTextFile(cwd, "signed.txt", "sign me\n"); - yield* git(cwd, ["add", "signed.txt"]); - - const error = yield* driver.commit(cwd, "Signed commit", "").pipe(Effect.flip); - assert.equal(error.failureKind, "commit_signing_failed"); - assert.notProperty(error, "stderr"); - - const commit = yield* driver.commit(cwd, "Unsigned commit", "", { - disableSigning: true, - }); - assert.match(commit.commitSha, /^[a-f0-9]{40}$/); - assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Unsigned commit"); - assert.notInclude(yield* git(cwd, ["cat-file", "commit", "HEAD"]), "gpgsig "); - assert.equal(yield* git(cwd, ["config", "--bool", "commit.gpgSign"]), "true"); - }), - ); - - it.effect("does not classify a failed commit hook as a signing failure", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - const pathService = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const hookPath = pathService.join(cwd, ".git", "hooks", "pre-commit"); - yield* fileSystem.writeFileString( - hookPath, - "#!/bin/sh\necho 'error: gpg failed to sign the data' >&2\nexit 1\n", - ); - yield* fileSystem.chmod(hookPath, 0o755); - yield* writeTextFile(cwd, "hooked.txt", "fail first\n"); - yield* git(cwd, ["add", "hooked.txt"]); - - const error = yield* driver.commit(cwd, "Hook failure", "").pipe(Effect.flip); - assert.equal(error.failureKind, "unknown"); - }), - ); }); describe("remote operations", () => { @@ -1066,53 +1443,3 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); }); - -describe("redactGitOutput", () => { - // git echoes the remote URL it used, and those URLs routinely carry secrets. - // This output gets logged, so a miss here writes a live token to the journal. - it("strips credentials embedded in remote URLs", () => { - const redacted = redactGitOutput( - "fatal: unable to access 'https://x-access-token:ghp_secretvalue123@github.com/o/r.git/': 403", - ); - assert.notInclude(redacted, "ghp_secretvalue123"); - // The useful part survives. - assert.include(redacted, "fatal: unable to access"); - assert.include(redacted, "github.com/o/r.git"); - assert.include(redacted, "403"); - }); - - it("strips userinfo for any scheme, not just https", () => { - for (const url of [ - "http://user:pw@example.com/x", - "ssh://git:pw@example.com/x", - "https://token@example.com/x", - ]) { - const redacted = redactGitOutput(`fatal: could not read ${url}`); - assert.notInclude(redacted, "pw@"); - assert.notInclude(redacted, "token@"); - assert.include(redacted, "@"); - } - }); - - it("strips bare provider tokens", () => { - const redacted = redactGitOutput("remote: bad credentials ghp_abc123XYZ and glpat-zzz999"); - assert.notInclude(redacted, "abc123XYZ"); - assert.notInclude(redacted, "zzz999"); - }); - - it("strips authorization headers", () => { - const redacted = redactGitOutput("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig"); - assert.notInclude(redacted, "eyJhbGciOiJIUzI1NiJ9.payload.sig"); - }); - - it("leaves ordinary git errors intact", () => { - // The point is diagnosability, so a plain error must survive verbatim. - const message = - "fatal: Needed a single revision\nfatal: a branch named 'x' already exists\nssh: connect to host github.com port 22: Connection timed out"; - assert.strictEqual(redactGitOutput(message), message); - }); - - it("caps runaway output", () => { - assert.isAtMost(redactGitOutput("x".repeat(50_000)).length, 2000); - }); -}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2f656c083da..f739c98da29 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -28,7 +28,6 @@ import { import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -import { DirenvEnvironment } from "../provider/DirenvEnvironment.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import { @@ -71,29 +70,6 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ } satisfies NodeJS.ProcessEnv); const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; - -const COMMIT_SIGNING_FAILURE_PATTERNS = [ - /gpg(?:2)?(?:\.exe)?: .*failed to sign/i, - /gpg failed to sign the data/i, - /signing failed:/i, - /failed to sign the data/i, - /pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i, - /(?:failed|error|no such file|cancell?ed).*pinentry/i, - /inappropriate ioctl for device/i, - /cannot open \/dev\/tty/i, - /no secret key/i, - /secret key not available/i, - /ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i, - /couldn['’]t sign (?:message|data)/i, - /couldn['’]t load public key/i, - /no private key found for public key/i, - /load key .*: (?:invalid format|no such file or directory|permission denied)/i, - /agent refused operation/i, -] as const; - -export function isCommitSigningFailureStderr(stderr: string): boolean { - return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr)); -} const NON_REPOSITORY_STATUS_DETAILS = Object.freeze({ isRepo: false, hasOriginRemote: false, @@ -403,7 +379,6 @@ function gitCommandContext( command: "git", cwd: input.cwd, argumentCount: input.args.length, - failureKind: "unknown" as const, } as const; } @@ -441,30 +416,6 @@ function isNonRepositoryGitStderr(stderr: string): boolean { return stderr.toLowerCase().includes("not a git repository"); } -/** Longer than any real git error line, short enough to keep logs readable. */ -const GIT_STDERR_LOG_LIMIT = 2000; - -/** - * Strip credentials from git output so it can be logged. - * - * git echoes the remote URL it used, and those URLs routinely carry secrets - * (`https://x-access-token:TOKEN@github.com/...`), so raw stderr must never - * reach a log. Redacts the userinfo component of any URL plus bare tokens that - * commonly appear on their own. - */ -export function redactGitOutput(stderr: string): string { - return ( - stderr - .slice(0, GIT_STDERR_LOG_LIMIT) - .replace(/([a-zA-Z][\w+.-]*:\/\/)[^/@\s]*@/g, "$1@") - .replace(/\b(gh[pousr]_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "$1") - // Take the whole value, not just the scheme word: `Authorization: Bearer X` - // must not redact `Bearer` and leave `X` behind. - .replace(/\b(Authorization)\s*[:=]\s*.*/gi, "$1: ") - .replace(/\b(Bearer|token)\s*[:=]?\s+\S+/gi, "$1 ") - ); -} - interface Trace2Monitor { readonly env: NodeJS.ProcessEnv; readonly flush: Effect.Effect; @@ -540,15 +491,16 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( return; } + if (traceRecord.success.child_class !== "hook") { + return; + } + const event = traceRecord.success.event; const childKey = trace2ChildKey(traceRecord.success); if (childKey === null) { return; } const started = hookStartByChildKey.get(childKey); - if (traceRecord.success.child_class !== "hook" && started === undefined) { - return; - } const hookNameFromEvent = typeof traceRecord.success.hook_name === "string" ? traceRecord.success.hook_name.trim() : ""; const hookName = hookNameFromEvent.length > 0 ? hookNameFromEvent : (started?.hookName ?? ""); @@ -570,7 +522,7 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* ( if (event === "child_exit") { hookStartByChildKey.delete(childKey); - const code = traceRecord.success.code ?? traceRecord.success.exitCode; + const code = traceRecord.success.exitCode; const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null; const now = yield* DateTime.now; const durationMs = started @@ -742,17 +694,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; - const direnvEnvironment = yield* DirenvEnvironment; - - const approveWorktreeEnvironment = (cwd: string) => - direnvEnvironment.allow({ cwd, environment: process.env }).pipe( - Effect.catch((error) => - Effect.logWarning("Failed to approve direnv for a newly created worktree.", { - cwd, - detail: error.message, - }), - ), - ); const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -799,8 +740,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); - const onStdoutLine = input.progress?.onStdoutLine; - const onStderrLine = input.progress?.onStderrLine; const [stdout, stderr, exitCode] = yield* Effect.all( [ collectOutput( @@ -808,18 +747,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* child.stdout, maxOutputBytes, appendTruncationMarker, - onStdoutLine - ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStdoutLine(line))) - : undefined, + input.progress?.onStdoutLine, ), collectOutput( commandInput, child.stderr, maxOutputBytes, appendTruncationMarker, - onStderrLine - ? (line) => trace2Monitor.flush.pipe(Effect.andThen(onStderrLine(line))) - : undefined, + input.progress?.onStderrLine, ), child.exitCode.pipe( Effect.mapError( @@ -929,29 +864,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (options.allowNonZeroExit || result.exitCode === 0) { return Effect.succeed(result); } - // GitCommandError carries only lengths, never git's output — it crosses - // the wire to clients, and git echoes remote URLs that can embed - // credentials. That makes a failure unexplainable from the UI alone - // ("git worktree add failed" and nothing more), so log the reason here, - // server-side and redacted, where it is safe to keep. - return Effect.logWarning("git command failed", { - operation, - cwd, - args: args.join(" "), - exitCode: result.exitCode, - stderr: redactGitOutput(result.stderr), - }).pipe( - Effect.andThen( - Effect.fail( - new GitCommandError({ - ...gitCommandContext({ operation, cwd, args }), - detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - }), - ), - ), + return Effect.fail( + new GitCommandError({ + ...gitCommandContext({ operation, cwd, args }), + detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", + ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), + stdoutLength: result.stdout.length, + stderrLength: result.stderr.length, + }), ); }), ); @@ -1817,52 +1737,25 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - const args = ["commit"]; - if (options?.disableSigning) { - args.push("--no-gpg-sign"); - } - args.push("-m", subject); + const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } - let hookFailed = false; - const progress: GitVcsDriver.ExecuteGitProgress = { - ...(options?.progress?.onOutputLine - ? { + const progress = + options?.progress?.onOutputLine === undefined + ? options?.progress + : { + ...options.progress, onStdoutLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void, onStderrLine: (line: string) => options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void, - } - : {}), - ...(options?.progress?.onHookStarted - ? { onHookStarted: options.progress.onHookStarted } - : {}), - onHookFinished: (input) => { - if (input.exitCode !== null && input.exitCode !== 0) { - hookFailed = true; - } - return options?.progress?.onHookFinished?.(input) ?? Effect.void; - }, - }; - const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, { - allowNonZeroExit: true, + }; + yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - progress, - }); - if (result.exitCode !== 0) { - return yield* new GitCommandError({ - ...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }), - detail: "Git command exited with a non-zero status.", - ...(result.exitCode === null ? {} : { exitCode: result.exitCode }), - stdoutLength: result.stdout.length, - stderrLength: result.stderr.length, - ...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr) - ? { failureKind: "commit_signing_failed" as const } - : {}), - }); - } + ...(progress ? { progress } : {}), + }).pipe(Effect.asVoid); const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", @@ -2046,18 +1939,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const readRangeContext: GitVcsDriver.GitVcsDriver["Service"]["readRangeContext"] = Effect.fn( "readRangeContext", )(function* (cwd, baseRef) { - // Two-dot for `log` lists only the branch's own commits, while three-dot - // diffs against the merge-base (fork point) so commits that landed on the - // base branch after we forked are not reported as removals when the base - // is ahead of our fork point. - const commitRange = `${baseRef}..HEAD`; - const diffRange = `${baseRef}...HEAD`; + const range = `${baseRef}..HEAD`; const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.log", cwd, - ["log", "--oneline", commitRange], + ["log", "--oneline", range], { maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2066,7 +1954,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffStat", cwd, - ["diff", "--stat", diffRange], + ["diff", "--stat", range], { maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2075,7 +1963,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGitStdoutWithOptions( "GitVcsDriver.readRangeContext.diffPatch", cwd, - ["diff", "--no-ext-diff", "--patch", "--minimal", diffRange], + ["diff", "--no-ext-diff", "--patch", "--minimal", range], { maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, appendTruncationMarker: true, @@ -2219,7 +2107,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* operation: "GitVcsDriver.getReviewDiffPreview.hash", command: "crypto.digest SHA-256", cwd: input.cwd, - failureKind: "unknown", detail: "Failed to hash review diff.", cause, }), @@ -2572,12 +2459,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const targetBranch = input.newRefName ?? input.refName; const sanitizedBranch = targetBranch.replace(/\//g, "-"); const repoName = path.basename(input.cwd); - const worktreeName = sanitizedBranch.startsWith(`${repoName}-`) - ? sanitizedBranch - : sanitizedBranch.startsWith("t3code-") - ? `${repoName}-${sanitizedBranch.slice("t3code-".length)}` - : sanitizedBranch; - const worktreePath = input.path ?? path.join(worktreesDir, repoName, worktreeName); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); const args = input.newRefName ? ["worktree", "add", "-b", input.newRefName, worktreePath, input.refName] : ["worktree", "add", worktreePath, input.refName]; @@ -2586,8 +2468,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackErrorDetail: "git worktree add failed", }); - yield* approveWorktreeEnvironment(worktreePath); - if (input.newRefName && input.baseRefName) { const remoteNames = yield* listRemoteNames(input.cwd).pipe(Effect.orElseSucceed(() => [])); const parsedBaseRef = parseRemoteRefWithRemoteNames( @@ -2711,7 +2591,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } args.push(input.path); yield* executeGit("GitVcsDriver.removeWorktree", input.cwd, args, { - timeoutMs: 30_000, + timeoutMs: 15_000, fallbackErrorDetail: "git worktree remove failed", }); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 2c810715ce0..9b4cbf2b4a4 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -24,6 +24,7 @@ import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches"; import { usePaginatedBranches } from "../state/queries"; import { useProject, useThread } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; @@ -35,6 +36,7 @@ import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; import { deriveLocalBranchNameFromRemoteRef, + resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, resolveBranchToolbarValue, @@ -73,8 +75,6 @@ interface BranchToolbarBranchSelectorProps { onActiveThreadBranchOverrideChange?: (refName: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; - reuseBaseBranch: boolean; - onReuseBaseBranchChange: (reuseBaseBranch: boolean) => void; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; } @@ -83,40 +83,6 @@ function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } -function getBranchTriggerLabel(input: { - activeWorktreePath: string | null; - effectiveEnvMode: "local" | "worktree"; - resolvedActiveBranch: string | null; - resolvedActiveBranchIsRemote: boolean | null; - startFromOrigin: boolean; - reuseBaseBranch: boolean; -}): string { - const { - activeWorktreePath, - effectiveEnvMode, - resolvedActiveBranch, - resolvedActiveBranchIsRemote, - startFromOrigin, - reuseBaseBranch, - } = input; - if (!resolvedActiveBranch) { - return "Select ref"; - } - // Reused base branch is checked out as-is (Tim #15); otherwise "From X" for - // new worktree branches, with optional origin/ prefix (upstream #4680). - if (effectiveEnvMode === "worktree" && !activeWorktreePath) { - if (reuseBaseBranch) { - return resolvedActiveBranch; - } - const baseRef = - startFromOrigin && resolvedActiveBranchIsRemote === false - ? `origin/${resolvedActiveBranch}` - : resolvedActiveBranch; - return `From ${baseRef}`; - } - return resolvedActiveBranch; -} - export function BranchToolbarBranchSelector({ className, environmentId, @@ -128,13 +94,10 @@ export function BranchToolbarBranchSelector({ onActiveThreadBranchOverrideChange, startFromOrigin, onStartFromOriginChange, - reuseBaseBranch, - onReuseBaseBranchChange, onCheckoutPullRequestRequest, onComposerFocusRequest, }: BranchToolbarBranchSelectorProps) { const startFromOriginSwitchId = useId(); - const reuseBaseBranchSwitchId = useId(); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop"); const updateThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, @@ -268,7 +231,7 @@ export function BranchToolbarBranchSelector({ const refs = branchRefState.refs; const hasNextPage = branchRefState.data?.nextCursor !== null && branchRefState.data?.nextCursor !== undefined; - const isFetchingNextPage = branchRefState.isPending && branchRefState.data !== null; + const isFetchingNextPage = branchRefState.isFetchingNextPage; const isInitialBranchesLoadPending = branchRefState.isPending && branchRefState.data === null; const currentGitBranch = branchStatusQuery.data?.refName ?? refs.find((refName) => refName.current)?.name ?? null; @@ -543,19 +506,16 @@ export function BranchToolbarBranchSelector({ // --------------------------------------------------------------------------- // Combobox / list plumbing // --------------------------------------------------------------------------- - const handleOpenChange = useCallback( - (open: boolean) => { - setIsBranchMenuOpen(open); - if (!open) { - setBranchQuery(""); - return; - } - branchRefState.refresh(); - }, - [branchRefState.refresh], - ); - const branchListScrollElementRef = useRef(null); + const previousBranchListScrollTopRef = useRef(null); + const handleOpenChange = useCallback((open: boolean) => { + previousBranchListScrollTopRef.current = null; + setIsBranchMenuOpen(open); + if (!open) { + setBranchQuery(""); + } + }, []); + const [showTopBranchScrollFade, setShowTopBranchScrollFade] = useState(false); const [showBottomBranchScrollFade, setShowBottomBranchScrollFade] = useState(false); const fetchNextBranchPage = useCallback(() => { @@ -566,18 +526,24 @@ export function BranchToolbarBranchSelector({ branchRefState.loadNext(); }, [branchRefState.loadNext, hasNextPage, isFetchingNextPage]); const maybeFetchNextBranchPage = useCallback(() => { - if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { - return; - } - const scrollElement = branchListScrollElementRef.current; if (!scrollElement) { return; } - const distanceFromBottom = - scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; - if (distanceFromBottom > 96) { + const previousScrollTop = previousBranchListScrollTopRef.current; + previousBranchListScrollTopRef.current = scrollElement.scrollTop; + if ( + !isBranchMenuOpen || + !hasNextPage || + isFetchingNextPage || + !shouldLoadNextBranchPageAfterScroll({ + previousScrollTop, + scrollTop: scrollElement.scrollTop, + scrollHeight: scrollElement.scrollHeight, + clientHeight: scrollElement.clientHeight, + }) + ) { return; } @@ -627,17 +593,12 @@ export function BranchToolbarBranchSelector({ void branchListRef.current?.scrollToOffset?.({ offset: 0, animated: false }); }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); - useEffect(() => { - maybeFetchNextBranchPage(); - }, [refs.length, maybeFetchNextBranchPage]); - - const triggerLabel = getBranchTriggerLabel({ + const triggerLabel = resolveBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, resolvedActiveBranch, resolvedActiveBranchIsRemote, startFromOrigin, - reuseBaseBranch, }); // PR pill shown next to the branch selector when the active branch has one. @@ -784,7 +745,7 @@ export function BranchToolbarBranchSelector({ > } - className="min-w-0 max-w-full shrink text-muted-foreground/70 hover:text-foreground/80" + className="min-w-0 max-w-full text-muted-foreground/70 hover:text-foreground/80" disabled={isInitialBranchesLoadPending || isBranchActionPending} > @@ -830,14 +791,10 @@ export function BranchToolbarBranchSelector({ renderItem={({ item, index }) => renderPickerItem(item, index)} estimatedItemSize={28} drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} onLayout={() => { updateBranchListScrollFades(); - maybeFetchNextBranchPage(); + previousBranchListScrollTopRef.current = + branchListScrollElementRef.current?.scrollTop ?? null; }} onScroll={() => { updateBranchListScrollFades(); @@ -853,65 +810,32 @@ export function BranchToolbarBranchSelector({ {isSelectingWorktreeBase ? ( -
- - - - - onReuseBaseBranchChange(Boolean(checked))} - /> - - } - /> - - Checks out the selected branch in the worktree instead of creating a new branch - from it. - - - - - - - onStartFromOriginChange(Boolean(checked))} - /> - - } - /> - - {reuseBaseBranch - ? "Not available when reusing the selected branch." - : "Creates the worktree from the latest matching branch on origin instead of your local branch."} - - -
+ + + + + onStartFromOriginChange(Boolean(checked))} + /> + + } + /> + + Creates the worktree from the latest matching branch on origin instead of your local + branch. + + ) : null} {branchStatusText ? {branchStatusText} : null} diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 83b100404da..a0d4510be7f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -6,32 +6,36 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; -import { Atom } from "effect/unstable/reactivity"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { - createEnvironmentRpcCommand, - createEnvironmentRpcQueryAtomFamily, - createEnvironmentSubscriptionAtomFamily, -} from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { followStreamInEnvironment } from "./runtime.ts"; +import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { - vcsCommandConcurrency, - vcsCommandScheduler, - vcsThreadCommandConcurrency, -} from "./vcsCommandScheduler.ts"; + invalidateCachedVcsRefs, + vcsRefsCacheStateAtom, + withVcsRefsPersistenceLock, +} from "./vcsRefInvalidation.ts"; const OFFLINE_BRANCH_LIST_LIMIT = 100; -const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; +const VCS_REFS_IDLE_TTL_MS = 30_000; +const VCS_REFS_RETRY_SCHEDULE = Schedule.exponential("1 second").pipe( + Schedule.modifyDelay(({ duration }) => + Effect.succeed(Duration.min(duration, Duration.seconds(30))), + ), +); function canUseVcsRefsCache(input: VcsListRefsInput): boolean { return ( @@ -43,6 +47,66 @@ function canUseVcsRefsCache(input: VcsListRefsInput): boolean { ); } +export const commitVcsRefsRefresh = Effect.fn("CachedVcsRefsState.commitRefresh")(function* ( + registry: AtomRegistry.AtomRegistry, + cache: EnvironmentCacheStore["Service"], + input: { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly refs: VcsListRefsResult; + readonly expectedRevision: number; + readonly persist: boolean; + }, +) { + return yield* withVcsRefsPersistenceLock( + input.environmentId, + Effect.gen(function* () { + const stateAtom = vcsRefsCacheStateAtom({ environmentId: input.environmentId }); + const state = registry.get(stateAtom); + if (state.revision !== input.expectedRevision) { + return false; + } + let persistedCacheReadable = state.persistedCacheReadable; + if (input.persist) { + if (!persistedCacheReadable) { + persistedCacheReadable = yield* cache.clearVcsRefs(input.environmentId).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("Could not recover invalidated cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId: input.environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(false), + ), + ), + ); + } + yield* cache.saveVcsRefs(input.environmentId, input.cwd, input.refs).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId: input.environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + if (persistedCacheReadable !== state.persistedCacheReadable) { + registry.update(stateAtom, (current) => + current.revision === input.expectedRevision + ? { ...current, persistedCacheReadable } + : current, + ); + } + } + return true; + }), + ); +}); + /** * Retains the last unfiltered branch-list response for the new-task picker. * Filtered or paginated lists intentionally stay live-only: treating a @@ -51,54 +115,59 @@ function canUseVcsRefsCache(input: VcsListRefsInput): boolean { */ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChanges")(function* ( input: VcsListRefsInput, + expectedRevision?: number, + registry?: AtomRegistry.AtomRegistry, + persistedCacheReadable = true, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const environmentId = supervisor.target.environmentId; const useCache = canUseVcsRefsCache(input); - const cached = useCache - ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe( - Effect.catch((error) => - Effect.logWarning("Could not load cached Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - Effect.as(Option.none()), + const cached = + useCache && persistedCacheReadable + ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), ), - ), - ) - : Option.none(); + ) + : Option.none(); const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), ); - if (useCache) { - yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( - Effect.catch((error) => - Effect.logWarning("Could not persist cached Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - ), + const persist = cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), ), - ); + ), + ); + if (expectedRevision === undefined || registry === undefined) { + if (useCache) yield* persist; + return Option.some(refs); } - return refs; + const committed = yield* commitVcsRefsRefresh(registry, cache, { + environmentId, + cwd: input.cwd, + refs, + expectedRevision, + persist: useCache, + }); + return committed ? Option.some(refs) : Option.none(); }); - const cachedRefs = Stream.fromEffect( - SubscriptionRef.get(supervisor.state).pipe( - Effect.flatMap((connection) => - connection.phase === "connected" - ? Effect.succeed(Option.none()) - : Effect.succeed(cached), - ), - ), - ).pipe( + const cachedRefs = Stream.fromEffect(Effect.succeed(cached)).pipe( Stream.filterMap((refs) => Option.match(refs, { onNone: () => Result.failVoid, @@ -115,24 +184,20 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange Stream.switchMap((generation) => generation === null ? Stream.empty - : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( - Stream.mapEffect( - () => - refresh().pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.logWarning("Could not refresh Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - Effect.as(Option.none()), - ), - ), + : Stream.fromEffect( + refresh().pipe( + Effect.tapError((error) => + Effect.logWarning("Could not refresh Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), ), - { concurrency: 1 }, + ), ), + ).pipe( + Stream.retry(VCS_REFS_RETRY_SCHEDULE), Stream.filterMap((refs) => Option.match(refs, { onNone: () => Result.failVoid, @@ -146,8 +211,26 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return Stream.concat(cachedRefs, refreshedRefs); }); -export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { - return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input))); +export function cachedVcsRefsChanges( + environmentId: EnvironmentId, + input: VcsListRefsInput, + expectedRevision: number, + persistedCacheReadable: boolean, +) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + Effect.gen(function* () { + const registry = yield* AtomRegistry.AtomRegistry; + return yield* makeCachedVcsRefsChanges( + input, + expectedRevision, + registry, + persistedCacheReadable, + ); + }), + ), + ); } export function createVcsEnvironmentAtoms( @@ -157,9 +240,17 @@ export function createVcsEnvironmentAtoms( Atom.family((inputKey: string) => { const input = JSON.parse(inputKey) as VcsListRefsInput; return runtime - .atom(cachedVcsRefsChanges(environmentId, input)) + .atom((get) => { + const state = get(vcsRefsCacheStateAtom({ environmentId })); + return cachedVcsRefsChanges( + environmentId, + input, + state.revision, + state.persistedCacheReadable, + ); + }) .pipe( - Atom.setIdleTTL(5 * 60_000), + Atom.setIdleTTL(VCS_REFS_IDLE_TTL_MS), Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), ); }), @@ -168,14 +259,17 @@ export function createVcsEnvironmentAtoms( readonly environmentId: EnvironmentId; readonly input: VcsListRefsInput; }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); + const invalidateRefs = ( + target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, + registry: AtomRegistry.AtomRegistry, + ) => + invalidateCachedVcsRefs(registry, { + environmentId: target.environmentId, + cwd: target.input.cwd, + }); return { listRefs, - resolveBranchChangeRequest: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:vcs:resolve-branch-change-request", - tag: WS_METHODS.vcsResolveBranchChangeRequest, - staleTimeMs: 60_000, - }), status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", subscribe: (input: EnvironmentRpcInput) => @@ -194,54 +288,49 @@ export function createVcsEnvironmentAtoms( tag: WS_METHODS.vcsPull, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), refreshStatus: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:refresh-status", tag: WS_METHODS.vcsRefreshStatus, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), createWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-worktree", tag: WS_METHODS.vcsCreateWorktree, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), removeWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:remove-worktree", tag: WS_METHODS.vcsRemoveWorktree, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, - }), - previewWorktreeCleanup: createEnvironmentRpcCommand(runtime, { - label: "environment-data:vcs:preview-worktree-cleanup", - tag: WS_METHODS.vcsPreviewWorktreeCleanup, - scheduler: vcsCommandScheduler, - concurrency: vcsThreadCommandConcurrency, - }), - cleanupThreadWorktree: createEnvironmentRpcCommand(runtime, { - label: "environment-data:vcs:cleanup-thread-worktree", - tag: WS_METHODS.vcsCleanupThreadWorktree, - scheduler: vcsCommandScheduler, - concurrency: vcsThreadCommandConcurrency, + onSettled: invalidateRefs, }), createRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-ref", tag: WS_METHODS.vcsCreateRef, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), switchRef: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:switch-ref", tag: WS_METHODS.vcsSwitchRef, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), init: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:init", tag: WS_METHODS.vcsInit, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, }), }; } diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index c8faa38ca83..b936246dc82 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -34,7 +34,6 @@ import { createVcsActionTransportId, EMPTY_VCS_ACTION_STATE, getVcsActionTargetKey, - isCommitSigningFailure, normalizeVcsActionProgressEvent, parseVcsActionTargetKey, VcsActionMissingTerminalEventError, @@ -259,7 +258,6 @@ describe("vcsActionState", () => { kind: "action_failed", phase: null, message: "Push failed.", - failureKind: "unknown", }), ); @@ -400,7 +398,6 @@ describe("vcsActionState", () => { kind: "action_failed", phase: "push", message: remoteMessage, - failureKind: "unknown", }, ]), { @@ -420,7 +417,6 @@ describe("vcsActionState", () => { environmentId, cwd, phase: "push", - failureKind: "unknown", remoteMessageLength: remoteMessage.length, }); expect(error).not.toHaveProperty("detail"); @@ -429,55 +425,6 @@ describe("vcsActionState", () => { }), ); - it.effect("prefers a classified terminal failure over a following stream error", () => - Effect.gen(function* () { - const target = { environmentId, cwd }; - const transportActionId = createVcsActionTransportId(target, actionId); - const transportError = new Error("rpc stream closed"); - const stream = Stream.fromIterable([ - { - actionId: transportActionId, - action, - cwd, - kind: "action_failed", - phase: "commit", - message: "Remote diagnostic that must stay sanitized.", - failureKind: "commit_signing_failed", - }, - ]).pipe(Stream.concat(Stream.fail(transportError))); - - const error = yield* consumeVcsActionProgress(stream, { - target, - transportActionId, - actionId, - action, - onProgress: () => Effect.void, - }).pipe(Effect.flip); - - expect(error).toBeInstanceOf(VcsActionRemoteFailureError); - expect(error).toMatchObject({ failureKind: "commit_signing_failed" }); - expect(isCommitSigningFailure(error)).toBe(true); - }), - ); - - it.effect("preserves a stream error when no terminal failure was received", () => - Effect.gen(function* () { - const target = { environmentId, cwd }; - const transportActionId = createVcsActionTransportId(target, actionId); - const transportError = new Error("rpc stream closed"); - - const error = yield* consumeVcsActionProgress(Stream.fail(transportError), { - target, - transportActionId, - actionId, - action, - onProgress: () => Effect.void, - }).pipe(Effect.flip); - - expect(error).toBe(transportError); - }), - ); - it.effect("reports a missing terminal event as a protocol failure", () => Effect.gen(function* () { const target = { environmentId, cwd }; diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index e0cbf7338f4..f0c3791e35b 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -2,7 +2,6 @@ import { EnvironmentId, type EnvironmentId as EnvironmentIdType, GitActionProgressPhase, - GitActionFailureKind, type GitActionProgressEvent, type GitRunStackedActionInput, type GitRunStackedActionResult, @@ -76,7 +75,6 @@ export interface RunVcsStackedActionInput { readonly action: GitStackedAction; readonly commitMessage?: string; readonly featureBranch?: boolean; - readonly disableCommitSigning?: boolean; readonly filePaths?: ReadonlyArray; readonly onProgress?: (event: GitActionProgressEvent) => void; } @@ -103,7 +101,6 @@ export class VcsActionRemoteFailureError extends Schema.TaggedErrorClass({ isRunning: false, operation: null, @@ -276,19 +265,6 @@ export function consumeVcsActionProgress( ): Effect.Effect { return Effect.suspend(() => { let terminalEvent: GitActionProgressEvent | null = null; - const remoteFailure = ( - event: Extract, - ): VcsActionRemoteFailureError => - new VcsActionRemoteFailureError({ - actionId: input.actionId, - transportActionId: input.transportActionId, - action: event.action, - environmentId: input.target.environmentId, - cwd: input.target.cwd, - phase: event.phase, - failureKind: event.failureKind, - remoteMessageLength: event.message.length, - }); return stream.pipe( Stream.runForEach((event) => { const normalized = normalizeVcsActionProgressEvent( @@ -305,18 +281,22 @@ export function consumeVcsActionProgress( } return input.onProgress(normalized); }), - Effect.catch((error) => { - const terminal = terminalEvent; - const failure: E | VcsActionRemoteFailureError = - terminal?.kind === "action_failed" ? remoteFailure(terminal) : error; - return Effect.fail(failure); - }), Effect.flatMap(() => { if (terminalEvent?.kind === "action_finished") { return Effect.succeed(terminalEvent.result); } if (terminalEvent?.kind === "action_failed") { - return Effect.fail(remoteFailure(terminalEvent)); + return Effect.fail( + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.transportActionId, + action: terminalEvent.action, + environmentId: input.target.environmentId, + cwd: input.target.cwd, + phase: terminalEvent.phase, + remoteMessageLength: terminalEvent.message.length, + }), + ); } return Effect.fail( new VcsActionMissingTerminalEventError({ @@ -482,7 +462,6 @@ export function createVcsActionManager( action: input.action, ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), - ...(input.disableCommitSigning ? { disableCommitSigning: true } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), }; return consumeVcsActionProgress( diff --git a/packages/client-runtime/src/state/vcsCommandScheduler.ts b/packages/client-runtime/src/state/vcsCommandScheduler.ts index d7b508709b3..a11b157bb2d 100644 --- a/packages/client-runtime/src/state/vcsCommandScheduler.ts +++ b/packages/client-runtime/src/state/vcsCommandScheduler.ts @@ -11,11 +11,3 @@ export const vcsCommandConcurrency: AtomCommandConcurrency<{ mode: "serial", key: ({ environmentId, input }) => JSON.stringify([environmentId, input.cwd]), }; - -export const vcsThreadCommandConcurrency: AtomCommandConcurrency<{ - readonly environmentId: EnvironmentId; - readonly input: { readonly threadId: string }; -}> = { - mode: "serial", - key: ({ environmentId, input }) => JSON.stringify([environmentId, input.threadId]), -};