diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml index 1d5a299308..56f49a9511 100644 --- a/.github/workflows/pr-size.yml +++ b/.github/workflows/pr-size.yml @@ -24,32 +24,32 @@ jobs: { name: "size:XS", color: "0e8a16", - description: "0-9 changed lines (additions + deletions).", + description: "0-9 effective changed lines (test files excluded in mixed PRs).", }, { name: "size:S", color: "5ebd3e", - description: "10-29 changed lines (additions + deletions).", + description: "10-29 effective changed lines (test files excluded in mixed PRs).", }, { name: "size:M", color: "fbca04", - description: "30-99 changed lines (additions + deletions).", + description: "30-99 effective changed lines (test files excluded in mixed PRs).", }, { name: "size:L", color: "fe7d37", - description: "100-499 changed lines (additions + deletions).", + description: "100-499 effective changed lines (test files excluded in mixed PRs).", }, { name: "size:XL", color: "d93f0b", - description: "500-999 changed lines (additions + deletions).", + description: "500-999 effective changed lines (test files excluded in mixed PRs).", }, { name: "size:XXL", color: "b60205", - description: "1,000+ changed lines (additions + deletions).", + description: "1,000+ effective changed lines (test files excluded in mixed PRs).", }, ]; @@ -131,12 +131,18 @@ jobs: with: script: | const issueNumber = context.payload.pull_request.number; - const additions = context.payload.pull_request.additions ?? 0; - const deletions = context.payload.pull_request.deletions ?? 0; - const changedLines = additions + deletions; const managedLabels = JSON.parse(process.env.PR_SIZE_LABELS_JSON ?? "[]"); - const managedLabelNames = new Set(managedLabels.map((label) => label.name)); + // Keep this aligned with the repo's test entrypoints and test-only support files. + const testFilePatterns = [ + /(^|\/)__tests__(\/|$)/, + /(^|\/)tests?(\/|$)/, + /^apps\/server\/integration\//, + /\.(test|spec|browser|integration)\.[^.\/]+$/, + ]; + + const isTestFile = (filename) => + testFilePatterns.some((pattern) => pattern.test(filename)); const resolveSizeLabel = (totalChangedLines) => { if (totalChangedLines < 10) { @@ -162,6 +168,42 @@ jobs: return "size:XXL"; }; + const files = await github.paginate( + github.rest.pulls.listFiles, + { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: issueNumber, + per_page: 100, + }, + (response) => response.data, + ); + + if (files.length >= 3000) { + core.warning( + "The GitHub pull request files API may truncate results at 3,000 files; PR size may be undercounted.", + ); + } + + let testChangedLines = 0; + let nonTestChangedLines = 0; + + for (const file of files) { + const changedLinesForFile = (file.additions ?? 0) + (file.deletions ?? 0); + + if (changedLinesForFile === 0) { + continue; + } + + if (isTestFile(file.filename)) { + testChangedLines += changedLinesForFile; + continue; + } + + nonTestChangedLines += changedLinesForFile; + } + + const changedLines = nonTestChangedLines === 0 ? testChangedLines : nonTestChangedLines; const nextLabelName = resolveSizeLabel(changedLines); const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ @@ -199,4 +241,15 @@ jobs: }); } - core.info(`PR #${issueNumber}: ${changedLines} changed lines -> ${nextLabelName}`); + const classification = + nonTestChangedLines === 0 + ? testChangedLines > 0 + ? "test-only PR" + : "no line changes" + : testChangedLines > 0 + ? "test lines excluded" + : "all non-test changes"; + + core.info( + `PR #${issueNumber}: ${nonTestChangedLines} non-test lines, ${testChangedLines} test lines, ${changedLines} effective lines -> ${nextLabelName} (${classification})`, + ); diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 19f34b49a0..f540685b79 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -24,6 +24,7 @@ import { import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; +import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; @@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(AnalyticsService.layerTest), ); + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, OrchestrationProjectionSnapshotQueryLive, ProjectionCheckpointRepositoryLive, ProjectionPendingApprovalRepositoryLive, - CheckpointStoreLive, + checkpointStoreLayer, providerLayer, RuntimeReceiptBusLive, ); diff --git a/apps/server/package.json b/apps/server/package.json index bf2011b4ce..3cf53d81be 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,7 @@ { "name": "t3", "version": "0.0.13", + "license": "MIT", "repository": { "type": "git", "url": "https://github.com/pingdotgg/t3code", diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index fac183ff7a..b20204780c 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { GitCommandError } from "../../git/Errors.ts"; -import { GitServiceLive } from "../../git/Layers/GitService.ts"; -import { GitService } from "../../git/Services/GitService.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const git = yield* GitService; + const git = yield* GitCore; const resolveHeadCommit = (cwd: string): Effect.Effect => git @@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () { } satisfies CheckpointStoreShape; }); -export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe( - Layer.provideMerge(GitServiceLive), -); +export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 6d50cd2504..635b9e8bc4 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -6,9 +6,7 @@ import { it } from "@effect/vitest"; import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; import { describe, expect, vi } from "vitest"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService, type GitServiceShape } from "../Services/GitService.ts"; -import { GitCoreLive } from "./GitCore.ts"; +import { GitCoreLive, makeGitCore } from "./GitCore.ts"; import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; import { GitCommandError } from "../Errors.ts"; import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; @@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts"; // ── Helpers ── -const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); const GitCoreTestLayer = GitCoreLive.pipe( - Layer.provide(GitServiceTestLayer), Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); function makeTmpDir( prefix = "git-test-", @@ -49,10 +45,10 @@ function git( cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ + const gitCore = yield* GitCore; + const result = yield* gitCore.execute({ operation: "GitCore.test.git", cwd, args, @@ -88,102 +84,10 @@ function runShellCommand(input: { }); } -const makeIsolatedGitCore = (gitService: GitServiceShape) => - Effect.promise(async () => { - const gitServiceLayer = Layer.succeed(GitService, gitService); - const coreLayer = GitCoreLive.pipe( - Layer.provide(gitServiceLayer), - Layer.provide(ServerConfigLayer), - Layer.provide(NodeServices.layer), - ); - const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); - - return { - status: (input) => core.status(input), - statusDetails: (cwd) => core.statusDetails(cwd), - prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), - commit: (cwd, subject, body) => core.commit(cwd, subject, body), - pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), - pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), - readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), - readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), - listBranches: (input) => core.listBranches(input), - createWorktree: (input) => core.createWorktree(input), - fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), - ensureRemote: (input) => core.ensureRemote(input), - fetchRemoteBranch: (input) => core.fetchRemoteBranch(input), - setBranchUpstream: (input) => core.setBranchUpstream(input), - removeWorktree: (input) => core.removeWorktree(input), - renameBranch: (input) => core.renameBranch(input), - createBranch: (input) => core.createBranch(input), - checkoutBranch: (input) => core.checkoutBranch(input), - initRepo: (input) => core.initRepo(input), - listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), - } satisfies GitCoreShape; - }); - -function listGitBranches(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.listBranches(input); - }); -} - -function initGitRepo(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.initRepo(input); - }); -} - -function createGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.createBranch(input); - }); -} - -function checkoutGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.checkoutBranch(input); - }); -} - -function createGitWorktree(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.createWorktree(input); - }); -} - -function fetchGitPullRequestBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.fetchPullRequestBranch(input); - }); -} - -function removeGitWorktree(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.removeWorktree(input); - }); -} - -function renameGitBranch(input: Parameters[0]) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.renameBranch(input); - }); -} - -function pullGitBranch({ cwd }: { cwd: string }) { - return Effect.gen(function* () { - const core = yield* GitCore; - return yield* core.pullCurrentBranch(cwd); - }); -} +const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => + makeGitCore({ executeOverride }).pipe( + Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), + ); /** Create a repo with an initial commit so branches work. */ function initRepoWithCommit( @@ -191,10 +95,11 @@ function initRepoWithCommit( ): Effect.Effect< { initialBranch: string }, GitCommandError | PlatformError.PlatformError, - GitCore | GitService | FileSystem.FileSystem + GitCore | FileSystem.FileSystem > { return Effect.gen(function* () { - yield* initGitRepo({ cwd }); + const core = yield* GitCore; + yield* core.initRepo({ cwd }); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); @@ -214,7 +119,7 @@ function commitWithDate( ): Effect.Effect< void, GitCommandError | PlatformError.PlatformError, - GitService | FileSystem.FileSystem + GitCore | FileSystem.FileSystem > { return Effect.gen(function* () { yield* writeTextFile(path.join(cwd, fileName), fileContents); @@ -253,7 +158,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("creates a valid git repo", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - yield* initGitRepo({ cwd: tmp }); + yield* (yield* GitCore).initRepo({ cwd: tmp }); expect(existsSync(path.join(tmp, ".git"))).toBe(true); }), ); @@ -262,7 +167,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(true); expect(result.hasOriginRemote).toBe(false); expect(result.branches.length).toBeGreaterThanOrEqual(1); @@ -276,7 +181,7 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("returns isRepo: false for non-git directory", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.isRepo).toBe(false); expect(result.hasOriginRemote).toBe(false); expect(result.branches).toEqual([]); @@ -287,7 +192,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current).toBeDefined(); expect(current!.current).toBe(true); @@ -300,7 +205,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); yield* git(tmp, ["checkout", "--detach", "HEAD"]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); expect(result.branches.some((branch) => branch.current)).toBe(false); }), @@ -310,12 +215,12 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; - yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); yield* commitWithDate( tmp, "older.txt", @@ -324,9 +229,9 @@ it.layer(TestLayer)("git integration", (it) => { "older branch change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); - yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); yield* commitWithDate( tmp, "newer.txt", @@ -336,9 +241,9 @@ it.layer(TestLayer)("git integration", (it) => { ); // Switch away to show current branch is pinned, then remaining branches are recency-sorted. - yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches[0]!.name).toBe("older-branch"); expect(result.branches[1]!.name).toBe("newer-branch"); }), @@ -349,7 +254,7 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); const remote = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; @@ -358,8 +263,8 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["push", "-u", "origin", defaultBranch]); yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); - yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); yield* commitWithDate( tmp, "current.txt", @@ -368,9 +273,9 @@ it.layer(TestLayer)("git integration", (it) => { "current change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); - yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); yield* commitWithDate( tmp, "newer.txt", @@ -379,9 +284,9 @@ it.layer(TestLayer)("git integration", (it) => { "newer change", ); - yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches[0]!.name).toBe("current-branch"); expect(result.branches[1]!.name).toBe(defaultBranch); expect(result.branches[2]!.name).toBe("newer-branch"); @@ -392,10 +297,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); - yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const names = result.branches.map((b) => b.name); expect(names).toContain("feature-a"); expect(names).toContain("feature-b"); @@ -406,7 +311,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }), ); @@ -418,23 +323,23 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", remote]); yield* git(tmp, ["push", "-u", "origin", defaultBranch]); - yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); const remoteOnlyBranch = "feature/remote-only"; - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); yield* git(tmp, ["checkout", defaultBranch]); yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); expect(result.hasOriginRemote).toBe(true); @@ -466,7 +371,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; @@ -479,7 +384,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["checkout", defaultBranch]); yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const remoteBranch = result.branches.find( (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, ); @@ -498,11 +403,11 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature"); }), @@ -516,20 +421,20 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); const featureBranch = "feature-behind"; - yield* createGitBranch({ cwd: source, branch: featureBranch }); - yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); yield* git(source, ["add", "feature.txt"]); yield* git(source, ["commit", "-m", "feature base"]); yield* git(source, ["push", "-u", "origin", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); yield* git(clone, ["clone", remote, "."]); yield* git(clone, ["config", "user.email", "test@test.com"]); @@ -540,7 +445,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(clone, ["commit", "-m", "remote feature update"]); yield* git(clone, ["push", "origin", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); const core = yield* GitCore; yield* Effect.promise(() => vi.waitFor(async () => { @@ -560,7 +465,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -575,23 +480,21 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let refreshFetchAttempts = 0; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (input.args[0] === "fetch") { - refreshFetchAttempts += 1; - return Effect.fail( - new GitCommandError({ - operation: "git.test.refreshFailure", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "simulated fetch timeout", - }), - ); - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "fetch") { + refreshFetchAttempts += 1; + return Effect.fail( + new GitCommandError({ + operation: "git.test.refreshFailure", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), + ); + } + return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => @@ -610,7 +513,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -624,16 +527,14 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let fetchArgs: readonly string[] | null = null; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (input.args[0] === "fetch") { - fetchArgs = [...input.args]; - return Effect.succeed({ code: 0, stdout: "", stderr: "" }); - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "fetch") { + fetchArgs = [...input.args]; + return Effect.succeed({ code: 0, stdout: "", stderr: "" }); + } + return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => @@ -660,7 +561,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -674,22 +575,20 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["push", "-u", "origin", featureBranch]); yield* git(source, ["checkout", defaultBranch]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let fetchStarted = false; let releaseFetch!: () => void; const waitForReleasePromise = new Promise((resolve) => { releaseFetch = resolve; }); - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (input.args[0] === "fetch") { - fetchStarted = true; - return Effect.promise(() => - waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), - ); - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "fetch") { + fetchStarted = true; + return Effect.promise(() => + waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), + ); + } + return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); yield* Effect.promise(() => @@ -706,7 +605,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); + const result = yield* Effect.result( + (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), + ); expect(result._tag).toBe("Failure"); }), ); @@ -718,16 +619,16 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); - yield* createGitBranch({ cwd: source, branch: "feature" }); + yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); const checkoutResult = yield* Effect.result( - checkoutGitBranch({ cwd: source, branch: "origin/feature" }), + (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), ); expect(checkoutResult._tag).toBe("Failure"); expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); @@ -743,7 +644,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", remoteName, remote]); @@ -757,7 +658,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); yield* git(source, ["branch", "-D", featureBranch]); - yield* checkoutGitBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` }); + yield* (yield* GitCore).checkoutBranch({ + cwd: source, + branch: `${remoteName}/${featureBranch}`, + }); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); }), @@ -772,9 +676,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const defaultBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", defaultBranch]); @@ -782,7 +686,10 @@ it.layer(TestLayer)("git integration", (it) => { // would attempt to create an already-existing local branch. yield* git(source, ["branch", "--unset-upstream"]); - yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` }); + yield* (yield* GitCore).checkoutBranch({ + cwd: source, + branch: `origin/${defaultBranch}`, + }); const core = yield* GitCore; const status = yield* core.statusDetails(source); @@ -794,7 +701,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); // Create a conflicting change: modify README on current branch yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); @@ -802,22 +709,24 @@ it.layer(TestLayer)("git integration", (it) => { // First, checkout other branch cleanly yield* git(tmp, ["stash"]); - yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); yield* git(tmp, ["add", "."]); yield* git(tmp, ["commit", "-m", "other change"]); // Go back to default branch - const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => !b.current, )!.name; - yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); // Make uncommitted changes to the same file yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); // Checkout should fail due to uncommitted changes - const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); + const result = yield* Effect.result( + (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), + ); expect(result._tag).toBe("Failure"); }), ); @@ -830,9 +739,9 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); }), ); @@ -841,8 +750,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "dupe" }); - const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); + const result = yield* Effect.result( + (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), + ); expect(result._tag).toBe("Failure"); }), ); @@ -855,10 +766,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "feature/old-name", newBranch: "feature/new-name", @@ -866,7 +777,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(renamed.branch).toBe("feature/new-name"); - const branches = yield* listGitBranches({ cwd: tmp }); + const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); const current = branches.branches.find((branch) => branch.current); expect(current?.name).toBe("feature/new-name"); @@ -877,9 +788,11 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; + const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( + (b) => b.current, + )!; - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: current.name, newBranch: current.name, @@ -893,18 +806,18 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "t3code/tmp-working", newBranch: "t3code/feat/session", }); expect(renamed.branch).toBe("t3code/feat/session-1"); - const branches = yield* listGitBranches({ cwd: tmp }); + const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( true, ); @@ -920,12 +833,12 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); - yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); - const renamed = yield* renameGitBranch({ + const renamed = yield* (yield* GitCore).renameBranch({ cwd: tmp, oldBranch: "t3code/tmp-working", newBranch: "t3code/feat/session", @@ -939,18 +852,16 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let renameArgs: ReadonlyArray | null = null; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (input.args[0] === "branch" && input.args[1] === "-m") { - renameArgs = [...input.args]; - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "branch" && input.args[1] === "-m") { + renameArgs = [...input.args]; + } + return realGitCore.execute(input); }); const renamed = yield* core.renameBranch({ @@ -974,11 +885,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "worktree-out"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-branch", @@ -991,7 +902,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); // Clean up worktree before tmp dir disposal - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1001,11 +912,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-check-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-check", @@ -1016,7 +927,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("wt-check"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1024,10 +935,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); const wtPath = path.join(tmp, "wt-existing"); - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: "feature/existing-worktree", path: wtPath, @@ -1038,7 +949,7 @@ it.layer(TestLayer)("git integration", (it) => { const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); expect(branchOutput).toBe("feature/existing-worktree"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1046,15 +957,15 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "existing" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); const wtPath = path.join(tmp, "wt-conflict"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; const result = yield* Effect.result( - createGitWorktree({ + (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "existing", @@ -1071,11 +982,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-list-dir"); - const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: mainBranch, newBranch: "wt-list", @@ -1083,17 +994,17 @@ it.layer(TestLayer)("git integration", (it) => { }); // listGitBranches from the worktree should show wt-list as current - const wtBranches = yield* listGitBranches({ cwd: wtPath }); + const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); expect(wtBranches.isRepo).toBe(true); const wtCurrent = wtBranches.branches.find((b) => b.current); expect(wtCurrent!.name).toBe("wt-list"); // Main repo should still show the original branch as current - const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(mainBranch); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); @@ -1103,11 +1014,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-remove-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-remove", @@ -1115,7 +1026,7 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(existsSync(wtPath)).toBe(true); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1126,11 +1037,11 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); const wtPath = path.join(tmp, "wt-dirty-dir"); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; - yield* createGitWorktree({ + yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "wt-dirty", @@ -1140,11 +1051,13 @@ it.layer(TestLayer)("git integration", (it) => { yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); - const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); + const failedRemove = yield* Effect.result( + (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), + ); expect(failedRemove._tag).toBe("Failure"); expect(existsSync(wtPath)).toBe(true); - yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); expect(existsSync(wtPath)).toBe(false); }), ); @@ -1157,10 +1070,10 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature-login"); }), @@ -1175,12 +1088,12 @@ it.layer(TestLayer)("git integration", (it) => { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (b) => b.current, )!.name; const wtPath = path.join(tmp, "my-worktree"); - const result = yield* createGitWorktree({ + const result = yield* (yield* GitCore).createWorktree({ cwd: tmp, branch: currentBranch, newBranch: "feature-wt", @@ -1191,7 +1104,7 @@ it.layer(TestLayer)("git integration", (it) => { expect(existsSync(result.worktree.path)).toBe(true); // Main repo still on original branch - const mainBranches = yield* listGitBranches({ cwd: tmp }); + const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const mainCurrent = mainBranches.branches.find((b) => b.current); expect(mainCurrent!.name).toBe(currentBranch); @@ -1199,7 +1112,7 @@ it.layer(TestLayer)("git integration", (it) => { const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); expect(wtBranch).toBe("feature-wt"); - yield* removeGitWorktree({ cwd: tmp, path: wtPath }); + yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); }), ); }); @@ -1221,7 +1134,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); yield* git(tmp, ["checkout", initialBranch]); - yield* fetchGitPullRequestBranch({ + yield* (yield* GitCore).fetchPullRequestBranch({ cwd: tmp, prNumber: 55, branch: "feature/pr-fetch", @@ -1242,22 +1155,22 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); - yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); // Simulate switching to thread A's branch - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); - let branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); // Simulate switching to thread B's branch - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); - branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); + branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); // Switch back to thread A - yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); - branches = yield* listGitBranches({ cwd: tmp }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); + branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); }), ); @@ -1270,30 +1183,30 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - yield* createGitBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); // Make diverged branch have different file content - yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); yield* git(tmp, ["add", "."]); yield* git(tmp, ["commit", "-m", "diverge"]); // Actually, let's just get back to the initial branch explicitly - const allBranches = yield* listGitBranches({ cwd: tmp }); + const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; - yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); // Make local uncommitted changes to the same file yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); // Attempt checkout should fail const failedCheckout = yield* Effect.result( - checkoutGitBranch({ cwd: tmp, branch: "diverged" }), + (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), ); expect(failedCheckout._tag).toBe("Failure"); // Current branch should still be the initial one - const result = yield* listGitBranches({ cwd: tmp }); + const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); }), ); @@ -1390,9 +1303,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", initialBranch]); yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); @@ -1423,9 +1336,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", remoteName, remote]); yield* git(source, ["push", "-u", remoteName, initialBranch]); yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); @@ -1526,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", remote]); @@ -1562,7 +1475,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(fork, ["init", "--bare"]); yield* initRepoWithCommit(tmp); - const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( (branch) => branch.current, )!.name; yield* git(tmp, ["remote", "add", "origin", origin]); @@ -1668,9 +1581,9 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( - (branch) => branch.current, - )!.name; + const initialBranch = (yield* (yield* GitCore).listBranches({ + cwd: source, + })).branches.find((branch) => branch.current)!.name; yield* git(source, ["remote", "add", "origin", remote]); yield* git(source, ["push", "-u", "origin", initialBranch]); @@ -1761,8 +1674,8 @@ it.layer(TestLayer)("git integration", (it) => { yield* initRepoWithCommit(tmp); yield* git(remote, ["init", "--bare"]); yield* git(tmp, ["remote", "add", "origin", remote]); - yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); - yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); + yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); const core = yield* GitCore; @@ -1790,7 +1703,7 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* initRepoWithCommit(source); - const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; yield* git(source, ["remote", "add", "origin", remote]); @@ -1818,7 +1731,7 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); + const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure.message.toLowerCase()).toContain("no upstream"); @@ -1830,23 +1743,21 @@ it.layer(TestLayer)("git integration", (it) => { Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let didFailRecency = false; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (!didFailRecency && input.args[0] === "for-each-ref") { - didFailRecency = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRecency", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "timeout", - }), - ); - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (!didFailRecency && input.args[0] === "for-each-ref") { + didFailRecency = true; + return Effect.fail( + new GitCommandError({ + operation: "git.test.listBranchesRecency", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "timeout", + }), + ); + } + return realGitCore.execute(input); }); const result = yield* core.listBranches({ cwd: tmp }); @@ -1866,35 +1777,33 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(remote, ["init", "--bare"]); yield* git(tmp, ["remote", "add", "origin", remote]); - const realGitService = yield* GitService; + const realGitCore = yield* GitCore; let didFailRemoteBranches = false; let didFailRemoteNames = false; - const core = yield* makeIsolatedGitCore({ - execute: (input) => { - if (input.args.join(" ") === "branch --no-color --remotes") { - didFailRemoteBranches = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteBranches", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - if (input.args.join(" ") === "remote") { - didFailRemoteNames = true; - return Effect.fail( - new GitCommandError({ - operation: "git.test.listBranchesRemoteNames", - command: `git ${input.args.join(" ")}`, - cwd: input.cwd, - detail: "remote unavailable", - }), - ); - } - return realGitService.execute(input); - }, + const core = yield* makeIsolatedGitCore((input) => { + if (input.args.join(" ") === "branch --no-color --remotes") { + didFailRemoteBranches = true; + return Effect.fail( + new GitCommandError({ + operation: "git.test.listBranchesRemoteBranches", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "remote unavailable", + }), + ); + } + if (input.args.join(" ") === "remote") { + didFailRemoteNames = true; + return Effect.fail( + new GitCommandError({ + operation: "git.test.listBranchesRemoteNames", + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "remote unavailable", + }), + ); + } + return realGitCore.execute(input); }); const result = yield* core.listBranches({ cwd: tmp }); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index edd05d4906..a95c1eac8c 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1,10 +1,29 @@ -import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { + Cache, + Data, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Schema, + Stream, +} from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError } from "../Errors.ts"; -import { GitService } from "../Services/GitService.ts"; -import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; +import { + GitCore, + type GitCoreShape, + type ExecuteGitInput, + type ExecuteGitResult, +} from "../Services/GitCore.ts"; import { ServerConfig } from "../../config.ts"; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; @@ -218,27 +237,146 @@ function createGitCommandError( }); } -const makeGitCore = Effect.gen(function* () { - const git = yield* GitService; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const { worktreesDir } = yield* ServerConfig; - - const executeGit = ( - operation: string, - cwd: string, - args: readonly string[], - options: ExecuteGitOptions = {}, - ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => - git - .execute({ +function quoteGitCommand(args: ReadonlyArray): string { + return `git ${args.join(" ")}`; +} + +function toGitCommandError( + input: Pick, + detail: string, +) { + return (cause: unknown) => + Schema.is(GitCommandError)(cause) + ? cause + : new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, + ...(cause !== undefined ? { cause } : {}), + }); +} + +const collectOutput = Effect.fn(function* ( + input: Pick, + stream: Stream.Stream, + maxOutputBytes: number, +): Effect.fn.Return { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new GitCommandError({ + operation: input.operation, + command: quoteGitCommand(input.args), + cwd: input.cwd, + detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, + }); + } + text += decoder.decode(chunk, { stream: true }); + }), + ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); + + text += decoder.decode(); + return text; +}); + +export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; + + let execute: GitCoreShape["execute"]; + + if (options?.executeOverride) { + execute = options.executeOverride; + } else { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + execute = Effect.fnUntraced(function* (input) { + const commandInput = { + ...input, + args: [...input.args], + } as const; + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + + const commandEffect = Effect.gen(function* () { + const child = yield* commandSpawner + .spawn( + ChildProcess.make("git", commandInput.args, { + cwd: commandInput.cwd, + ...(input.env ? { env: input.env } : {}), + }), + ) + .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectOutput(commandInput, child.stdout, maxOutputBytes), + collectOutput(commandInput, child.stderr, maxOutputBytes), + child.exitCode.pipe( + Effect.map((value) => Number(value)), + Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), + ), + ], + { concurrency: "unbounded" }, + ); + + if (!input.allowNonZeroExit && exitCode !== 0) { + const trimmedStderr = stderr.trim(); + return yield* new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: + trimmedStderr.length > 0 + ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` + : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, + }); + } + + return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + }); + + return yield* commandEffect.pipe( + Effect.scoped, + Effect.timeoutOption(timeoutMs), + Effect.flatMap((result) => + Option.match(result, { + onNone: () => + Effect.fail( + new GitCommandError({ + operation: commandInput.operation, + command: quoteGitCommand(commandInput.args), + cwd: commandInput.cwd, + detail: `${quoteGitCommand(commandInput.args)} timed out.`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); + }); + } + + const executeGit = ( + operation: string, + cwd: string, + args: readonly string[], + options: ExecuteGitOptions = {}, + ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => + execute({ operation, cwd, args, allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - }) - .pipe( + }).pipe( Effect.flatMap((result) => { if (options.allowNonZeroExit || result.code === 0) { return Effect.succeed(result); @@ -263,1198 +401,1202 @@ const makeGitCore = Effect.gen(function* () { }), ); - const runGit = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); - - const runGitStdout = ( - operation: string, - cwd: string, - args: readonly string[], - allowNonZeroExit = false, - ): Effect.Effect => - executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( - Effect.map((result) => result.stdout), - ); - - const branchExists = (cwd: string, branch: string): Effect.Effect => - executeGit( - "GitCore.branchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], - { - allowNonZeroExit: true, - timeoutMs: 5_000, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const resolveAvailableBranchName = ( - cwd: string, - desiredBranch: string, - ): Effect.Effect => - Effect.gen(function* () { - const isDesiredTaken = yield* branchExists(cwd, desiredBranch); - if (!isDesiredTaken) { - return desiredBranch; - } - - for (let suffix = 1; suffix <= 100; suffix += 1) { - const candidate = `${desiredBranch}-${suffix}`; - const isCandidateTaken = yield* branchExists(cwd, candidate); - if (!isCandidateTaken) { - return candidate; - } - } - - return yield* createGitCommandError( - "GitCore.renameBranch", - cwd, - ["branch", "-m", "--", desiredBranch], - `Could not find an available branch name for '${desiredBranch}'.`, + const runGit = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + + const runGitStdout = ( + operation: string, + cwd: string, + args: readonly string[], + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( + Effect.map((result) => result.stdout), ); - }); - - const resolveCurrentUpstream = ( - cwd: string, - ): Effect.Effect< - { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, - GitCommandError - > => - Effect.gen(function* () { - const upstreamRef = yield* runGitStdout( - "GitCore.resolveCurrentUpstream", - cwd, - ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return null; - } - - // Resolve the remote name from branch config to handle remotes whose - // names contain `/` (e.g. `my-org/upstream`). Splitting on the first - // `/` would incorrectly truncate such names. - const branch = yield* runGitStdout( - "GitCore.resolveCurrentUpstream.branch", + const branchExists = (cwd: string, branch: string): Effect.Effect => + executeGit( + "GitCore.branchExists", cwd, - ["rev-parse", "--abbrev-ref", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const remoteName = - branch.length > 0 - ? yield* runGitStdout( - "GitCore.resolveCurrentUpstream.remote", - cwd, - ["config", "--get", `branch.${branch}.remote`], - true, - ).pipe( - Effect.map((stdout) => stdout.trim()), - Effect.catch(() => Effect.succeed("")), - ) - : ""; + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.code === 0)); - if (remoteName.length > 0 && upstreamRef.startsWith(`${remoteName}/`)) { - const upstreamBranch = upstreamRef.slice(remoteName.length + 1); - if (upstreamBranch.length === 0) { - return null; + const resolveAvailableBranchName = ( + cwd: string, + desiredBranch: string, + ): Effect.Effect => + Effect.gen(function* () { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; } - return { upstreamRef, remoteName, upstreamBranch }; - } - - // Fallback: split on first `/` for cases where config lookup fails. - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0) { - return null; - } - const fallbackRemoteName = upstreamRef.slice(0, separatorIndex); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1); - if (fallbackRemoteName.length === 0 || upstreamBranch.length === 0) { - return null; - } - return { - upstreamRef, - remoteName: fallbackRemoteName, - upstreamBranch, - }; - }); - - const fetchUpstreamRef = ( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - return runGit( - "GitCore.fetchUpstreamRef", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - true, - ); - }; + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } - const fetchUpstreamRefForStatus = ( - cwd: string, - upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, - ): Effect.Effect => { - const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; - return executeGit( - "GitCore.fetchUpstreamRefForStatus", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], - { - allowNonZeroExit: true, - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); - }; + return yield* createGitCommandError( + "GitCore.renameBranch", + cwd, + ["branch", "-m", "--", desiredBranch], + `Could not find an available branch name for '${desiredBranch}'.`, + ); + }); - const statusUpstreamRefreshCache = yield* Cache.makeWith({ - capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, - lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => + const resolveCurrentUpstream = ( + cwd: string, + ): Effect.Effect< + { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, + GitCommandError + > => Effect.gen(function* () { - yield* fetchUpstreamRefForStatus(cacheKey.cwd, { - upstreamRef: cacheKey.upstreamRef, - remoteName: cacheKey.remoteName, - upstreamBranch: cacheKey.upstreamBranch, - }); - return true as const; - }), - // Keep successful refreshes warm; drop failures immediately so next request can retry. - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), - }); - - const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect => - Effect.gen(function* () { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* Cache.get( - statusUpstreamRefreshCache, - new StatusUpstreamRefreshCacheKey({ + const upstreamRef = yield* runGitStdout( + "GitCore.resolveCurrentUpstream", cwd, - upstreamRef: upstream.upstreamRef, - remoteName: upstream.remoteName, - upstreamBranch: upstream.upstreamBranch, - }), - ); - }); - - const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect => - Effect.gen(function* () { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* fetchUpstreamRef(cwd, upstream); - }); + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - const resolveDefaultBranchName = ( - cwd: string, - remoteName: string, - ): Effect.Effect => - executeGit( - "GitCore.resolveDefaultBranchName", - cwd, - ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.code !== 0) { + if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { return null; } - return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); - }), - ); - - const remoteBranchExists = ( - cwd: string, - remoteName: string, - branch: string, - ): Effect.Effect => - executeGit( - "GitCore.remoteBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], - { - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)); - - const originRemoteExists = (cwd: string): Effect.Effect => - executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { - allowNonZeroExit: true, - }).pipe(Effect.map((result) => result.code === 0)); - - const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), - ); - - const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => - Effect.gen(function* () { - if (yield* originRemoteExists(cwd)) { - return "origin"; - } - const remotes = yield* listRemoteNames(cwd); - const [firstRemote] = remotes; - if (firstRemote) { - return firstRemote; - } - return yield* createGitCommandError( - "GitCore.resolvePrimaryRemoteName", - cwd, - ["remote"], - "No git remote is configured for this repository.", - ); - }); - - const resolvePushRemoteName = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const branchPushRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.branchPushRemote", - cwd, - ["config", "--get", `branch.${branch}.pushRemote`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (branchPushRemote.length > 0) { - return branchPushRemote; - } - - const pushDefaultRemote = yield* runGitStdout( - "GitCore.resolvePushRemoteName.remotePushDefault", - cwd, - ["config", "--get", "remote.pushDefault"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { - return pushDefaultRemote; - } - - return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); - }); - const ensureRemote: GitCoreShape["ensureRemote"] = (input) => - Effect.gen(function* () { - const preferredName = sanitizeRemoteName(input.preferredName); - const normalizedTargetUrl = normalizeRemoteUrl(input.url); - const remoteFetchUrls = yield* runGitStdout( - "GitCore.ensureRemote.listRemoteUrls", - input.cwd, - ["remote", "-v"], - ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - - for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { - if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { - return remoteName; + // Resolve the remote name from branch config to handle remotes whose + // names contain `/` (e.g. `my-org/upstream`). Splitting on the first + // `/` would incorrectly truncate such names. + const branch = yield* runGitStdout( + "GitCore.resolveCurrentUpstream.branch", + cwd, + ["rev-parse", "--abbrev-ref", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const remoteName = + branch.length > 0 + ? yield* runGitStdout( + "GitCore.resolveCurrentUpstream.remote", + cwd, + ["config", "--get", `branch.${branch}.remote`], + true, + ).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.catch(() => Effect.succeed("")), + ) + : ""; + + if (remoteName.length > 0 && upstreamRef.startsWith(`${remoteName}/`)) { + const upstreamBranch = upstreamRef.slice(remoteName.length + 1); + if (upstreamBranch.length === 0) { + return null; + } + return { upstreamRef, remoteName, upstreamBranch }; } - } - let remoteName = preferredName; - let suffix = 1; - while (remoteFetchUrls.has(remoteName)) { - remoteName = `${preferredName}-${suffix}`; - suffix += 1; - } + // Fallback: split on first `/` for cases where config lookup fails. + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0) { + return null; + } + const fallbackRemoteName = upstreamRef.slice(0, separatorIndex); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1); + if (fallbackRemoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } - yield* runGit("GitCore.ensureRemote.add", input.cwd, [ - "remote", - "add", - remoteName, - input.url, - ]); - return remoteName; - }); + return { + upstreamRef, + remoteName: fallbackRemoteName, + upstreamBranch, + }; + }); - const resolveBaseBranchForNoUpstream = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const configuredBaseBranch = yield* runGitStdout( - "GitCore.resolveBaseBranchForNoUpstream.config", + const fetchUpstreamRef = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return runGit( + "GitCore.fetchUpstreamRef", cwd, - ["config", "--get", `branch.${branch}.gh-merge-base`], + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), ); - const defaultBranch = - primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); - const candidates = [ - configuredBaseBranch.length > 0 ? configuredBaseBranch : null, - defaultBranch, - ...DEFAULT_BASE_BRANCH_CANDIDATES, - ]; - - for (const candidate of candidates) { - if (!candidate) { - continue; - } - - const remotePrefix = - primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; - const normalizedCandidate = candidate.startsWith("origin/") - ? candidate.slice("origin/".length) - : remotePrefix && candidate.startsWith(remotePrefix) - ? candidate.slice(remotePrefix.length) - : candidate; - if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { - continue; - } - - if (yield* branchExists(cwd, normalizedCandidate)) { - return normalizedCandidate; - } + }; - if ( - primaryRemoteName && - (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) - ) { - return `${primaryRemoteName}/${normalizedCandidate}`; - } - } + const fetchUpstreamRefForStatus = ( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Effect.Effect => { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + return executeGit( + "GitCore.fetchUpstreamRefForStatus", + cwd, + ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + { + allowNonZeroExit: true, + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ).pipe(Effect.asVoid); + }; - return null; + const statusUpstreamRefreshCache = yield* Cache.makeWith({ + capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, + lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => + Effect.gen(function* () { + yield* fetchUpstreamRefForStatus(cacheKey.cwd, { + upstreamRef: cacheKey.upstreamRef, + remoteName: cacheKey.remoteName, + upstreamBranch: cacheKey.upstreamBranch, + }); + return true as const; + }), + // Keep successful refreshes warm; drop failures immediately so next request can retry. + timeToLive: (exit) => + Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero, }); - const computeAheadCountAgainstBase = ( - cwd: string, - branch: string, - ): Effect.Effect => - Effect.gen(function* () { - const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); - if (!baseBranch) { - return 0; - } + const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + yield* Cache.get( + statusUpstreamRefreshCache, + new StatusUpstreamRefreshCacheKey({ + cwd, + upstreamRef: upstream.upstreamRef, + remoteName: upstream.remoteName, + upstreamBranch: upstream.upstreamBranch, + }), + ); + }); - const result = yield* executeGit( - "GitCore.computeAheadCountAgainstBase", + const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const upstream = yield* resolveCurrentUpstream(cwd); + if (!upstream) return; + yield* fetchUpstreamRef(cwd, upstream); + }); + + const resolveDefaultBranchName = ( + cwd: string, + remoteName: string, + ): Effect.Effect => + executeGit( + "GitCore.resolveDefaultBranchName", cwd, - ["rev-list", "--count", `${baseBranch}..HEAD`], + ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], { allowNonZeroExit: true }, + ).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); + }), ); - if (result.code !== 0) { - return 0; - } - - const parsed = Number.parseInt(result.stdout.trim(), 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; - }); - const readBranchRecency = (cwd: string): Effect.Effect, GitCommandError> => - Effect.gen(function* () { - const branchRecency = yield* executeGit( - "GitCore.readBranchRecency", + const remoteBranchExists = ( + cwd: string, + remoteName: string, + branch: string, + ): Effect.Effect => + executeGit( + "GitCore.remoteBranchExists", cwd, - [ - "for-each-ref", - "--format=%(refname:short)%09%(committerdate:unix)", - "refs/heads", - "refs/remotes", - ], + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], { - timeoutMs: 15_000, allowNonZeroExit: true, }, + ).pipe(Effect.map((result) => result.code === 0)); + + const originRemoteExists = (cwd: string): Effect.Effect => + executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.code === 0)); + + const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => + runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), ); - const branchLastCommit = new Map(); - if (branchRecency.code !== 0) { - return branchLastCommit; - } + const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => + Effect.gen(function* () { + if (yield* originRemoteExists(cwd)) { + return "origin"; + } + const remotes = yield* listRemoteNames(cwd); + const [firstRemote] = remotes; + if (firstRemote) { + return firstRemote; + } + return yield* createGitCommandError( + "GitCore.resolvePrimaryRemoteName", + cwd, + ["remote"], + "No git remote is configured for this repository.", + ); + }); - for (const line of branchRecency.stdout.split("\n")) { - if (line.length === 0) { - continue; + const resolvePushRemoteName = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const branchPushRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.branchPushRemote", + cwd, + ["config", "--get", `branch.${branch}.pushRemote`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (branchPushRemote.length > 0) { + return branchPushRemote; } - const [name, lastCommitRaw] = line.split("\t"); - if (!name) { - continue; + + const pushDefaultRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.remotePushDefault", + cwd, + ["config", "--get", "remote.pushDefault"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (pushDefaultRemote.length > 0) { + return pushDefaultRemote; } - const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); - branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); - } - return branchLastCommit; - }); + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + }); - const statusDetails: GitCoreShape["statusDetails"] = (cwd) => - Effect.gen(function* () { - yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - - const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( - [ - runGitStdout("GitCore.statusDetails.status", cwd, [ - "status", - "--porcelain=2", - "--branch", - ]), - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ - "diff", - "--cached", - "--numstat", - ]), - ], - { concurrency: "unbounded" }, - ); + const ensureRemote: GitCoreShape["ensureRemote"] = (input) => + Effect.gen(function* () { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout( + "GitCore.ensureRemote.listRemoteUrls", + input.cwd, + ["remote", "-v"], + ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); - let branch: string | null = null; - let upstreamRef: string | null = null; - let aheadCount = 0; - let behindCount = 0; - let hasWorkingTreeChanges = false; - const changedFilesWithoutNumstat = new Set(); - - for (const line of statusStdout.split(/\r?\n/g)) { - if (line.startsWith("# branch.head ")) { - const value = line.slice("# branch.head ".length).trim(); - branch = value.startsWith("(") ? null : value; - continue; - } - if (line.startsWith("# branch.upstream ")) { - const value = line.slice("# branch.upstream ".length).trim(); - upstreamRef = value.length > 0 ? value : null; - continue; - } - if (line.startsWith("# branch.ab ")) { - const value = line.slice("# branch.ab ".length).trim(); - const parsed = parseBranchAb(value); - aheadCount = parsed.ahead; - behindCount = parsed.behind; - continue; + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } } - if (line.trim().length > 0 && !line.startsWith("#")) { - hasWorkingTreeChanges = true; - const pathValue = parsePorcelainPath(line); - if (pathValue) changedFilesWithoutNumstat.add(pathValue); + + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; } - } - if (!upstreamRef && branch) { - aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(0)), + yield* runGit("GitCore.ensureRemote.add", input.cwd, [ + "remote", + "add", + remoteName, + input.url, + ]); + return remoteName; + }); + + const resolveBaseBranchForNoUpstream = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const configuredBaseBranch = yield* runGitStdout( + "GitCore.resolveBaseBranchForNoUpstream.config", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), ); - behindCount = 0; - } + const defaultBranch = + primaryRemoteName === null + ? null + : yield* resolveDefaultBranchName(cwd, primaryRemoteName); + const candidates = [ + configuredBaseBranch.length > 0 ? configuredBaseBranch : null, + defaultBranch, + ...DEFAULT_BASE_BRANCH_CANDIDATES, + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } - const stagedEntries = parseNumstatEntries(stagedNumstatStdout); - const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); - const fileStatMap = new Map(); - for (const entry of [...stagedEntries, ...unstagedEntries]) { - const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; - existing.insertions += entry.insertions; - existing.deletions += entry.deletions; - fileStatMap.set(entry.path, existing); - } + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; + const normalizedCandidate = candidate.startsWith("origin/") + ? candidate.slice("origin/".length) + : remotePrefix && candidate.startsWith(remotePrefix) + ? candidate.slice(remotePrefix.length) + : candidate; + if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { + continue; + } - let insertions = 0; - let deletions = 0; - const files = Array.from(fileStatMap.entries()) - .map(([filePath, stat]) => { - insertions += stat.insertions; - deletions += stat.deletions; - return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; - }) - .toSorted((a, b) => a.path.localeCompare(b.path)); - - for (const filePath of changedFilesWithoutNumstat) { - if (fileStatMap.has(filePath)) continue; - files.push({ path: filePath, insertions: 0, deletions: 0 }); - } - files.sort((a, b) => a.path.localeCompare(b.path)); - - return { - branch, - upstreamRef, - hasWorkingTreeChanges, - workingTree: { - files, - insertions, - deletions, - }, - hasUpstream: upstreamRef !== null, - aheadCount, - behindCount, - }; - }); + if (yield* branchExists(cwd, normalizedCandidate)) { + return normalizedCandidate; + } - const status: GitCoreShape["status"] = (input) => - statusDetails(input.cwd).pipe( - Effect.map((details) => ({ - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr: null, - })), - ); - - const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => - Effect.gen(function* () { - if (filePaths && filePaths.length > 0) { - yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( - Effect.catch(() => Effect.void), - ); - yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ - "add", - "-A", - "--", - ...filePaths, - ]); - } else { - yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); - } + if ( + primaryRemoteName && + (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; + } + } - const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ - "diff", - "--cached", - "--name-status", - ]).pipe(Effect.map((stdout) => stdout.trim())); - if (stagedSummary.length === 0) { return null; - } - - const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ - "diff", - "--cached", - "--patch", - "--minimal", - ]); + }); - return { - stagedSummary, - stagedPatch, - }; - }); + const computeAheadCountAgainstBase = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); + if (!baseBranch) { + return 0; + } - const commit: GitCoreShape["commit"] = (cwd, subject, body) => - Effect.gen(function* () { - const args = ["commit", "-m", subject]; - const trimmedBody = body.trim(); - if (trimmedBody.length > 0) { - args.push("-m", trimmedBody); - } - yield* runGit("GitCore.commit.commit", cwd, args); - const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ - "rev-parse", - "HEAD", - ]).pipe(Effect.map((stdout) => stdout.trim())); + const result = yield* executeGit( + "GitCore.computeAheadCountAgainstBase", + cwd, + ["rev-list", "--count", `${baseBranch}..HEAD`], + { allowNonZeroExit: true }, + ); + if (result.code !== 0) { + return 0; + } - return { commitSha }; - }); + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }); - const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => - Effect.gen(function* () { - const details = yield* statusDetails(cwd); - const branch = details.branch ?? fallbackBranch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", + const readBranchRecency = (cwd: string): Effect.Effect, GitCommandError> => + Effect.gen(function* () { + const branchRecency = yield* executeGit( + "GitCore.readBranchRecency", cwd, - ["push"], - "Cannot push from detached HEAD.", + [ + "for-each-ref", + "--format=%(refname:short)%09%(committerdate:unix)", + "refs/heads", + "refs/remotes", + ], + { + timeoutMs: 15_000, + allowNonZeroExit: true, + }, ); - } - const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; - if (hasNoLocalDelta) { - if (details.hasUpstream) { - return { - status: "skipped_up_to_date" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - }; + const branchLastCommit = new Map(); + if (branchRecency.code !== 0) { + return branchLastCommit; } - const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (comparableBaseBranch) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (!publishRemoteName) { - return { - status: "skipped_up_to_date" as const, - branch, - }; + for (const line of branchRecency.stdout.split("\n")) { + if (line.length === 0) { + continue; } - - const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), - ); - if (hasRemoteBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; + const [name, lastCommitRaw] = line.split("\t"); + if (!name) { + continue; } + const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); + branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); } - } - if (!details.hasUpstream) { - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); - if (!publishRemoteName) { - return yield* createGitCommandError( - "GitCore.pushCurrentBranch", - cwd, - ["push"], - "Cannot push because no git remote is configured for this repository.", - ); - } - yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - branch, - ]); - return { - status: "pushed" as const, - branch, - upstreamBranch: `${publishRemoteName}/${branch}`, - setUpstream: true, - }; - } + return branchLastCommit; + }); + + const statusDetails: GitCoreShape["statusDetails"] = (cwd) => + Effect.gen(function* () { + yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); + + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( + [ + runGitStdout("GitCore.statusDetails.status", cwd, [ + "status", + "--porcelain=2", + "--branch", + ]), + runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + ], + { concurrency: "unbounded" }, + ); + + let branch: string | null = null; + let upstreamRef: string | null = null; + let aheadCount = 0; + let behindCount = 0; + let hasWorkingTreeChanges = false; + const changedFilesWithoutNumstat = new Set(); + + for (const line of statusStdout.split(/\r?\n/g)) { + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + branch = value.startsWith("(") ? null : value; + continue; + } + if (line.startsWith("# branch.upstream ")) { + const value = line.slice("# branch.upstream ".length).trim(); + upstreamRef = value.length > 0 ? value : null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const value = line.slice("# branch.ab ".length).trim(); + const parsed = parseBranchAb(value); + aheadCount = parsed.ahead; + behindCount = parsed.behind; + continue; + } + if (line.trim().length > 0 && !line.startsWith("#")) { + hasWorkingTreeChanges = true; + const pathValue = parsePorcelainPath(line); + if (pathValue) changedFilesWithoutNumstat.add(pathValue); + } + } + + if (!upstreamRef && branch) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + behindCount = 0; + } + + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); + const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); + const fileStatMap = new Map(); + for (const entry of [...stagedEntries, ...unstagedEntries]) { + const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; + existing.insertions += entry.insertions; + existing.deletions += entry.deletions; + fileStatMap.set(entry.path, existing); + } + + let insertions = 0; + let deletions = 0; + const files = Array.from(fileStatMap.entries()) + .map(([filePath, stat]) => { + insertions += stat.insertions; + deletions += stat.deletions; + return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; + }) + .toSorted((a, b) => a.path.localeCompare(b.path)); + + for (const filePath of changedFilesWithoutNumstat) { + if (fileStatMap.has(filePath)) continue; + files.push({ path: filePath, insertions: 0, deletions: 0 }); + } + files.sort((a, b) => a.path.localeCompare(b.path)); - const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), - ); - if (currentUpstream) { - yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:${currentUpstream.upstreamBranch}`, - ]); return { - status: "pushed" as const, branch, - upstreamBranch: currentUpstream.upstreamRef, - setUpstream: false, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, }; - } + }); - yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); - return { - status: "pushed" as const, - branch, - ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), - setUpstream: false, - }; - }); + const status: GitCoreShape["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr: null, + })), + ); - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => - Effect.gen(function* () { - const details = yield* statusDetails(cwd); - const branch = details.branch; - if (!branch) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", - cwd, - ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", - ); - } - if (!details.hasUpstream) { - return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => + Effect.gen(function* () { + if (filePaths && filePaths.length > 0) { + yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( + Effect.catch(() => Effect.void), + ); + yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ + "add", + "-A", + "--", + ...filePaths, + ]); + } else { + yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); + } + + const stagedSummary = yield* runGitStdout( + "GitCore.prepareCommitContext.stagedSummary", cwd, - ["pull", "--ff-only"], - "Current branch has no upstream configured. Push with upstream first.", - ); - } - const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { - timeoutMs: 30_000, - fallbackErrorMessage: "git pull failed", + ["diff", "--cached", "--name-status"], + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ + "diff", + "--cached", + "--patch", + "--minimal", + ]); + + return { + stagedSummary, + stagedPatch, + }; }); - const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", - cwd, - ["rev-parse", "HEAD"], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - - const refreshed = yield* statusDetails(cwd); - return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", - branch, - upstreamBranch: refreshed.upstreamRef, - }; - }); - const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => - Effect.gen(function* () { - const range = `${baseBranch}..HEAD`; - const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( - [ - runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), - runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), - runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ - "diff", - "--patch", - "--minimal", - range, - ]), - ], - { concurrency: "unbounded" }, - ); + const commit: GitCoreShape["commit"] = (cwd, subject, body) => + Effect.gen(function* () { + const args = ["commit", "-m", subject]; + const trimmedBody = body.trim(); + if (trimmedBody.length > 0) { + args.push("-m", trimmedBody); + } + yield* runGit("GitCore.commit.commit", cwd, args); + const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ + "rev-parse", + "HEAD", + ]).pipe(Effect.map((stdout) => stdout.trim())); - return { - commitSummary, - diffSummary, - diffPatch, - }; - }); + return { commitSha }; + }); - const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => - runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( - Effect.map((stdout) => stdout.trim()), - Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), - ); + const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => + Effect.gen(function* () { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", + cwd, + ["push"], + "Cannot push from detached HEAD.", + ); + } - const listBranches: GitCoreShape["listBranches"] = (input) => - Effect.gen(function* () { - const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), - ); - const localBranchResult = yield* executeGit( - "GitCore.listBranches.branchNoColor", - input.cwd, - ["branch", "--no-color"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ); + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; + if (hasNoLocalDelta) { + if (details.hasUpstream) { + return { + status: "skipped_up_to_date" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + }; + } - if (localBranchResult.code !== 0) { - const stderr = localBranchResult.stderr.trim(); - if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false, hasOriginRemote: false }; + const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (comparableBaseBranch) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!publishRemoteName) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + } } - return yield* createGitCommandError( - "GitCore.listBranches", - input.cwd, - ["branch", "--no-color"], - stderr || "git branch failed", + + if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); + } + yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ + "push", + "-u", + publishRemoteName, + branch, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${publishRemoteName}/${branch}`, + setUpstream: true, + }; + } + + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), ); - } + if (currentUpstream) { + yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:${currentUpstream.upstreamBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, + }; + } - const remoteBranchResultEffect = executeGit( - "GitCore.listBranches.remoteBranches", - input.cwd, - ["branch", "--no-color", "--remotes"], - { - timeoutMs: 10_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); + yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); + return { + status: "pushed" as const, + branch, + ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), + setUpstream: false, + }; + }); - const remoteNamesResultEffect = executeGit( - "GitCore.listBranches.remoteNames", - input.cwd, - ["remote"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.catch((error) => - Effect.logWarning( - `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, - ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), - ), - ); + const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => + Effect.gen(function* () { + const details = yield* statusDetails(cwd); + const branch = details.branch; + if (!branch) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Cannot pull from detached HEAD.", + ); + } + if (!details.hasUpstream) { + return yield* createGitCommandError( + "GitCore.pullCurrentBranch", + cwd, + ["pull", "--ff-only"], + "Current branch has no upstream configured. Push with upstream first.", + ); + } + const beforeSha = yield* runGitStdout( + "GitCore.pullCurrentBranch.beforeSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + timeoutMs: 30_000, + fallbackErrorMessage: "git pull failed", + }); + const afterSha = yield* runGitStdout( + "GitCore.pullCurrentBranch.afterSha", + cwd, + ["rev-parse", "HEAD"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); - const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = - yield* Effect.all( + const refreshed = yield* statusDetails(cwd); + return { + status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + branch, + upstreamBranch: refreshed.upstreamRef, + }; + }); + + const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => + Effect.gen(function* () { + const range = `${baseBranch}..HEAD`; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( [ - executeGit( - "GitCore.listBranches.defaultRef", - input.cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - executeGit( - "GitCore.listBranches.worktreeList", - input.cwd, - ["worktree", "list", "--porcelain"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ), - remoteBranchResultEffect, - remoteNamesResultEffect, - branchRecencyPromise, + runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), + runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), + runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ + "diff", + "--patch", + "--minimal", + range, + ]), ], { concurrency: "unbounded" }, ); - const remoteNames = - remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; - if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, + return { + commitSummary, + diffSummary, + diffPatch, + }; + }); + + const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => + runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), + ); + + const listBranches: GitCoreShape["listBranches"] = (input) => + Effect.gen(function* () { + const branchRecencyPromise = readBranchRecency(input.cwd).pipe( + Effect.catch(() => Effect.succeed(new Map())), ); - } - if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { - yield* Effect.logWarning( - `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + const localBranchResult = yield* executeGit( + "GitCore.listBranches.branchNoColor", + input.cwd, + ["branch", "--no-color"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, ); - } - const defaultBranch = - defaultRef.code === 0 - ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") - : null; + if (localBranchResult.code !== 0) { + const stderr = localBranchResult.stderr.trim(); + if (stderr.toLowerCase().includes("not a git repository")) { + return { branches: [], isRepo: false, hasOriginRemote: false }; + } + return yield* createGitCommandError( + "GitCore.listBranches", + input.cwd, + ["branch", "--no-color"], + stderr || "git branch failed", + ); + } - const worktreeMap = new Map(); - if (worktreeList.code === 0) { - let currentPath: string | null = null; - for (const line of worktreeList.stdout.split("\n")) { - if (line.startsWith("worktree ")) { - const candidatePath = line.slice("worktree ".length); - const exists = yield* fileSystem.stat(candidatePath).pipe( - Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), - ); - currentPath = exists ? candidatePath : null; - } else if (line.startsWith("branch refs/heads/") && currentPath) { - worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); - } else if (line === "") { - currentPath = null; + const remoteBranchResultEffect = executeGit( + "GitCore.listBranches.remoteBranches", + input.cwd, + ["branch", "--no-color", "--remotes"], + { + timeoutMs: 10_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, + ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + ), + ); + + const remoteNamesResultEffect = executeGit( + "GitCore.listBranches.remoteNames", + input.cwd, + ["remote"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, + ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), + ), + ); + + const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = + yield* Effect.all( + [ + executeGit( + "GitCore.listBranches.defaultRef", + input.cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + executeGit( + "GitCore.listBranches.worktreeList", + input.cwd, + ["worktree", "list", "--porcelain"], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ), + remoteBranchResultEffect, + remoteNamesResultEffect, + branchRecencyPromise, + ], + { concurrency: "unbounded" }, + ); + + const remoteNames = + remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; + if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, + ); + } + if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { + yield* Effect.logWarning( + `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, + ); + } + + const defaultBranch = + defaultRef.code === 0 + ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + + const worktreeMap = new Map(); + if (worktreeList.code === 0) { + let currentPath: string | null = null; + for (const line of worktreeList.stdout.split("\n")) { + if (line.startsWith("worktree ")) { + const candidatePath = line.slice("worktree ".length); + const exists = yield* fileSystem.stat(candidatePath).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + currentPath = exists ? candidatePath : null; + } else if (line.startsWith("branch refs/heads/") && currentPath) { + worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); + } else if (line === "") { + currentPath = null; + } } } - } - const localBranches = localBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => ({ - name: branch.name, - current: branch.current, - isRemote: false, - isDefault: branch.name === defaultBranch, - worktreePath: worktreeMap.get(branch.name) ?? null, - })) - .toSorted((a, b) => { - const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; - const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; - if (aPriority !== bPriority) return aPriority - bPriority; - - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }); + const localBranches = localBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => ({ + name: branch.name, + current: branch.current, + isRemote: false, + isDefault: branch.name === defaultBranch, + worktreePath: worktreeMap.get(branch.name) ?? null, + })) + .toSorted((a, b) => { + const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; + const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; + if (aPriority !== bPriority) return aPriority - bPriority; + + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }); + + const remoteBranches = + remoteBranchResult.code === 0 + ? remoteBranchResult.stdout + .split("\n") + .map(parseBranchLine) + .filter((branch): branch is { name: string; current: boolean } => branch !== null) + .map((branch) => { + const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); + const remoteBranch: { + name: string; + current: boolean; + isRemote: boolean; + remoteName?: string; + isDefault: boolean; + worktreePath: string | null; + } = { + name: branch.name, + current: false, + isRemote: true, + isDefault: false, + worktreePath: null, + }; + if (parsedRemoteRef) { + remoteBranch.remoteName = parsedRemoteRef.remoteName; + } + return remoteBranch; + }) + .toSorted((a, b) => { + const aLastCommit = branchLastCommit.get(a.name) ?? 0; + const bLastCommit = branchLastCommit.get(b.name) ?? 0; + if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; + return a.name.localeCompare(b.name); + }) + : []; + + const branches = [...localBranches, ...remoteBranches]; + + return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; + }); - const remoteBranches = - remoteBranchResult.code === 0 - ? remoteBranchResult.stdout - .split("\n") - .map(parseBranchLine) - .filter((branch): branch is { name: string; current: boolean } => branch !== null) - .map((branch) => { - const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); - const remoteBranch: { - name: string; - current: boolean; - isRemote: boolean; - remoteName?: string; - isDefault: boolean; - worktreePath: string | null; - } = { - name: branch.name, - current: false, - isRemote: true, - isDefault: false, - worktreePath: null, - }; - if (parsedRemoteRef) { - remoteBranch.remoteName = parsedRemoteRef.remoteName; - } - return remoteBranch; - }) - .toSorted((a, b) => { - const aLastCommit = branchLastCommit.get(a.name) ?? 0; - const bLastCommit = branchLastCommit.get(b.name) ?? 0; - if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; - return a.name.localeCompare(b.name); - }) - : []; - - const branches = [...localBranches, ...remoteBranches]; - - return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; - }); + const createWorktree: GitCoreShape["createWorktree"] = (input) => + Effect.gen(function* () { + const targetBranch = input.newBranch ?? input.branch; + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const repoName = path.basename(input.cwd); + const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const args = input.newBranch + ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] + : ["worktree", "add", worktreePath, input.branch]; + + yield* executeGit("GitCore.createWorktree", input.cwd, args, { + fallbackErrorMessage: "git worktree add failed", + }); - const createWorktree: GitCoreShape["createWorktree"] = (input) => - Effect.gen(function* () { - const targetBranch = input.newBranch ?? input.branch; - const sanitizedBranch = targetBranch.replace(/\//g, "-"); - const repoName = path.basename(input.cwd); - const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); - const args = input.newBranch - ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] - : ["worktree", "add", worktreePath, input.branch]; - - yield* executeGit("GitCore.createWorktree", input.cwd, args, { - fallbackErrorMessage: "git worktree add failed", + return { + worktree: { + path: worktreePath, + branch: targetBranch, + }, + }; }); - return { - worktree: { - path: worktreePath, - branch: targetBranch, - }, - }; - }); + const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => + Effect.gen(function* () { + const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + yield* executeGit( + "GitCore.fetchPullRequestBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, + ], + { + fallbackErrorMessage: "git fetch pull request branch failed", + }, + ); + }).pipe(Effect.asVoid); - const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => - Effect.gen(function* () { - const remoteName = yield* resolvePrimaryRemoteName(input.cwd); - yield* executeGit( - "GitCore.fetchPullRequestBranch", - input.cwd, - [ + const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => + Effect.gen(function* () { + yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ "fetch", "--quiet", "--no-tags", - remoteName, - `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, - ], - { - fallbackErrorMessage: "git fetch pull request branch failed", - }, - ); - }).pipe(Effect.asVoid); + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); - const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => - Effect.gen(function* () { - yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitCore.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }).pipe(Effect.asVoid); + + const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => + runGit("GitCore.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, ]); - const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); - const targetRef = `${input.remoteName}/${input.remoteBranch}`; - yield* runGit( - "GitCore.fetchRemoteBranch.materialize", - input.cwd, - localBranchAlreadyExists - ? ["branch", "--force", input.localBranch, targetRef] - : ["branch", input.localBranch, targetRef], - ); - }).pipe(Effect.asVoid); - - const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => - runGit("GitCore.setBranchUpstream", input.cwd, [ - "branch", - "--set-upstream-to", - `${input.remoteName}/${input.remoteBranch}`, - input.branch, - ]); - - const removeWorktree: GitCoreShape["removeWorktree"] = (input) => - Effect.gen(function* () { - const args = ["worktree", "remove"]; - if (input.force) { - args.push("--force"); - } - args.push(input.path); - yield* executeGit("GitCore.removeWorktree", input.cwd, args, { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }).pipe( - Effect.mapError((error) => - createGitCommandError( - "GitCore.removeWorktree", - input.cwd, - args, - `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, - error, + const removeWorktree: GitCoreShape["removeWorktree"] = (input) => + Effect.gen(function* () { + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + yield* executeGit("GitCore.removeWorktree", input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }).pipe( + Effect.mapError((error) => + createGitCommandError( + "GitCore.removeWorktree", + input.cwd, + args, + `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, + error, + ), ), - ), - ); - }); - - const renameBranch: GitCoreShape["renameBranch"] = (input) => - Effect.gen(function* () { - if (input.oldBranch === input.newBranch) { - return { branch: input.newBranch }; - } - const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + ); + }); - yield* executeGit( - "GitCore.renameBranch", - input.cwd, - ["branch", "-m", "--", input.oldBranch, targetBranch], - { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch rename failed", - }, - ); + const renameBranch: GitCoreShape["renameBranch"] = (input) => + Effect.gen(function* () { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); - return { branch: targetBranch }; - }); + yield* executeGit( + "GitCore.renameBranch", + input.cwd, + ["branch", "-m", "--", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); - const createBranch: GitCoreShape["createBranch"] = (input) => - executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { - timeoutMs: 10_000, - fallbackErrorMessage: "git branch create failed", - }).pipe(Effect.asVoid); + return { branch: targetBranch }; + }); - const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => - Effect.gen(function* () { - const [localInputExists, remoteExists] = yield* Effect.all( - [ - executeGit( - "GitCore.checkoutBranch.localInputExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - executeGit( - "GitCore.checkoutBranch.remoteExists", - input.cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe(Effect.map((result) => result.code === 0)), - ], - { concurrency: "unbounded" }, - ); + const createBranch: GitCoreShape["createBranch"] = (input) => + executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch create failed", + }).pipe(Effect.asVoid); - const localTrackingBranch = remoteExists - ? yield* executeGit( - "GitCore.checkoutBranch.localTrackingBranch", - input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], - { - timeoutMs: 5_000, - allowNonZeroExit: true, - }, - ).pipe( - Effect.map((result) => - result.code === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) - : null, - ), - ) - : null; + const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => + Effect.gen(function* () { + const [localInputExists, remoteExists] = yield* Effect.all( + [ + executeGit( + "GitCore.checkoutBranch.localInputExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + executeGit( + "GitCore.checkoutBranch.remoteExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)), + ], + { concurrency: "unbounded" }, + ); - const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); - const localTrackedBranchTargetExists = - remoteExists && localTrackedBranchCandidate + const localTrackingBranch = remoteExists ? yield* executeGit( - "GitCore.checkoutBranch.localTrackedBranchTargetExists", + "GitCore.checkoutBranch.localTrackingBranch", input.cwd, - ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], { timeoutMs: 5_000, allowNonZeroExit: true, }, - ).pipe(Effect.map((result) => result.code === 0)) - : false; + ).pipe( + Effect.map((result) => + result.code === 0 + ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) + : null, + ), + ) + : null; - const checkoutArgs = localInputExists - ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); + const localTrackedBranchTargetExists = + remoteExists && localTrackedBranchCandidate + ? yield* executeGit( + "GitCore.checkoutBranch.localTrackedBranchTargetExists", + input.cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], + { + timeoutMs: 5_000, + allowNonZeroExit: true, + }, + ).pipe(Effect.map((result) => result.code === 0)) + : false; + + const checkoutArgs = localInputExists ? ["checkout", input.branch] - : remoteExists && !localTrackingBranch - ? ["checkout", "--track", input.branch] - : remoteExists && localTrackingBranch - ? ["checkout", localTrackingBranch] - : ["checkout", input.branch]; + : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists + ? ["checkout", input.branch] + : remoteExists && !localTrackingBranch + ? ["checkout", "--track", input.branch] + : remoteExists && localTrackingBranch + ? ["checkout", localTrackingBranch] + : ["checkout", input.branch]; + + yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { + timeoutMs: 10_000, + fallbackErrorMessage: "git checkout failed", + }); - yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { - timeoutMs: 10_000, - fallbackErrorMessage: "git checkout failed", + // Refresh upstream refs in the background so checkout remains responsive. + yield* Effect.forkScoped( + refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), + ); }); - // Refresh upstream refs in the background so checkout remains responsive. - yield* Effect.forkScoped( - refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), + const initRepo: GitCoreShape["initRepo"] = (input) => + executeGit("GitCore.initRepo", input.cwd, ["init"], { + timeoutMs: 10_000, + fallbackErrorMessage: "git init failed", + }).pipe(Effect.asVoid); + + const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => + runGitStdout("GitCore.listLocalBranchNames", cwd, [ + "branch", + "--list", + "--format=%(refname:short)", + ]).pipe( + Effect.map((stdout) => + stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ), ); - }); - - const initRepo: GitCoreShape["initRepo"] = (input) => - executeGit("GitCore.initRepo", input.cwd, ["init"], { - timeoutMs: 10_000, - fallbackErrorMessage: "git init failed", - }).pipe(Effect.asVoid); - - const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => - runGitStdout("GitCore.listLocalBranchNames", cwd, [ - "branch", - "--list", - "--format=%(refname:short)", - ]).pipe( - Effect.map((stdout) => - stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0), - ), - ); - return { - status, - statusDetails, - prepareCommitContext, - commit, - pushCurrentBranch, - pullCurrentBranch, - readRangeContext, - readConfigValue, - listBranches, - createWorktree, - fetchPullRequestBranch, - ensureRemote, - fetchRemoteBranch, - setBranchUpstream, - removeWorktree, - renameBranch, - createBranch, - checkoutBranch, - initRepo, - listLocalBranchNames, - } satisfies GitCoreShape; -}); + return { + execute, + status, + statusDetails, + prepareCommitContext, + commit, + pushCurrentBranch, + pullCurrentBranch, + readRangeContext, + readConfigValue, + listBranches, + createWorktree, + fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, + removeWorktree, + renameBranch, + createBranch, + checkoutBranch, + initRepo, + listLocalBranchNames, + } satisfies GitCoreShape; + }); -export const GitCoreLive = Layer.effect(GitCore, makeGitCore); +export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index a48aeac288..5bddb0cc43 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -28,9 +28,8 @@ import { SessionTextGeneration, type SessionTextGenerationShape, } from "../Services/SessionTextGeneration.ts"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService } from "../Services/GitService.ts"; import { GitCoreLive } from "./GitCore.ts"; +import { GitCore } from "../Services/GitCore.ts"; import { makeGitManager } from "./GitManager.ts"; import { ServerConfig } from "../../config.ts"; @@ -107,11 +106,11 @@ function runGit( ): Effect.Effect< { readonly code: number; readonly stdout: string; readonly stderr: string }, GitCommandError, - GitService + GitCore > { return Effect.gen(function* () { - const gitService = yield* GitService; - return yield* gitService.execute({ + const gitCore = yield* GitCore; + return yield* gitCore.execute({ operation: "GitManager.test.runGit", cwd, args, @@ -125,7 +124,7 @@ function initRepo( ): Effect.Effect< void, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitService + FileSystem.FileSystem | Scope.Scope | GitCore > { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -141,7 +140,7 @@ function initRepo( function createBareRemote(): Effect.Effect< string, PlatformError.PlatformError | GitCommandError, - FileSystem.FileSystem | Scope.Scope | GitService + FileSystem.FileSystem | Scope.Scope | GitCore > { return Effect.gen(function* () { const remoteDir = yield* makeTempDir("t3code-git-remote-"); @@ -520,7 +519,6 @@ function makeManager(input?: { }); const gitCoreLayer = GitCoreLive.pipe( - Layer.provideMerge(GitServiceLive), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(ServerConfigLayer), ); @@ -538,7 +536,10 @@ function makeManager(input?: { ); } -const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); +const GitManagerTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), + Layer.provideMerge(NodeServices.layer), +); it.layer(GitManagerTestLayer)("GitManager", (it) => { it.effect("status includes PR metadata when branch already has an open PR", () => diff --git a/apps/server/src/git/Layers/GitService.test.ts b/apps/server/src/git/Layers/GitService.test.ts deleted file mode 100644 index 7db468c06c..0000000000 --- a/apps/server/src/git/Layers/GitService.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { it, assert } from "@effect/vitest"; -import { Effect, Layer, Schema } from "effect"; - -import { GitCommandError } from "../Errors.ts"; -import { GitServiceLive } from "./GitService.ts"; -import { GitService } from "../Services/GitService.ts"; - -const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); - -layer("GitServiceLive", (it) => { - it.effect("runGit executes successful git commands", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.version", - cwd: process.cwd(), - args: ["--version"], - }); - - assert.equal(result.code, 0); - assert.ok(result.stdout.toLowerCase().includes("git version")); - }), - ); - - it.effect("runGit can return non-zero exit codes when allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* gitService.execute({ - operation: "GitProcess.test.allowNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - allowNonZeroExit: true, - }); - - assert.notEqual(result.code, 0); - }), - ); - - it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => - Effect.gen(function* () { - const gitService = yield* GitService; - const result = yield* Effect.result( - gitService.execute({ - operation: "GitProcess.test.failOnNonZero", - cwd: process.cwd(), - args: ["rev-parse", "--verify", "__definitely_missing_ref__"], - }), - ); - - assert.equal(result._tag, "Failure"); - if (result._tag === "Failure") { - assert.ok(Schema.is(GitCommandError)(result.failure)); - assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); - assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); - } - }), - ); -}); diff --git a/apps/server/src/git/Layers/GitService.ts b/apps/server/src/git/Layers/GitService.ts deleted file mode 100644 index d3f07e3151..0000000000 --- a/apps/server/src/git/Layers/GitService.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Git process helpers - Effect-native git execution with typed errors. - * - * Centralizes child-process git invocation for server modules. This module - * only executes git commands and reports structured failures. - * - * @module GitServiceLive - */ -import { Effect, Layer, Option, Schema, Stream } from "effect"; -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "../Errors.ts"; -import { - ExecuteGitInput, - ExecuteGitResult, - GitService, - GitServiceShape, -} from "../Services/GitService.ts"; - -const DEFAULT_TIMEOUT_MS = 30_000; -const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; - -function quoteGitCommand(args: ReadonlyArray): string { - return `git ${args.join(" ")}`; -} - -function toGitCommandError( - input: Pick, - detail: string, -) { - return (cause: unknown) => - Schema.is(GitCommandError)(cause) - ? cause - : new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, - ...(cause !== undefined ? { cause } : {}), - }); -} - -const collectOutput = Effect.fn(function* ( - input: Pick, - stream: Stream.Stream, - maxOutputBytes: number, -): Effect.fn.Return { - const decoder = new TextDecoder(); - let bytes = 0; - let text = ""; - - yield* Stream.runForEach(stream, (chunk) => - Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { - return yield* new GitCommandError({ - operation: input.operation, - command: quoteGitCommand(input.args), - cwd: input.cwd, - detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, - }); - } - text += decoder.decode(chunk, { stream: true }); - }), - ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); - - text += decoder.decode(); - return text; -}); - -const makeGitService = Effect.gen(function* () { - const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - - const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { - const commandInput = { - ...input, - args: [...input.args], - } as const; - const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; - const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; - - const commandEffect = Effect.gen(function* () { - const child = yield* commandSpawner - .spawn( - ChildProcess.make("git", commandInput.args, { - cwd: commandInput.cwd, - ...(input.env ? { env: input.env } : {}), - }), - ) - .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); - - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - collectOutput(commandInput, child.stdout, maxOutputBytes), - collectOutput(commandInput, child.stderr, maxOutputBytes), - child.exitCode.pipe( - Effect.map((value) => Number(value)), - Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), - ), - ], - { concurrency: "unbounded" }, - ); - - if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); - return yield* new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: - trimmedStderr.length > 0 - ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` - : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, - }); - } - - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; - }); - - return yield* commandEffect.pipe( - Effect.scoped, - Effect.timeoutOption(timeoutMs), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - operation: commandInput.operation, - command: quoteGitCommand(commandInput.args), - cwd: commandInput.cwd, - detail: `${quoteGitCommand(commandInput.args)} timed out.`, - }), - ), - onSome: Effect.succeed, - }), - ), - ); - }); - - return { - execute, - } satisfies GitServiceShape; -}); - -export const GitServiceLive = Layer.effect(GitService, makeGitService); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 879927934e..bcab916db4 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -24,6 +24,22 @@ import type { import type { GitCommandError } from "../Errors.ts"; +export interface ExecuteGitInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; +} + +export interface ExecuteGitResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + export interface GitStatusDetails extends Omit { upstreamRef: string | null; } @@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput { * GitCoreShape - Service API for low-level Git repository interactions. */ export interface GitCoreShape { + /** + * Execute a raw Git command. + */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; + /** * Read Git status for a repository. */ diff --git a/apps/server/src/git/Services/GitService.ts b/apps/server/src/git/Services/GitService.ts deleted file mode 100644 index f43a4e6dcc..0000000000 --- a/apps/server/src/git/Services/GitService.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * GitService - Service for Git command execution. - * - * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed - * domain errors for Git command execution. - * - * @module GitService - */ -import { ServiceMap } from "effect"; -import type { Effect } from "effect"; - -import type { GitCommandError } from "../Errors.ts"; - -export interface ExecuteGitInput { - readonly operation: string; - readonly cwd: string; - readonly args: ReadonlyArray; - readonly env?: NodeJS.ProcessEnv; - readonly allowNonZeroExit?: boolean; - readonly timeoutMs?: number; - readonly maxOutputBytes?: number; -} - -export interface ExecuteGitResult { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -} - -/** - * GitServiceShape - Service API for Git command execution. - */ -export interface GitServiceShape { - /** - * Execute a Git command. - */ - readonly execute: (input: ExecuteGitInput) => Effect.Effect; -} - -/** - * GitService - Service for Git command execution. - */ -export class GitService extends ServiceMap.Service()( - "t3/git/Services/GitService", -) {} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 35d2b5cd37..93e74583c8 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -261,7 +262,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(RuntimeReceiptBusLive), Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), - Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index c54308a112..4ad2cc4da1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -273,6 +273,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); }).pipe( @@ -281,6 +282,26 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "approval-required", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("keeps explicit claude permission mode over runtime-derived defaults", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index b42550b580..9e7703e4b9 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -15,6 +15,7 @@ import { type PermissionUpdate, type SDKMessage, type SDKResultMessage, + type SettingSource, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -442,6 +443,11 @@ const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ "image/png", "image/webp", ]); +const CLAUDE_SETTING_SOURCES = [ + "user", + "project", + "local", +] as const satisfies ReadonlyArray; function buildPromptText(input: ProviderSendTurnInput): string { const requestedEffort = resolveReasoningEffortForProvider( @@ -2588,6 +2594,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(input.cwd ? { cwd: input.cwd } : {}), ...(input.model ? { model: input.model } : {}), pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", + settingSources: [...CLAUDE_SETTING_SOURCES], ...(effectiveEffort ? { effort: effectiveEffort } : {}), ...(permissionMode ? { permissionMode } : {}), ...(permissionMode === "bypassPermissions" diff --git a/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts b/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts deleted file mode 100644 index ca6c9b44f2..0000000000 --- a/apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; -import { afterEach, describe, expect, it } from "vitest"; - -async function* emptyPrompt(): AsyncGenerator {} - -class FakeClaudeCodeProcess implements SpawnedProcess { - readonly stdin = new PassThrough(); - readonly stdout = new PassThrough(); - killed = false; - exitCode: number | null = null; - - private readonly events = new EventEmitter(); - private bufferedInput = ""; - - constructor( - private readonly onMessage: ( - message: Record, - process: FakeClaudeCodeProcess, - ) => void, - ) { - this.stdin.setEncoding("utf8"); - this.stdin.on("data", (chunk: string) => { - this.bufferedInput += chunk; - this.drainInput(); - }); - } - - emitJson(message: unknown): void { - this.stdout.write(`${JSON.stringify(message)}\n`); - } - - kill(_signal: NodeJS.Signals): boolean { - this.killed = true; - this.exitCode = 0; - this.stdout.end(); - this.events.emit("exit", 0, null); - return true; - } - - on( - event: "exit" | "error", - listener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | ((error: Error) => void), - ): void { - this.events.on(event, listener); - } - - once( - event: "exit" | "error", - listener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | ((error: Error) => void), - ): void { - this.events.once(event, listener); - } - - off( - event: "exit" | "error", - listener: - | ((code: number | null, signal: NodeJS.Signals | null) => void) - | ((error: Error) => void), - ): void { - this.events.off(event, listener); - } - - private drainInput(): void { - while (true) { - const newlineIndex = this.bufferedInput.indexOf("\n"); - if (newlineIndex === -1) { - return; - } - const line = this.bufferedInput.slice(0, newlineIndex).trim(); - this.bufferedInput = this.bufferedInput.slice(newlineIndex + 1); - if (line.length === 0) { - continue; - } - this.onMessage(JSON.parse(line) as Record, this); - } - } -} - -describe("Claude SDK fast mode probe", () => { - let activeQuery: ReturnType | null = null; - - afterEach(() => { - activeQuery?.close?.(); - activeQuery = null; - }); - - it("passes fast mode through the SDK settings flag", async () => { - let spawnOptions: SpawnOptions | undefined; - - activeQuery = query({ - prompt: emptyPrompt(), - options: { - persistSession: false, - settings: { - fastMode: true, - }, - spawnClaudeCodeProcess: (options): SpawnedProcess => { - spawnOptions = options; - return new FakeClaudeCodeProcess((message, process) => { - if ( - message.type !== "control_request" || - typeof message.request_id !== "string" || - !message.request || - typeof message.request !== "object" || - (message.request as { subtype?: unknown }).subtype !== "initialize" - ) { - return; - } - - process.emitJson({ - type: "control_response", - response: { - subtype: "success", - request_id: message.request_id, - response: { - commands: [], - agents: [], - output_style: "default", - available_output_styles: ["default"], - models: [], - account: { - subscriptionType: "max", - }, - fast_mode_state: "on", - }, - }, - }); - }); - }, - }, - }); - - const initialization = await activeQuery.initializationResult!(); - expect(initialization.fast_mode_state).toBe("on"); - - expect(spawnOptions).toBeDefined(); - const settingsFlagIndex = spawnOptions?.args.indexOf("--settings") ?? -1; - expect(settingsFlagIndex).toBeGreaterThan(-1); - expect(JSON.parse(spawnOptions?.args[settingsFlagIndex + 1] ?? "")).toEqual({ - fastMode: true, - }); - }); -}); diff --git a/apps/server/src/provider/claude-agent-sdk.d.ts b/apps/server/src/provider/claude-agent-sdk.d.ts index 37169ac76e..dd98e6c595 100644 --- a/apps/server/src/provider/claude-agent-sdk.d.ts +++ b/apps/server/src/provider/claude-agent-sdk.d.ts @@ -144,6 +144,8 @@ declare module "@anthropic-ai/claude-agent-sdk" { off(event: "exit" | "error", listener: (...args: unknown[]) => void): void; } + export type SettingSource = "user" | "project" | "local"; + export interface Options { readonly cwd?: string; readonly model?: string; @@ -160,6 +162,7 @@ declare module "@anthropic-ai/claude-agent-sdk" { readonly persistSession?: boolean; readonly sessionId?: string; readonly settings?: Record; + readonly settingSources?: SettingSource[]; readonly spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess; readonly canUseTool?: CanUseTool; readonly env?: Record; diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index b44cdd8830..cd508880b1 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Effect, FileSystem, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; @@ -38,11 +38,26 @@ import { GitCoreLive } from "./git/Layers/GitCore"; import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; import { SessionTextGenerationLive } from "./git/Layers/SessionTextGeneration"; -import { GitServiceLive } from "./git/Layers/GitService"; -import { BunPtyAdapterLive } from "./terminal/Layers/BunPTY"; -import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; +import { PtyAdapter } from "./terminal/Services/PTY"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +type RuntimePtyAdapterLoader = { + layer: Layer.Layer; +}; + +const runtimePtyAdapterLoaders = { + bun: () => import("./terminal/Layers/BunPTY"), + node: () => import("./terminal/Layers/NodePTY"), +} satisfies Record Promise>; + +const makeRuntimePtyAdapterLayer = () => + Effect.gen(function* () { + const runtime = process.versions.bun !== undefined ? "bun" : "node"; + const loader = runtimePtyAdapterLoaders[runtime]; + const ptyAdapterModule = yield* Effect.promise(loader); + return ptyAdapterModule.layer; + }).pipe(Layer.unwrap); + export function makeServerProviderLayer(): Layer.Layer< ProviderService, ProviderUnsupportedError, @@ -93,9 +108,9 @@ export function makeServerProviderLayer(): Layer.Layer< } export function makeServerRuntimeServicesLayer() { - const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); const textGenerationLayer = CodexTextGenerationLive; const sessionTextGenerationLayer = SessionTextGenerationLive; + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); const orchestrationLayer = OrchestrationEngineLive.pipe( Layer.provide(OrchestrationProjectionPipelineLive), @@ -105,13 +120,13 @@ export function makeServerRuntimeServicesLayer() { const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), - Layer.provideMerge(CheckpointStoreLive), + Layer.provideMerge(checkpointStoreLayer), ); const runtimeServicesLayer = Layer.mergeAll( orchestrationLayer, OrchestrationProjectionSnapshotQueryLive, - CheckpointStoreLive, + checkpointStoreLayer, checkpointDiffQueryLayer, RuntimeReceiptBusLive, ); @@ -120,7 +135,7 @@ export function makeServerRuntimeServicesLayer() { ); const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(GitCoreLive), Layer.provideMerge(textGenerationLayer), ); const checkpointReactorLayer = CheckpointReactorLive.pipe( @@ -132,16 +147,10 @@ export function makeServerRuntimeServicesLayer() { Layer.provideMerge(checkpointReactorLayer), ); - const terminalLayer = TerminalManagerLive.pipe( - Layer.provide( - typeof Bun !== "undefined" && process.platform !== "win32" - ? BunPtyAdapterLive - : NodePtyAdapterLive, - ), - ); + const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); const gitManagerLayer = GitManagerLive.pipe( - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(GitCoreLive), Layer.provideMerge(GitHubCliLive), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(sessionTextGenerationLayer), @@ -149,7 +158,7 @@ export function makeServerRuntimeServicesLayer() { return Layer.mergeAll( orchestrationReactorLayer, - gitCoreLayer, + GitCoreLive, gitManagerLayer, terminalLayer, KeybindingsLive, diff --git a/apps/server/src/terminal/Layers/BunPTY.ts b/apps/server/src/terminal/Layers/BunPTY.ts index 48b6492b19..1fb4bdd636 100644 --- a/apps/server/src/terminal/Layers/BunPTY.ts +++ b/apps/server/src/terminal/Layers/BunPTY.ts @@ -86,11 +86,13 @@ class BunPtyProcess implements PtyProcess { } } -export const BunPtyAdapterLive = Layer.effect( +export const layer = Layer.effect( PtyAdapter, Effect.gen(function* () { if (process.platform === "win32") { - return yield* Effect.die("Bun PTY terminal support is unavailable on Windows."); + return yield* Effect.die( + "Bun PTY terminal support is unavailable on Windows. Please use Node.js (e.g. by running `npx t3`) instead.", + ); } return { spawn: (input) => diff --git a/apps/server/src/terminal/Layers/NodePTY.ts b/apps/server/src/terminal/Layers/NodePTY.ts index 60a7ab6220..cf1fdd2198 100644 --- a/apps/server/src/terminal/Layers/NodePTY.ts +++ b/apps/server/src/terminal/Layers/NodePTY.ts @@ -84,7 +84,7 @@ class NodePtyProcess implements PtyProcess { } } -export const NodePtyAdapterLive = Layer.effect( +export const layer = Layer.effect( PtyAdapter, Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/web/package.json b/apps/web/package.json index 19d7d8b6fb..937cb9d82b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,7 @@ "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", - "msw": "^2.12.10", + "msw": "2.12.11", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index ac732c03fd..e8349005ef 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -865,6 +865,53 @@ function readPersistedAttachmentIdsFromStorage(threadId: ThreadId): string[] { } } +function verifyPersistedAttachments( + threadId: ThreadId, + attachments: PersistedComposerImageAttachment[], + set: ( + partial: + | ComposerDraftStoreState + | Partial + | (( + state: ComposerDraftStoreState, + ) => ComposerDraftStoreState | Partial), + replace?: false, + ) => void, +): void { + let persistedIdSet = new Set(); + try { + composerDebouncedStorage.flush(); + persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); + } catch { + persistedIdSet = new Set(); + } + set((state) => { + const current = state.draftsByThreadId[threadId]; + if (!current) { + return state; + } + const imageIdSet = new Set(current.images.map((image) => image.id)); + const persistedAttachments = attachments.filter( + (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id), + ); + const nonPersistedImageIds = current.images + .map((image) => image.id) + .filter((imageId) => !persistedIdSet.has(imageId)); + const nextDraft: ComposerThreadDraftState = { + ...current, + persistedAttachments, + nonPersistedImageIds, + }; + const nextDraftsByThreadId = { ...state.draftsByThreadId }; + if (shouldRemoveDraft(nextDraft)) { + delete nextDraftsByThreadId[threadId]; + } else { + nextDraftsByThreadId[threadId] = nextDraft; + } + return { draftsByThreadId: nextDraftsByThreadId }; + }); +} + function hydreatePersistedComposerImageAttachment( attachment: PersistedComposerImageAttachment, ): File | null { @@ -1684,32 +1731,7 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); Promise.resolve().then(() => { - const persistedIdSet = new Set(readPersistedAttachmentIdsFromStorage(threadId)); - set((state) => { - const current = state.draftsByThreadId[threadId]; - if (!current) { - return state; - } - const imageIdSet = new Set(current.images.map((image) => image.id)); - const persistedAttachments = attachments.filter( - (attachment) => imageIdSet.has(attachment.id) && persistedIdSet.has(attachment.id), - ); - const nonPersistedImageIds = current.images - .map((image) => image.id) - .filter((imageId) => !persistedIdSet.has(imageId)); - const nextDraft: ComposerThreadDraftState = { - ...current, - persistedAttachments, - nonPersistedImageIds, - }; - const nextDraftsByThreadId = { ...state.draftsByThreadId }; - if (shouldRemoveDraft(nextDraft)) { - delete nextDraftsByThreadId[threadId]; - } else { - nextDraftsByThreadId[threadId] = nextDraft; - } - return { draftsByThreadId: nextDraftsByThreadId }; - }); + verifyPersistedAttachments(threadId, attachments, set); }); }, clearComposerContent: (threadId) => { diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 193cb0e7a9..7cb377056b 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -10,11 +10,14 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { resolveShortcutCommand } from "../keybindings"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; -import { Sidebar, SidebarProvider } from "~/components/ui/sidebar"; import { resolveSidebarNewThreadEnvMode } from "~/components/Sidebar.logic"; import { useAppSettings } from "~/appSettings"; +import { Sidebar, SidebarProvider, SidebarRail } from "~/components/ui/sidebar"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const THREAD_SIDEBAR_WIDTH_STORAGE_KEY = "chat_thread_sidebar_width"; +const THREAD_SIDEBAR_MIN_WIDTH = 13 * 16; +const THREAD_MAIN_CONTENT_MIN_WIDTH = 40 * 16; function ChatRouteGlobalShortcuts() { const clearSelection = useThreadSelectionStore((state) => state.clearSelection); @@ -116,8 +119,15 @@ function ChatRouteLayout() { side="left" collapsible="offcanvas" className="border-r border-border bg-card text-foreground" + resizable={{ + minWidth: THREAD_SIDEBAR_MIN_WIDTH, + shouldAcceptWidth: ({ nextWidth, wrapper }) => + wrapper.clientWidth - nextWidth >= THREAD_MAIN_CONTENT_MIN_WIDTH, + storageKey: THREAD_SIDEBAR_WIDTH_STORAGE_KEY, + }} > + diff --git a/bun.lock b/bun.lock index 1d3d29dfa3..b2243ccc69 100644 --- a/bun.lock +++ b/bun.lock @@ -116,7 +116,7 @@ "@vitejs/plugin-react": "^6.0.0", "@vitest/browser-playwright": "^4.0.18", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", - "msw": "^2.12.10", + "msw": "2.12.11", "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "catalog:", diff --git a/scripts/claude-fast-mode-probe.ts b/scripts/claude-fast-mode-probe.ts deleted file mode 100644 index 272cc743f1..0000000000 --- a/scripts/claude-fast-mode-probe.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -type ProbeMode = "off" | "on"; - -async function* emptyPrompt(): AsyncGenerator {} - -function parseArgs(argv: ReadonlyArray): { - readonly mode: ProbeMode | "both"; - readonly model?: string; - readonly cwd?: string; - readonly prompt?: string; -} { - let mode: ProbeMode | "both" = "both"; - let model: string | undefined; - let cwd: string | undefined; - let prompt: string | undefined; - - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (!value) { - continue; - } - if (value === "--mode") { - const next = argv[index + 1]; - if (next === "off" || next === "on" || next === "both") { - mode = next; - index += 1; - } - continue; - } - if (value === "--model") { - model = argv[index + 1]; - index += 1; - continue; - } - if (value === "--cwd") { - cwd = argv[index + 1]; - index += 1; - continue; - } - if (value === "--prompt") { - prompt = argv[index + 1] ?? prompt; - index += 1; - } - } - - return { - mode, - ...(model ? { model } : {}), - ...(cwd ? { cwd } : {}), - ...(prompt ? { prompt } : {}), - }; -} - -async function runProbe(input: { - readonly mode: ProbeMode; - readonly model?: string; - readonly cwd?: string; - readonly prompt?: string; -}): Promise { - const messages = query({ - prompt: input.prompt ?? emptyPrompt(), - options: { - ...(input.model ? { model: input.model } : {}), - ...(input.cwd ? { cwd: input.cwd } : {}), - persistSession: false, - tools: [], - permissionMode: "plan", - includePartialMessages: true, - ...(input.mode === "on" ? { settings: { fastMode: true } } : {}), - }, - }); - - const summary = { - mode: input.mode, - initFastModeState: null as string | null, - resultFastModeState: null as string | null, - resultSubtype: null as string | null, - resultText: null as string | null, - }; - - const initialization = await messages.initializationResult(); - summary.initFastModeState = initialization.fast_mode_state ?? null; - - if (!input.prompt) { - messages.close(); - console.log(JSON.stringify(summary, null, 2)); - return; - } - - for await (const message of messages) { - handleProbeMessage(message, summary); - } - console.log(JSON.stringify(summary, null, 2)); -} - -function handleProbeMessage( - message: SDKMessage, - summary: { - mode: ProbeMode; - initFastModeState: string | null; - resultFastModeState: string | null; - resultSubtype: string | null; - resultText: string | null; - }, -): void { - if (message.type !== "result") { - return; - } - summary.resultSubtype = message.subtype; - summary.resultFastModeState = message.fast_mode_state ?? null; - summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); -} - -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const modes = args.mode === "both" ? (["off", "on"] as const) : [args.mode]; - - for (const mode of modes) { - await runProbe({ - mode, - ...(args.model ? { model: args.model } : {}), - ...(args.cwd ? { cwd: args.cwd } : {}), - ...(args.prompt ? { prompt: args.prompt } : {}), - }); - } -} - -await main(); diff --git a/scripts/claude-haiku-thinking-probe.ts b/scripts/claude-haiku-thinking-probe.ts deleted file mode 100644 index 8254e9d3dc..0000000000 --- a/scripts/claude-haiku-thinking-probe.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; - -type ProbeCase = - | "default" - | "effort-low" - | "effort-high" - | "settings-off" - | "settings-on" - | "thinking-disabled" - | "thinking-enabled"; - -function parseArgs(argv: ReadonlyArray): { - readonly model: string; - readonly cwd?: string; - readonly prompt: string; - readonly cases: ReadonlyArray; -} { - let model = "claude-haiku-4-5"; - let cwd: string | undefined; - let prompt = - "Think step by step about this before answering: what is 27 multiplied by 43? Return the final number only."; - let cases: ReadonlyArray = [ - "default", - "effort-low", - "effort-high", - "settings-off", - "settings-on", - "thinking-disabled", - "thinking-enabled", - ]; - - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (!value) { - continue; - } - if (value === "--model") { - model = argv[index + 1] ?? model; - index += 1; - continue; - } - if (value === "--cwd") { - cwd = argv[index + 1] ?? cwd; - index += 1; - continue; - } - if (value === "--prompt") { - prompt = argv[index + 1] ?? prompt; - index += 1; - continue; - } - if (value === "--cases") { - const next = argv[index + 1]; - if (next) { - const requested = next - .split(",") - .map((entry) => entry.trim()) - .filter( - (entry): entry is ProbeCase => - entry === "default" || - entry === "effort-low" || - entry === "effort-high" || - entry === "settings-off" || - entry === "settings-on" || - entry === "thinking-disabled" || - entry === "thinking-enabled", - ); - if (requested.length > 0) { - cases = requested; - } - } - index += 1; - } - } - - return { - model, - prompt, - ...(cwd ? { cwd } : {}), - cases, - }; -} - -function caseOptions(caseName: ProbeCase): { - readonly label: ProbeCase; - readonly effort?: "low" | "high"; - readonly settings?: { alwaysThinkingEnabled: boolean }; - readonly thinking?: ThinkingConfig; -} { - switch (caseName) { - case "effort-low": - return { - label: caseName, - effort: "low", - }; - case "effort-high": - return { - label: caseName, - effort: "high", - }; - case "settings-off": - return { - label: caseName, - settings: { - alwaysThinkingEnabled: false, - }, - }; - case "settings-on": - return { - label: caseName, - settings: { - alwaysThinkingEnabled: true, - }, - }; - case "thinking-disabled": - return { - label: caseName, - thinking: { - type: "disabled", - }, - }; - case "thinking-enabled": - return { - label: caseName, - thinking: { - type: "enabled", - budgetTokens: 1024, - }, - }; - default: - return { - label: caseName, - }; - } -} - -type ProbeSummary = { - readonly case: ProbeCase; - readonly model: string; - initModelInfo: { - value: string | null; - supportsEffort: boolean | null; - supportsAdaptiveThinking: boolean | null; - supportedEffortLevels: ReadonlyArray | null; - } | null; - resultSubtype: string | null; - resultText: string | null; - assistantThinkingBlockCount: number; - streamThinkingEventTypes: Record; - error: string | null; -}; - -async function runCase(input: { - readonly caseName: ProbeCase; - readonly model: string; - readonly cwd?: string; - readonly prompt: string; -}): Promise { - const caseConfig = caseOptions(input.caseName); - const summary: ProbeSummary = { - case: input.caseName, - model: input.model, - initModelInfo: null, - resultSubtype: null, - resultText: null, - assistantThinkingBlockCount: 0, - streamThinkingEventTypes: {}, - error: null, - }; - - try { - const messages = query({ - prompt: input.prompt, - options: { - model: input.model, - ...(input.cwd ? { cwd: input.cwd } : {}), - persistSession: false, - tools: [], - permissionMode: "bypassPermissions", - includePartialMessages: true, - maxTurns: 1, - ...(caseConfig.effort ? { effort: caseConfig.effort } : {}), - ...(caseConfig.settings ? { settings: caseConfig.settings } : {}), - ...(caseConfig.thinking ? { thinking: caseConfig.thinking } : {}), - }, - }); - - const initialization = await messages.initializationResult(); - const initModel = - initialization.models.find((candidate) => candidate.value === input.model) ?? - initialization.models.find((candidate) => candidate.value.includes("haiku")) ?? - null; - summary.initModelInfo = initModel - ? { - value: initModel.value, - supportsEffort: initModel.supportsEffort ?? null, - supportsAdaptiveThinking: initModel.supportsAdaptiveThinking ?? null, - supportedEffortLevels: initModel.supportedEffortLevels ?? null, - } - : null; - - for await (const message of messages) { - handleMessage(summary, message); - } - } catch (error) { - summary.error = error instanceof Error ? error.message : String(error); - } - - return summary; -} - -function handleMessage(summary: ProbeSummary, message: SDKMessage): void { - if (message.type === "stream_event") { - const event = message.event as { - type?: string; - delta?: { - type?: string; - }; - }; - const eventType = event.delta?.type ?? event.type; - if (typeof eventType === "string" && eventType.includes("thinking")) { - summary.streamThinkingEventTypes[eventType] = - (summary.streamThinkingEventTypes[eventType] ?? 0) + 1; - } - return; - } - - if (message.type === "assistant") { - for (const block of message.message.content) { - if (typeof block?.type === "string" && block.type.includes("thinking")) { - summary.assistantThinkingBlockCount += 1; - } - } - return; - } - - if (message.type === "result") { - summary.resultSubtype = message.subtype; - summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); - } -} - -async function main(): Promise { - const args = parseArgs(process.argv.slice(2)); - const results: ProbeSummary[] = []; - - for (const caseName of args.cases) { - results.push( - await runCase({ - caseName, - model: args.model, - ...(args.cwd ? { cwd: args.cwd } : {}), - prompt: args.prompt, - }), - ); - } - - console.log(JSON.stringify(results, null, 2)); -} - -await main();