diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 07892ec447..2838edcad7 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -59,6 +59,23 @@ function git( }); } +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* git(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + return yield* git(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + function runShellCommand(input: { command: string; cwd: string; @@ -587,7 +604,7 @@ it.layer(TestLayer)("git integration", (it) => { }), ); - it.effect("keeps checkout successful when upstream refresh fails", () => + it.effect("statusDetails remains successful when upstream refresh fails after checkout", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -612,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => { const realGitCore = yield* GitCore; let refreshFetchAttempts = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { refreshFetchAttempts += 1; return Effect.fail( new GitCommandError({ @@ -626,16 +643,15 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(refreshFetchAttempts).toBe(1); - }), - ); + const status = yield* core.statusDetails(source); + expect(refreshFetchAttempts).toBe(1); + expect(status.branch).toBe(featureBranch); + expect(status.upstreamRef).toBe(`origin/${featureBranch}`); expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); }), ); - it.effect("refresh fetch is scoped to the checked out branch upstream refspec", () => + it.effect("defers upstream refresh until statusDetails is requested", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); const source = yield* makeTmpDir(); @@ -657,10 +673,10 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(source, ["checkout", defaultBranch]); const realGitCore = yield* GitCore; - let fetchArgs: readonly string[] | null = null; + let refreshFetchAttempts = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { - fetchArgs = [...input.args]; + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + refreshFetchAttempts += 1; return Effect.succeed({ code: 0, stdout: "", @@ -672,73 +688,131 @@ it.layer(TestLayer)("git integration", (it) => { return realGitCore.execute(input); }); yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchArgs).not.toBeNull(); - }), - ); - - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - expect(fetchArgs).toEqual([ - "fetch", - "--quiet", - "--no-tags", - "origin", - `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, - ]); + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 50))); + expect(refreshFetchAttempts).toBe(0); + const status = yield* core.statusDetails(source); + expect(status.branch).toBe(featureBranch); + expect(refreshFetchAttempts).toBe(1); }), ); - it.effect("returns checkout result before background upstream refresh completes", () => + it.effect("shares upstream refreshes across worktrees that use the same git common dir", () => Effect.gen(function* () { - const remote = yield* makeTmpDir(); - const source = yield* makeTmpDir(); - yield* git(remote, ["init", "--bare"]); + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); - yield* initRepoWithCommit(source); - 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]); + let fetchCount = 0; + const core = yield* makeIsolatedGitCore((input) => { + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + return ok("origin/main\n"); + } + if (input.args[0] === "remote") { + return ok("origin\n"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + expect(input.cwd).toBe("/repo"); + return ok(); + } + if (input.operation === "GitCore.statusDetails.status") { + return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + } + if ( + input.operation === "GitCore.statusDetails.unstagedNumstat" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Unexpected git command in shared refresh cache test.", + }), + ); + }); - const featureBranch = "feature/background-refresh"; - yield* git(source, ["checkout", "-b", 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* git(source, ["checkout", defaultBranch]); + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); + }), + ); - const realGitCore = yield* GitCore; - let fetchStarted = false; - let releaseFetch!: () => void; - const waitForReleasePromise = new Promise((resolve) => { - releaseFetch = resolve; - }); + it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () => + Effect.gen(function* () { + const ok = (stdout = "") => + Effect.succeed({ + code: 0, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + + let fetchCount = 0; const core = yield* makeIsolatedGitCore((input) => { - if (input.args[0] === "fetch") { - fetchStarted = true; - return Effect.promise(() => - waitForReleasePromise.then(() => ({ - code: 0, - stdout: "", - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, - })), + if ( + input.args[0] === "rev-parse" && + input.args[1] === "--abbrev-ref" && + input.args[2] === "--symbolic-full-name" && + input.args[3] === "@{upstream}" + ) { + return ok("origin/main\n"); + } + if (input.args[0] === "remote") { + return ok("origin\n"); + } + if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") { + return ok("/repo/.git\n"); + } + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchCount += 1; + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "simulated fetch timeout", + }), ); } - return realGitCore.execute(input); + if (input.operation === "GitCore.statusDetails.status") { + return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n"); + } + if ( + input.operation === "GitCore.statusDetails.unstagedNumstat" || + input.operation === "GitCore.statusDetails.stagedNumstat" + ) { + return ok(); + } + return Effect.fail( + new GitCommandError({ + operation: input.operation, + command: `git ${input.args.join(" ")}`, + cwd: input.cwd, + detail: "Unexpected git command in refresh failure cooldown test.", + }), + ); }); - yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); - yield* Effect.promise(() => - vi.waitFor(() => { - expect(fetchStarted).toBe(true); - }), - ); - expect(yield* git(source, ["branch", "--show-current"])).toBe(featureBranch); - releaseFetch(); + + yield* core.statusDetails("/repo/worktrees/main"); + yield* core.statusDetails("/repo/worktrees/pr-123"); + expect(fetchCount).toBe(1); }), ); @@ -779,16 +853,21 @@ it.layer(TestLayer)("git integration", (it) => { it.effect("checks out a remote tracking branch when remote name contains slashes", () => Effect.gen(function* () { const remote = yield* makeTmpDir(); + const prefixRemote = yield* makeTmpDir(); const source = yield* makeTmpDir(); + const prefixFetchNamespace = "prefix-my-org"; + const prefixRemoteName = "my-org"; const remoteName = "my-org/upstream"; const featureBranch = "feature"; yield* git(remote, ["init", "--bare"]); + yield* git(prefixRemote, ["init", "--bare"]); yield* initRepoWithCommit(source); const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( (branch) => branch.current, )!.name; - yield* git(source, ["remote", "add", remoteName, remote]); + yield* configureRemote(source, prefixRemoteName, prefixRemote, prefixFetchNamespace); + yield* configureRemote(source, remoteName, remote, remoteName); yield* git(source, ["push", "-u", remoteName, defaultBranch]); yield* git(source, ["checkout", "-b", featureBranch]); @@ -805,6 +884,34 @@ it.layer(TestLayer)("git integration", (it) => { }); expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); + const realGitCore = yield* GitCore; + let fetchArgs: readonly string[] | null = null; + const core = yield* makeIsolatedGitCore((input) => { + if (input.args[0] === "--git-dir" && input.args[2] === "fetch") { + fetchArgs = [...input.args]; + return Effect.succeed({ + code: 0, + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + } + return realGitCore.execute(input); + }); + + const status = yield* core.statusDetails(source); + expect(status.branch).toBe("upstream/feature"); + expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`); + expect(fetchArgs).toEqual([ + "--git-dir", + path.join(source, ".git"), + "fetch", + "--quiet", + "--no-tags", + remoteName, + `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`, + ]); }), ); @@ -1691,6 +1798,47 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("pushes to the tracked upstream when the remote name contains slashes", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + const prefixRemote = yield* makeTmpDir(); + const prefixFetchNamespace = "prefix-my-org"; + const prefixRemoteName = "my-org"; + const remoteName = "my-org/upstream"; + const featureBranch = "feature/slash-remote-push"; + yield* git(remote, ["init", "--bare"]); + yield* git(prefixRemote, ["init", "--bare"]); + + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* configureRemote(tmp, prefixRemoteName, prefixRemote, prefixFetchNamespace); + yield* configureRemote(tmp, remoteName, remote, remoteName); + yield* git(tmp, ["push", "-u", remoteName, initialBranch]); + + yield* git(tmp, ["checkout", "-b", featureBranch]); + yield* writeTextFile(path.join(tmp, "feature.txt"), "first revision\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature base"]); + yield* git(tmp, ["push", "-u", remoteName, featureBranch]); + + yield* writeTextFile(path.join(tmp, "feature.txt"), "second revision\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature update"]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(false); + expect(pushed.upstreamBranch).toBe(`${remoteName}/${featureBranch}`); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + `${remoteName}/${featureBranch}`, + ); + expect(yield* git(tmp, ["ls-remote", "--heads", remoteName, featureBranch])).toContain( + featureBranch, + ); + }), + ); + it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index cbb2c67449..0a11abab5b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -27,6 +27,11 @@ import { type ExecuteGitInput, type ExecuteGitResult, } from "../Services/GitCore.ts"; +import { + parseRemoteNames, + parseRemoteNamesInGitOrder, + parseRemoteRefWithRemoteNames, +} from "../remoteRefs.ts"; import { ServerConfig } from "../../config.ts"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; @@ -41,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); +const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; @@ -50,7 +56,7 @@ type TraceTailState = { }; class StatusUpstreamRefreshCacheKey extends Data.Class<{ - cwd: string; + gitCommonDir: string; upstreamRef: string; remoteName: string; upstreamBranch: string; @@ -177,14 +183,6 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } -function parseRemoteNames(stdout: string): ReadonlyArray { - return stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .toSorted((a, b) => b.length - a.length); -} - function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -217,30 +215,41 @@ function parseRemoteFetchUrls(stdout: string): Map { return remotes; } -function parseRemoteRefWithRemoteNames( - branchName: string, +function parseUpstreamRefWithRemoteNames( + upstreamRef: string, remoteNames: ReadonlyArray, -): { remoteRef: string; remoteName: string; localBranch: string } | null { - const trimmedBranchName = branchName.trim(); - if (trimmedBranchName.length === 0) return null; +): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { + const parsed = parseRemoteRefWithRemoteNames(upstreamRef, remoteNames); + if (!parsed) { + return null; + } - for (const remoteName of remoteNames) { - const remotePrefix = `${remoteName}/`; - if (!trimmedBranchName.startsWith(remotePrefix)) { - continue; - } - const localBranch = trimmedBranchName.slice(remotePrefix.length).trim(); - if (localBranch.length === 0) { - return null; - } - return { - remoteRef: trimmedBranchName, - remoteName, - localBranch, - }; + return { + upstreamRef, + remoteName: parsed.remoteName, + upstreamBranch: parsed.branchName, + }; +} + +function parseUpstreamRefByFirstSeparator( + upstreamRef: string, +): { upstreamRef: string; remoteName: string; upstreamBranch: string } | null { + const separatorIndex = upstreamRef.indexOf("/"); + if (separatorIndex <= 0 || separatorIndex === upstreamRef.length - 1) { + return null; } - return null; + const remoteName = upstreamRef.slice(0, separatorIndex).trim(); + const upstreamBranch = upstreamRef.slice(separatorIndex + 1).trim(); + if (remoteName.length === 0 || upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; } function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { @@ -792,45 +801,27 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { return null; } - const separatorIndex = upstreamRef.indexOf("/"); - if (separatorIndex <= 0) { - return null; - } - const remoteName = upstreamRef.slice(0, separatorIndex); - const upstreamBranch = upstreamRef.slice(separatorIndex + 1); - if (remoteName.length === 0 || upstreamBranch.length === 0) { - return null; - } - - return { - upstreamRef, - remoteName, - 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, + const remoteNames = yield* runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( + Effect.map(parseRemoteNames), + Effect.catch(() => Effect.succeed>([])), ); - }; + return ( + parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef) + ); + }); const fetchUpstreamRefForStatus = ( - cwd: string, + gitCommonDir: string, upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, ): Effect.Effect => { const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + const fetchCwd = + path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; return executeGit( "GitCore.fetchUpstreamRefForStatus", - cwd, - ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { allowNonZeroExit: true, timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), @@ -838,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ).pipe(Effect.asVoid); }; + const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { + const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [ + "rev-parse", + "--git-common-dir", + ]).pipe(Effect.map((stdout) => stdout.trim())); + return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); + }); + const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* ( cacheKey: StatusUpstreamRefreshCacheKey, ) { - yield* fetchUpstreamRefForStatus(cacheKey.cwd, { + yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, { upstreamRef: cacheKey.upstreamRef, remoteName: cacheKey.remoteName, upstreamBranch: cacheKey.upstreamBranch, @@ -852,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const statusUpstreamRefreshCache = yield* Cache.makeWith({ capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, lookup: refreshStatusUpstreamCacheEntry, - // Keep successful refreshes warm; drop failures immediately so next request can retry. - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), + // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms. + timeToLive: (exit) => + Exit.isSuccess(exit) + ? STATUS_UPSTREAM_REFRESH_INTERVAL + : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN, }); const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* ( @@ -861,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ) { const upstream = yield* resolveCurrentUpstream(cwd); if (!upstream) return; + const gitCommonDir = yield* resolveGitCommonDir(cwd); yield* Cache.get( statusUpstreamRefreshCache, new StatusUpstreamRefreshCacheKey({ - cwd, + gitCommonDir, upstreamRef: upstream.upstreamRef, remoteName: upstream.remoteName, upstreamBranch: upstream.upstreamBranch, @@ -872,14 +875,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { ); }); - const refreshCheckedOutBranchUpstream = Effect.fn("refreshCheckedOutBranchUpstream")(function* ( - cwd: string, - ) { - const upstream = yield* resolveCurrentUpstream(cwd); - if (!upstream) return; - yield* fetchUpstreamRef(cwd, upstream); - }); - const resolveDefaultBranchName = ( cwd: string, remoteName: string, @@ -919,7 +914,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( - Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), + Effect.map(parseRemoteNamesInGitOrder), ); const resolvePrimaryRemoteName = Effect.fn("resolvePrimaryRemoteName")(function* (cwd: string) { @@ -1939,12 +1934,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { timeoutMs: 10_000, fallbackErrorMessage: "git checkout failed", }); - - // Refresh upstream refs in the background so checkout remains responsive. - yield* refreshCheckedOutBranchUpstream(input.cwd).pipe( - Effect.ignoreCause({ log: true }), - Effect.forkDetach({ startImmediately: true }), - ); }, ); diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 76d7d30a47..280679e337 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -187,7 +187,7 @@ const makeGitHubCli = Effect.sync(() => { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e05fc30875..6fd55030fd 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -25,6 +25,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; + prListSequenceByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { @@ -77,6 +78,72 @@ interface FakeGitTextGeneration { type FakePullRequest = NonNullable; +function normalizeFakePullRequestSummary(raw: unknown): GitHubPullRequestSummary | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const record = raw as Record; + const number = record.number; + const title = record.title; + const url = record.url; + const baseRefName = record.baseRefName; + const headRefName = record.headRefName; + const headRepository = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwner = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + + if ( + typeof number !== "number" || + typeof title !== "string" || + typeof url !== "string" || + typeof baseRefName !== "string" || + typeof headRefName !== "string" + ) { + return null; + } + + const state = + typeof record.state === "string" + ? record.state === "OPEN" || record.state === "open" + ? "open" + : record.state === "CLOSED" || record.state === "closed" + ? "closed" + : "merged" + : undefined; + const isCrossRepository = + typeof record.isCrossRepository === "boolean" ? record.isCrossRepository : undefined; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepository?.nameWithOwner === "string" + ? headRepository.nameWithOwner + : undefined; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwner?.login === "string" + ? headRepositoryOwner.login + : undefined; + + return { + number, + title, + url, + baseRefName, + headRefName, + ...(state ? { state } : {}), + ...(isCrossRepository !== undefined ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { const result = spawnSync("git", args, { cwd, @@ -159,6 +226,23 @@ function createBareRemote(): Effect.Effect< }); } +function configureRemote( + cwd: string, + remoteName: string, + remotePath: string, + fetchNamespace: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* runGit(cwd, ["config", `remote.${remoteName}.url`, remotePath]); + yield* runGit(cwd, [ + "config", + "--replace-all", + `remote.${remoteName}.fetch`, + `+refs/heads/*:refs/remotes/${fetchNamespace}/*`, + ]); + }); +} + function createTextGeneration(overrides: Partial = {}): TextGenerationShape { const implementation: FakeGitTextGeneration = { generateCommitMessage: (input) => @@ -236,6 +320,12 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { ghCalls: string[]; } { const prListQueue = [...(scenario.prListSequence ?? [])]; + const prListQueueByHeadSelector = new Map( + Object.entries(scenario.prListSequenceByHeadSelector ?? {}).map(([headSelector, values]) => [ + headSelector, + [...values], + ]), + ); const ghCalls: string[] = []; const execute: GitHubCliShape["execute"] = (input) => { @@ -252,11 +342,15 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 ? args[headSelectorIndex + 1] : undefined; + const mappedQueue = + typeof headSelector === "string" + ? prListQueueByHeadSelector.get(headSelector)?.shift() + : undefined; const mappedStdout = typeof headSelector === "string" ? scenario.prListByHeadSelector?.[headSelector] : undefined; - const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; + const stdout = (mappedQueue ?? mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed({ stdout, stderr: "", @@ -410,11 +504,14 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( - Effect.map( - (result) => JSON.parse(result.stdout) as ReadonlyArray, + Effect.map((result) => JSON.parse(result.stdout) as unknown[]), + Effect.map((raw) => + raw + .map((entry) => normalizeFakePullRequestSummary(entry)) + .filter((entry): entry is GitHubPullRequestSummary => entry !== null), ), ), createPullRequest: (input) => @@ -473,7 +570,7 @@ function runStackedAction( manager: GitManagerShape, input: { cwd: string; - action: "commit" | "commit_push" | "commit_push_pr"; + action: "commit" | "push" | "create_pr" | "commit_push" | "commit_push_pr"; actionId?: string; commitMessage?: string; featureBranch?: boolean; @@ -575,6 +672,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status briefly caches repeated lookups for the same cwd", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]); + + const existingPr = { + number: 113, + title: "Cached PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/113", + baseRefName: "main", + headRefName: "feature/status-cache", + }; + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.pr?.number).toBe(113); + expect(second.pr?.number).toBe(113); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect( + "status ignores unrelated fork PRs when the current branch tracks the same repository", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR from main", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "main", + state: "OPEN", + updatedAt: "2026-04-01T15:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("main"); + expect(status.pr).toBeNull(); + }), + ); + it.effect( "status detects cross-repo PRs from the upstream remote URL owner", () => @@ -610,6 +779,13 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { headRefName: "statemachine", state: "OPEN", updatedAt: "2026-03-10T07:00:00Z", + isCrossRepository: true, + headRepository: { + nameWithOwner: "jasonLaster/codething-mvp", + }, + headRepositoryOwner: { + login: "jasonLaster", + }, }, ]), ], @@ -627,8 +803,116 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { state: "open", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + ); + }), + 12_000, + ); + + it.effect( + "status ignores synthetic local branch aliases when the upstream remote name contains slashes", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + state: "OPEN", + updatedAt: "2026-03-01T10:00:00Z", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + state: "OPEN", + updatedAt: "2026-04-01T10:00:00Z", + }, + ]), + }, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("upstream/effect-atom"); + expect(status.pr).toEqual({ + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseBranch: "main", + headBranch: "effect-atom", + state: "open", + }); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, ); + expect( + ghCalls.some((call) => call.includes("pr list --head pingdotgg:upstream/effect-atom ")), + ).toBe(false); + expect( + ghCalls.some((call) => + call.includes("pr list --head my-org/upstream:upstream/effect-atom "), + ), + ).toBe(false); }), 12_000, ); @@ -758,6 +1042,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("skipped_not_requested"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Push", + action: { + kind: "push", + }, + }, + }); + expect(result.toast.title).toMatch(/^Committed [0-9a-f]{7}$/); expect( yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -867,6 +1162,19 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.name).toBe("feature/implement-stacked-git-actions"); expect(result.commit.status).toBe("created"); expect(result.push.status).toBe("pushed"); + expect(result.toast).toMatchObject({ + description: "Implement stacked git actions", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", + }, + }, + }); + expect(result.toast.title).toMatch( + /^Pushed [0-9a-f]{7} to origin\/feature\/implement-stacked-git-actions$/, + ); expect( yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "HEAD"]).pipe( Effect.map((result) => result.stdout.trim()), @@ -1063,6 +1371,80 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("pushes existing clean commits without rerunning commit logic", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/push-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "push-only.txt"), "push only\n"); + yield* runGit(repoDir, ["add", "push-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Push only branch"]); + + const { manager } = yield* makeManager(); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "push", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("skipped_not_requested"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((output) => output.stdout.trim()), + ), + ).toBe("origin/feature/push-only"); + }), + ); + + it.effect("create_pr pushes a clean branch before creating the PR when needed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/create-pr-only"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "create-pr-only.txt"), "create pr\n"); + yield* runGit(repoDir, ["add", "create-pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Create PR only branch"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 303, + title: "Create PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/303", + baseRefName: "main", + headRefName: "feature/create-pr-only", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "create_pr", + }); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(303); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/create-pr-only"), + ), + ).toBe(true); + }), + ); + it.effect("returns existing PR metadata for commit/push/pr action", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1095,6 +1477,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(42); + expect(result.toast).toEqual({ + title: "Opened PR #42", + description: "Existing PR", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + }, + }); expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); }), ); @@ -1126,6 +1517,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), ], @@ -1149,6 +1548,98 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { 12_000, ); + it.effect( + "returns the correct existing PR when a slash remote checks out to a synthetic local alias", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* configureRemote(repoDir, "origin", originDir, "origin"); + yield* configureRemote(repoDir, "my-org/upstream", upstreamDir, "my-org/upstream"); + + yield* runGit(repoDir, ["checkout", "-b", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "origin", "effect-atom"]); + yield* runGit(repoDir, ["push", "-u", "my-org/upstream", "effect-atom"]); + yield* runGit(repoDir, [ + "config", + "remote.origin.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.origin.pushurl", originDir]); + yield* runGit(repoDir, [ + "config", + "remote.my-org/upstream.url", + "git@github.com:pingdotgg/codething-mvp.git", + ]); + yield* runGit(repoDir, ["config", "remote.my-org/upstream.pushurl", upstreamDir]); + yield* runGit(repoDir, ["checkout", "main"]); + yield* runGit(repoDir, ["branch", "-D", "effect-atom"]); + yield* runGit(repoDir, ["checkout", "--track", "my-org/upstream/effect-atom"]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "effect-atom": JSON.stringify([ + { + number: 1618, + title: "Correct PR", + url: "https://github.com/pingdotgg/t3code/pull/1618", + baseRefName: "main", + headRefName: "effect-atom", + }, + ]), + "upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "pingdotgg:effect-atom": JSON.stringify([]), + "my-org/upstream:effect-atom": JSON.stringify([]), + "pingdotgg:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + "my-org/upstream:upstream/effect-atom": JSON.stringify([ + { + number: 1518, + title: "Wrong PR", + url: "https://github.com/pingdotgg/t3code/pull/1518", + baseRefName: "main", + headRefName: "upstream/effect-atom", + }, + ]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(1618); + expect(ghCalls.some((call) => call.includes("pr list --head upstream/effect-atom "))).toBe( + false, + ); + }), + 12_000, + ); + it.effect( "prefers owner-qualified selectors before bare branch names for cross-repo PRs", () => @@ -1187,6 +1678,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1239,6 +1738,14 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { url: "https://github.com/pingdotgg/codething-mvp/pull/142", baseRefName: "main", headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }, ]), "fork-seed:statemachine": JSON.stringify([]), @@ -1256,9 +1763,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.pr.status).toBe("opened_existing"); expect(result.pr.number).toBe(142); - const prListCalls = ghCalls.filter((call) => call.startsWith("pr list ")); - expect(prListCalls).toHaveLength(1); - expect(prListCalls[0]).toContain( + const openLookupCalls = ghCalls.filter((call) => call.includes("--state open --limit 1")); + expect(openLookupCalls).toHaveLength(1); + expect(openLookupCalls[0]).toContain( "pr list --head octocat:statemachine --state open --limit 1", ); }), @@ -1302,6 +1809,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch.status).toBe("skipped_not_requested"); expect(result.pr.status).toBe("created"); expect(result.pr.number).toBe(88); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); expect( ghCalls.some((call) => call.includes("pr create --base main --head feature-create-pr")), ).toBe(true); @@ -1309,6 +1817,78 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "creates a new PR instead of reusing an unrelated fork PR with the same head branch", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-fork-match"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "changes.txt"), "change\n"); + yield* runGit(repoDir, ["add", "changes.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Feature commit"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/no-fork-match"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 1661, + title: "Fork PR with same branch name", + url: "https://github.com/pingdotgg/t3code/pull/1661", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "lnieuwenhuis/t3code", + }, + headRepositoryOwner: { + login: "lnieuwenhuis", + }, + }, + ]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "feature/no-fork-match", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect(result.toast).toEqual({ + title: "Created PR #188", + description: "Add stacked git actions", + cta: { + kind: "open_pr", + label: "View PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + }, + }); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-fork-match"), + ), + ).toBe(true); + }), + ); + it.effect("creates cross-repo PRs with the fork owner selector and default base branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1330,23 +1910,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const { manager, ghCalls } = yield* makeManager({ ghScenario: { - prListSequence: [ - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([]), - JSON.stringify([ - { - number: 188, - title: "Add stacked git actions", - url: "https://github.com/pingdotgg/codething-mvp/pull/188", - baseRefName: "main", - headRefName: "statemachine", - }, - ]), - ], + prListSequenceByHeadSelector: { + "octocat:statemachine": [ + JSON.stringify([]), + JSON.stringify([ + { + number: 188, + title: "Add stacked git actions", + url: "https://github.com/pingdotgg/codething-mvp/pull/188", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, + }, + ]), + ], + "fork-seed:statemachine": [JSON.stringify([])], + statemachine: [JSON.stringify([])], + }, }, }); @@ -2090,4 +2677,71 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ); }), ); + + it.effect("create_pr emits only the PR phase when the branch is already pushed", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-only-follow-up"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "pr-only.txt"), "pr only\n"); + yield* runGit(repoDir, ["add", "pr-only.txt"]); + yield* runGit(repoDir, ["commit", "-m", "PR only branch"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-only-follow-up"]); + + const { manager } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 201, + title: "PR only branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/201", + baseRefName: "main", + headRefName: "feature/pr-only-follow-up", + state: "OPEN", + isCrossRepository: false, + }, + ]), + ], + }, + }); + const events: GitActionProgressEvent[] = []; + + const result = yield* runStackedAction( + manager, + { + cwd: repoDir, + action: "create_pr", + }, + { + actionId: "action-pr-only", + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ); + + expect(result.commit.status).toBe("skipped_not_requested"); + expect(result.push.status).toBe("skipped_not_requested"); + expect(result.pr.status).toBe("created"); + expect( + events.filter( + (event): event is Extract => + event.kind === "phase_started", + ), + ).toEqual([ + expect.objectContaining({ + kind: "phase_started", + phase: "pr", + label: "Creating PR...", + }), + ]); + }), + ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index f8445cf09a..ca9d562c03 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,11 +1,12 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect"; +import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, GitRunStackedActionResult, + GitStackedAction, ModelSelection, } from "@t3tools/contracts"; import { @@ -22,13 +23,18 @@ import { type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; import { GitCore } from "../Services/GitCore.ts"; -import { GitHubCli } from "../Services/GitHubCli.ts"; +import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; +import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; const COMMIT_TIMEOUT_MS = 10 * 60_000; const MAX_PROGRESS_TEXT_LENGTH = 500; +const SHORT_SHA_LENGTH = 7; +const TOAST_DESCRIPTION_MAX = 72; +const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); +const STATUS_RESULT_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; @@ -40,7 +46,7 @@ interface OpenPrInfo { headRefName: string; } -interface PullRequestInfo extends OpenPrInfo { +interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; updatedAt: string | null; } @@ -135,6 +141,94 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; } +function normalizeOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function normalizeOptionalRepositoryNameWithOwner(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function normalizeOptionalOwnerLogin(value: string | null | undefined): string | null { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : null; +} + +function resolvePullRequestHeadRepositoryNameWithOwner( + pr: PullRequestHeadRemoteInfo & { url: string }, +) { + const explicitRepository = normalizeOptionalString(pr.headRepositoryNameWithOwner); + if (explicitRepository) { + return explicitRepository; + } + + if (!pr.isCrossRepository) { + return null; + } + + const ownerLogin = normalizeOptionalString(pr.headRepositoryOwnerLogin); + const repositoryName = parseRepositoryNameFromPullRequestUrl(pr.url); + if (!ownerLogin || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function matchesBranchHeadContext( + pr: PullRequestInfo, + headContext: Pick< + BranchHeadContext, + "headBranch" | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + >, +): boolean { + if (pr.headRefName !== headContext.headBranch) { + return false; + } + + const expectedHeadRepository = normalizeOptionalRepositoryNameWithOwner( + headContext.headRepositoryNameWithOwner, + ); + const expectedHeadOwner = + normalizeOptionalOwnerLogin(headContext.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(expectedHeadRepository); + const prHeadRepository = normalizeOptionalRepositoryNameWithOwner( + resolvePullRequestHeadRepositoryNameWithOwner(pr), + ); + const prHeadOwner = + normalizeOptionalOwnerLogin(pr.headRepositoryOwnerLogin) ?? + parseRepositoryOwnerLogin(prHeadRepository); + + if (headContext.isCrossRepository) { + if (pr.isCrossRepository === false) { + return false; + } + if ((expectedHeadRepository || expectedHeadOwner) && !prHeadRepository && !prHeadOwner) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; + } + + if (pr.isCrossRepository === true) { + return false; + } + if (expectedHeadRepository && prHeadRepository && expectedHeadRepository !== prHeadRepository) { + return false; + } + if (expectedHeadOwner && prHeadOwner && expectedHeadOwner !== prHeadOwner) { + return false; + } + return true; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -150,6 +244,27 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { const state = record.state; const mergedAt = record.mergedAt; const updatedAt = record.updatedAt; + const isCrossRepository = record.isCrossRepository; + const headRepositoryRecord = + typeof record.headRepository === "object" && record.headRepository !== null + ? (record.headRepository as Record) + : null; + const headRepositoryOwnerRecord = + typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null + ? (record.headRepositoryOwner as Record) + : null; + const headRepositoryNameWithOwner = + typeof record.headRepositoryNameWithOwner === "string" + ? record.headRepositoryNameWithOwner + : typeof headRepositoryRecord?.nameWithOwner === "string" + ? headRepositoryRecord.nameWithOwner + : null; + const headRepositoryOwnerLogin = + typeof record.headRepositoryOwnerLogin === "string" + ? record.headRepositoryOwnerLogin + : typeof headRepositoryOwnerRecord?.login === "string" + ? headRepositoryOwnerRecord.login + : null; if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) { continue; } @@ -163,11 +278,15 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { } let normalizedState: "open" | "closed" | "merged"; - if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") { + if ( + (typeof mergedAt === "string" && mergedAt.trim().length > 0) || + state === "MERGED" || + state === "merged" + ) { normalizedState = "merged"; - } else if (state === "OPEN" || state === undefined || state === null) { + } else if (state === "OPEN" || state === "open" || state === undefined || state === null) { normalizedState = "open"; - } else if (state === "CLOSED") { + } else if (state === "CLOSED" || state === "closed") { normalizedState = "closed"; } else { continue; @@ -181,11 +300,35 @@ function parsePullRequestList(raw: unknown): PullRequestInfo[] { headRefName, state: normalizedState, updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null, + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), }); } return parsed; } +function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo { + return { + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state ?? "open", + updatedAt: null, + ...(summary.isCrossRepository !== undefined + ? { isCrossRepository: summary.isCrossRepository } + : {}), + ...(summary.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: summary.headRepositoryNameWithOwner } + : {}), + ...(summary.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: summary.headRepositoryOwnerLogin } + : {}), + }; +} + function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { return new GitManagerError({ operation, @@ -199,6 +342,57 @@ function limitContext(value: string, maxChars: number): string { return `${value.slice(0, maxChars)}\n\n[truncated]`; } +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function truncateText( + value: string | undefined, + maxLength = TOAST_DESCRIPTION_MAX, +): string | undefined { + if (!value) return undefined; + if (value.length <= maxLength) return value; + if (maxLength <= 3) return "...".slice(0, maxLength); + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function withDescription(title: string, description: string | undefined) { + return description ? { title, description } : { title }; +} + +function summarizeGitActionResult( + result: Pick, +): { + title: string; + description?: string; +} { + if (result.pr.status === "created" || result.pr.status === "opened_existing") { + const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; + const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; + return withDescription(title, truncateText(result.pr.title)); + } + + if (result.push.status === "pushed") { + const shortSha = shortenSha(result.commit.commitSha); + const branch = result.push.upstreamBranch ?? result.push.branch; + const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; + const branchPart = branch ? ` to ${branch}` : ""; + return withDescription( + `Pushed${pushedCommitPart}${branchPart}`, + truncateText(result.commit.subject), + ); + } + + if (result.commit.status === "created") { + const shortSha = shortenSha(result.commit.commitSha); + const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; + return withDescription(title, truncateText(result.commit.subject)); + } + + return { title: "Done" }; +} + function sanitizeCommitMessage(generated: { subject: string; body: string; @@ -236,6 +430,12 @@ interface CommitAndBranchSuggestion { commitMessage: string; } +function isCommitAction( + action: GitStackedAction, +): action is "commit" | "commit_push" | "commit_push_pr" { + return action === "commit" || action === "commit_push" || action === "commit_push_pr"; +} + function formatCommitMessage(subject: string, body: string): string { const trimmedBody = body.trim(); if (trimmedBody.length === 0) { @@ -262,25 +462,6 @@ function parseCustomCommitMessage(raw: string): { subject: string; body: string }; } -function extractBranchFromRef(ref: string): string { - const normalized = ref.trim(); - - if (normalized.startsWith("refs/remotes/")) { - const withoutPrefix = normalized.slice("refs/remotes/".length); - const firstSlash = withoutPrefix.indexOf("/"); - if (firstSlash === -1) { - return withoutPrefix.trim(); - } - return withoutPrefix.slice(firstSlash + 1).trim(); - } - - const firstSlash = normalized.indexOf("/"); - if (firstSlash === -1) { - return normalized; - } - return normalized.slice(firstSlash + 1).trim(); -} - function appendUnique(values: string[], next: string | null | undefined): void { const trimmed = next?.trim() ?? ""; if (trimmed.length === 0 || values.includes(trimmed)) { @@ -368,7 +549,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const serverSettingsService = yield* ServerSettingsService; const createProgressEmitter = ( - input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, + input: { cwd: string; action: GitStackedAction }, options?: GitRunStackedActionOptions, ) => { const actionId = options?.actionId ?? randomUUID(); @@ -505,6 +686,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const path = yield* Path.Path; const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; + const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); + const readStatus = Effect.fn("readStatus")(function* (cwd: string) { + const details = yield* gitCore.statusDetails(cwd); + + const pr = + details.branch !== null + ? yield* findLatestPr(cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), + Effect.catch(() => Effect.succeed(null)), + ) + : null; + + return { + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr, + }; + }); + const statusResultCache = yield* Cache.makeWith({ + capacity: STATUS_RESULT_CACHE_CAPACITY, + lookup: readStatus, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + const invalidateStatusResultCache = (cwd: string) => + Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); @@ -534,9 +747,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) { const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); const headBranchFromUpstream = details.upstreamRef - ? extractBranchFromRef(details.upstreamRef) + ? extractBranchNameFromRemoteRef(details.upstreamRef, { remoteName }) : ""; const headBranch = headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + const shouldProbeLocalBranchSelector = + headBranchFromUpstream.length === 0 || headBranch === details.branch; const [remoteRepository, originRepository] = yield* Effect.all( [ @@ -572,7 +787,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, ); } - appendUnique(headSelectors, details.branch); + if (shouldProbeLocalBranchSelector) { + appendUnique(headSelectors, details.branch); + } appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { appendUnique(headSelectors, ownerHeadSelector); @@ -597,16 +814,26 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const findOpenPr = Effect.fn("findOpenPr")(function* ( cwd: string, - headSelectors: ReadonlyArray, + headContext: Pick< + BranchHeadContext, + | "headBranch" + | "headSelectors" + | "headRepositoryNameWithOwner" + | "headRepositoryOwnerLogin" + | "isCrossRepository" + >, ) { - for (const headSelector of headSelectors) { + for (const headSelector of headContext.headSelectors) { const pullRequests = yield* gitHubCli.listOpenPullRequests({ cwd, headSelector, limit: 1, }); + const normalizedPullRequests = pullRequests.map(toPullRequestInfo); - const [firstPullRequest] = pullRequests; + const firstPullRequest = normalizedPullRequests.find((pullRequest) => + matchesBranchHeadContext(pullRequest, headContext), + ); if (firstPullRequest) { return { number: firstPullRequest.number, @@ -644,7 +871,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { "--limit", "20", "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe(Effect.map((result) => result.stdout)); @@ -661,6 +888,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); for (const pr of parsePullRequestList(parsedJson)) { + if (!matchesBranchHeadContext(pr, headContext)) { + continue; + } parsedByNumber.set(pr.number, pr); } } @@ -678,17 +908,119 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); + const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { + const branches = yield* gitCore.listBranches({ cwd }); + const currentBranch = branches.branches.find((candidate) => candidate.name === branch); + return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); + }); + + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( + cwd: string, + result: Pick, + ) { + const summary = summarizeGitActionResult(result); + let latestOpenPr: PullRequestInfo | null = null; + let currentBranchIsDefault = false; + let finalBranchContext: { + branch: string; + upstreamRef: string | null; + hasUpstream: boolean; + } | null = null; + + if (result.action !== "commit") { + const finalStatus = yield* gitCore.statusDetails(cwd); + if (finalStatus.branch) { + finalBranchContext = { + branch: finalStatus.branch, + upstreamRef: finalStatus.upstreamRef, + hasUpstream: finalStatus.hasUpstream, + }; + currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( + Effect.catch(() => + Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), + ), + ); + } + } + + const explicitResultPr = + (result.pr.status === "created" || result.pr.status === "opened_existing") && result.pr.url + ? { + url: result.pr.url, + state: "open" as const, + } + : null; + const shouldLookupExistingOpenPr = + (result.action === "commit_push" || result.action === "push") && + result.push.status === "pushed" && + result.branch.status !== "created" && + !currentBranchIsDefault && + explicitResultPr === null && + finalBranchContext?.hasUpstream === true; + + if (shouldLookupExistingOpenPr && finalBranchContext) { + latestOpenPr = yield* resolveBranchHeadContext(cwd, { + branch: finalBranchContext.branch, + upstreamRef: finalBranchContext.upstreamRef, + }).pipe( + Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), + Effect.catch(() => Effect.succeed(null)), + ); + } + + const openPr = latestOpenPr ?? explicitResultPr; + + const cta = + result.action === "commit" && result.commit.status === "created" + ? { + kind: "run_action" as const, + label: "Push", + action: { kind: "push" as const }, + } + : (result.action === "push" || + result.action === "create_pr" || + result.action === "commit_push" || + result.action === "commit_push_pr") && + openPr?.url && + (!currentBranchIsDefault || + result.pr.status === "created" || + result.pr.status === "opened_existing") + ? { + kind: "open_pr" as const, + label: "View PR", + url: openPr.url, + } + : (result.action === "push" || result.action === "commit_push") && + result.push.status === "pushed" && + !currentBranchIsDefault + ? { + kind: "run_action" as const, + label: "Create PR", + action: { kind: "create_pr" as const }, + } + : { + kind: "none" as const, + }; + + return { + ...summary, + cta, + }; + }); + const resolveBaseBranch = Effect.fn("resolveBaseBranch")(function* ( cwd: string, branch: string, upstreamRef: string | null, - headContext: Pick, + headContext: Pick, ) { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; if (upstreamRef && !headContext.isCrossRepository) { - const upstreamBranch = extractBranchFromRef(upstreamRef); + const upstreamBranch = extractBranchNameFromRemoteRef(upstreamRef, { + remoteName: headContext.remoteName, + }); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return upstreamBranch; } @@ -889,7 +1221,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: details.upstreamRef, }); - const existing = yield* findOpenPr(cwd, headContext.headSelectors); + const existing = yield* findOpenPr(cwd, headContext); if (existing) { return { status: "opened_existing" as const, @@ -932,7 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - const created = yield* findOpenPr(cwd, headContext.headSelectors); + const created = yield* findOpenPr(cwd, headContext); if (!created) { return { status: "created" as const, @@ -953,28 +1285,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - const details = yield* gitCore.statusDetails(input.cwd); - - const pr = - details.branch !== null - ? yield* findLatestPr(input.cwd, { - branch: details.branch, - upstreamRef: details.upstreamRef, - }).pipe( - Effect.map((latest) => (latest ? toStatusPr(latest) : null)), - Effect.catch(() => Effect.succeed(null)), - ) - : null; - - return { - branch: details.branch, - hasWorkingTreeChanges: details.hasWorkingTreeChanges, - workingTree: details.workingTree, - hasUpstream: details.hasUpstream, - aheadCount: details.aheadCount, - behindCount: details.behindCount, - pr, - }; + return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); }); const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( @@ -993,143 +1304,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( "preparePullRequestThread", )(function* (input) { - const normalizedReference = normalizePullRequestReference(input.reference); - const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequestSummary = yield* gitHubCli.getPullRequest({ - cwd: input.cwd, - reference: normalizedReference, - }); - const pullRequest = toResolvedPullRequest(pullRequestSummary); - - if (input.mode === "local") { - yield* gitHubCli.checkoutPullRequest({ + return yield* Effect.gen(function* () { + const normalizedReference = normalizePullRequestReference(input.reference); + const rootWorktreePath = canonicalizeExistingPath(input.cwd); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ cwd: input.cwd, reference: normalizedReference, - force: true, }); - const details = yield* gitCore.statusDetails(input.cwd); - yield* configurePullRequestHeadUpstream( - input.cwd, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - return { - pullRequest, - branch: details.branch ?? pullRequest.headBranch, - worktreePath: null, - }; - } + const pullRequest = toResolvedPullRequest(pullRequestSummary); - const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( - worktreePath: string, - ) { - const details = yield* gitCore.statusDetails(worktreePath); - yield* configurePullRequestHeadUpstream( - worktreePath, - { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - }, - details.branch ?? pullRequest.headBranch, - ); - }); + if (input.mode === "local") { + yield* gitHubCli.checkoutPullRequest({ + cwd: input.cwd, + reference: normalizedReference, + force: true, + }); + const details = yield* gitCore.statusDetails(input.cwd); + yield* configurePullRequestHeadUpstream( + input.cwd, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + return { + pullRequest, + branch: details.branch ?? pullRequest.headBranch, + worktreePath: null, + }; + } - const pullRequestWithRemoteInfo = { - ...pullRequest, - ...toPullRequestHeadRemoteInfo(pullRequestSummary), - } as const; - const localPullRequestBranch = - resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); - - const findLocalHeadBranch = (cwd: string) => - gitCore.listBranches({ cwd }).pipe( - Effect.map((result) => { - const localBranch = result.branches.find( - (branch) => !branch.isRemote && branch.name === localPullRequestBranch, - ); - if (localBranch) { - return localBranch; - } - if (localPullRequestBranch === pullRequest.headBranch) { - return null; - } - return ( - result.branches.find( - (branch) => - !branch.isRemote && - branch.name === pullRequest.headBranch && - branch.worktreePath !== null && - canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, - ) ?? null - ); - }), - ); + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* gitCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }, + details.branch ?? pullRequest.headBranch, + ); + }); - const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) - : null; - if ( - existingBranchBeforeFetch?.worktreePath && - existingBranchBeforeFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); - return { - pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchBeforeFetch.worktreePath, - }; - } - if (existingBranchBeforeFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); + + const findLocalHeadBranch = (cwd: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, ); - } - yield* materializePullRequestHeadBranch( - input.cwd, - pullRequestWithRemoteInfo, - localPullRequestBranch, - ); + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* gitManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + const worktree = yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); - const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); - const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath - ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) - : null; - if ( - existingBranchAfterFetch?.worktreePath && - existingBranchAfterFetchPath !== rootWorktreePath - ) { - yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); return { pullRequest, - branch: localPullRequestBranch, - worktreePath: existingBranchAfterFetch.worktreePath, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, }; - } - if (existingBranchAfterFetchPath === rootWorktreePath) { - return yield* gitManagerError( - "preparePullRequestThread", - "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", - ); - } - - const worktree = yield* gitCore.createWorktree({ - cwd: input.cwd, - branch: localPullRequestBranch, - path: null, - }); - yield* ensureExistingWorktreeUpstream(worktree.worktree.path); - - return { - pullRequest, - branch: worktree.worktree.branch, - worktreePath: worktree.worktree.path, - }; + }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); }); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -1171,27 +1484,53 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const runStackedAction: GitManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( function* (input, options) { const progress = createProgressEmitter(input, options); - const phases: GitActionProgressPhase[] = [ - ...(input.featureBranch ? (["branch"] as const) : []), - "commit", - ...(input.action !== "commit" ? (["push"] as const) : []), - ...(input.action === "commit_push_pr" ? (["pr"] as const) : []), - ]; const currentPhase = yield* Ref.make>(Option.none()); const runAction = Effect.fn("runStackedAction.runAction")(function* (): Effect.fn.Return< GitRunStackedActionResult, GitManagerServiceError > { + const initialStatus = yield* gitCore.statusDetails(input.cwd); + const wantsCommit = isCommitAction(input.action); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && + (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + + if (input.featureBranch && !wantsCommit) { + return yield* gitManagerError( + "runStackedAction", + "Feature-branch checkout is only supported for commit actions.", + ); + } + if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit or stash local changes before pushing.", + ); + } + if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { + return yield* gitManagerError( + "runStackedAction", + "Commit local changes before creating a PR.", + ); + } + + const phases: GitActionProgressPhase[] = [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ]; + yield* progress.emit({ kind: "action_started", phases, }); - const wantsPush = input.action !== "commit"; - const wantsPr = input.action === "commit_push_pr"; - - const initialStatus = yield* gitCore.statusDetails(input.cwd); if (!input.featureBranch && wantsPush && !initialStatus.branch) { return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD."); } @@ -1235,19 +1574,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { } const currentBranch = branchStep.name ?? initialStatus.branch; - - yield* Ref.set(currentPhase, Option.some("commit")); - const commit = yield* runCommitStep( - modelSelection, - input.cwd, - input.action, - currentBranch, - commitMessageForStep, - preResolvedCommitSuggestion, - input.filePaths, - options?.progressReporter, - progress.actionId, - ); + const commitAction = isCommitAction(input.action) ? input.action : null; + + const commit = commitAction + ? yield* Ref.set(currentPhase, Option.some("commit")).pipe( + Effect.flatMap(() => + runCommitStep( + modelSelection, + input.cwd, + commitAction, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + options?.progressReporter, + progress.actionId, + ), + ), + ) + : { status: "skipped_not_requested" as const }; const push = wantsPush ? yield* progress @@ -1275,12 +1620,21 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) : { status: "skipped_not_requested" as const }; + const toast = yield* buildCompletionToast(input.cwd, { + action: input.action, + branch: branchStep, + commit, + push, + pr, + }); + const result = { action: input.action, branch: branchStep, commit, push, pr, + toast, }; yield* progress.emit({ kind: "action_finished", @@ -1290,6 +1644,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }); return yield* runAction().pipe( + Effect.ensuring(invalidateStatusResultCache(input.cwd)), Effect.tapError((error) => Effect.flatMap(Ref.get(currentPhase), (phase) => progress.emit({ diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index a99e4d3bc4..86842257b4 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -56,7 +56,7 @@ export interface GitManagerShape { ) => Effect.Effect; /** - * Run a stacked Git action (`commit`, `commit_push`, `commit_push_pr`). + * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. */ readonly runStackedAction: ( diff --git a/apps/server/src/git/remoteRefs.ts b/apps/server/src/git/remoteRefs.ts new file mode 100644 index 0000000000..b5dfc87bd0 --- /dev/null +++ b/apps/server/src/git/remoteRefs.ts @@ -0,0 +1,67 @@ +export function parseRemoteNamesInGitOrder(stdout: string): ReadonlyArray { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +export function parseRemoteNames(stdout: string): ReadonlyArray { + return parseRemoteNamesInGitOrder(stdout).toSorted((a, b) => b.length - a.length); +} + +export function parseRemoteRefWithRemoteNames( + ref: string, + remoteNames: ReadonlyArray, +): { remoteRef: string; remoteName: string; branchName: string } | null { + const trimmedRef = ref.trim(); + if (trimmedRef.length === 0) { + return null; + } + + for (const remoteName of remoteNames) { + const remotePrefix = `${remoteName}/`; + if (!trimmedRef.startsWith(remotePrefix)) { + continue; + } + const branchName = trimmedRef.slice(remotePrefix.length).trim(); + if (branchName.length === 0) { + return null; + } + return { + remoteRef: trimmedRef, + remoteName, + branchName, + }; + } + + return null; +} + +export function extractBranchNameFromRemoteRef( + ref: string, + options?: { + remoteName?: string | null; + remoteNames?: ReadonlyArray; + }, +): string { + const normalized = ref.trim(); + if (normalized.length === 0) { + return ""; + } + + if (normalized.startsWith("refs/remotes/")) { + return extractBranchNameFromRemoteRef(normalized.slice("refs/remotes/".length), options); + } + + const remoteNames = options?.remoteName ? [options.remoteName] : (options?.remoteNames ?? []); + const parsedRemoteRef = parseRemoteRefWithRemoteNames(normalized, remoteNames); + if (parsedRemoteRef) { + return parsedRemoteRef.branchName; + } + + const firstSlash = normalized.indexOf("/"); + if (firstSlash === -1) { + return normalized; + } + return normalized.slice(firstSlash + 1).trim(); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0e53eae43e..53829f37b8 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -842,6 +842,17 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, push: { status: "skipped_not_requested" as const }, pr: { status: "skipped_not_requested" as const }, + toast: { + title: "Committed abc123", + description: "feat: demo", + cta: { + kind: "run_action" as const, + label: "Push", + action: { + kind: "push" as const, + }, + }, + }, }; yield* ( diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 44ad29efac..f21c924c79 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -6,8 +6,9 @@ import { requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, resolveQuickAction, - summarizeGitResult, + resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; function status(overrides: Partial = {}): GitStatusResult { @@ -162,7 +163,7 @@ describe("when: branch is clean, ahead, and has an open PR", () => { }), false, ); - assert.deepInclude(quick, { kind: "run_action", action: "commit_push", label: "Push" }); + assert.deepInclude(quick, { kind: "run_action", action: "push", label: "Push" }); }); it("buildMenuItems enables push and keeps open PR available", () => { @@ -213,7 +214,7 @@ describe("when: branch is clean, ahead, and has no open PR", () => { const quick = resolveQuickAction(status({ aheadCount: 2, pr: null }), false); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push_pr", + action: "create_pr", label: "Push & create PR", }); }); @@ -585,7 +586,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push", + action: "push", label: "Push", disabled: false, }); @@ -632,7 +633,7 @@ describe("when: branch has no upstream configured", () => { ); assert.deepInclude(quick, { kind: "run_action", - action: "commit_push_pr", + action: "create_pr", label: "Push & create PR", disabled: false, }); @@ -801,9 +802,12 @@ describe("when: branch has no upstream configured", () => { describe("requiresDefaultBranchConfirmation", () => { it("requires confirmation for push actions on default branch", () => { assert.isFalse(requiresDefaultBranchConfirmation("commit", true)); + assert.isTrue(requiresDefaultBranchConfirmation("push", true)); + assert.isTrue(requiresDefaultBranchConfirmation("create_pr", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push", true)); assert.isTrue(requiresDefaultBranchConfirmation("commit_push_pr", true)); assert.isFalse(requiresDefaultBranchConfirmation("commit_push", false)); + assert.isFalse(requiresDefaultBranchConfirmation("push", false)); }); }); @@ -855,28 +859,37 @@ describe("resolveDefaultBranchActionDialogCopy", () => { }); describe("buildGitActionProgressStages", () => { - it("shows only push progress when push-only is forced", () => { + it("shows only push progress for explicit push actions", () => { const stages = buildGitActionProgressStages({ - action: "commit_push", + action: "push", hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, + hasWorkingTreeChanges: false, pushTarget: "origin/feature/test", }); assert.deepEqual(stages, ["Pushing to origin/feature/test..."]); }); - it("skips commit stages for create-pr flow when push-only is forced", () => { + it("shows push and PR progress for create-pr actions that still need a push", () => { const stages = buildGitActionProgressStages({ - action: "commit_push_pr", + action: "create_pr", hasCustomCommitMessage: false, - hasWorkingTreeChanges: true, - forcePushOnly: true, + hasWorkingTreeChanges: false, pushTarget: "origin/feature/test", + shouldPushBeforePr: true, }); assert.deepEqual(stages, ["Pushing to origin/feature/test...", "Creating PR..."]); }); + it("shows only PR progress when create-pr can skip the push", () => { + const stages = buildGitActionProgressStages({ + action: "create_pr", + hasCustomCommitMessage: false, + hasWorkingTreeChanges: false, + shouldPushBeforePr: false, + }); + assert.deepEqual(stages, ["Creating PR..."]); + }); + it("includes commit stages for commit+push when working tree is dirty", () => { const stages = buildGitActionProgressStages({ action: "commit_push", @@ -892,97 +905,92 @@ describe("buildGitActionProgressStages", () => { }); }); -describe("summarizeGitResult", () => { - it("returns commit-focused toast for commit action", () => { - const result = summarizeGitResult({ - action: "commit", - branch: { status: "skipped_not_requested" }, +describe("resolveThreadBranchUpdate", () => { + it("returns a branch update when the action created a new branch", () => { + const update = resolveThreadBranchUpdate({ + action: "commit_push_pr", + branch: { + status: "created", + name: "feature/fix-toast-copy", + }, commit: { status: "created", - commitSha: "0123456789abcdef", - subject: "feat: add optimistic UI for git action button", + commitSha: "89abcdef01234567", + subject: "feat: add branch sync", }, - push: { status: "skipped_not_requested" }, + push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, + toast: { + title: "Pushed 89abcde to origin/feature/fix-toast-copy", + cta: { kind: "none" }, + }, }); - assert.deepEqual(result, { - title: "Committed 0123456", - description: "feat: add optimistic UI for git action button", + assert.deepEqual(update, { + branch: "feature/fix-toast-copy", }); }); - it("returns push-focused toast for push action", () => { - const result = summarizeGitResult({ + it("returns null when the action stayed on the existing branch", () => { + const update = resolveThreadBranchUpdate({ action: "commit_push", - branch: { status: "skipped_not_requested" }, + branch: { + status: "skipped_not_requested", + }, commit: { status: "created", - commitSha: "abcdef0123456789", - subject: "fix: tighten quick action tooltip hover handling", - }, - push: { - status: "pushed", - branch: "foo", - upstreamBranch: "origin/foo", + commitSha: "89abcdef01234567", + subject: "feat: add branch sync", }, + push: { status: "pushed", branch: "feature/fix-toast-copy" }, pr: { status: "skipped_not_requested" }, + toast: { + title: "Pushed 89abcde to origin/feature/fix-toast-copy", + cta: { kind: "none" }, + }, }); - assert.deepEqual(result, { - title: "Pushed abcdef0 to origin/foo", - description: "fix: tighten quick action tooltip hover handling", - }); + assert.equal(update, null); }); +}); - it("returns PR-focused toast for created PR action", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "feat: ship github shortcuts", - }, - push: { - status: "pushed", - branch: "foo", - }, - pr: { - status: "created", - number: 42, - title: "feat: ship github shortcuts and improve PR CTA in success toast", - }, +describe("resolveLiveThreadBranchUpdate", () => { + it("returns a branch update when live git status differs from stored thread metadata", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/old-branch", + gitStatus: status({ branch: "effect-atom" }), }); - assert.deepEqual(result, { - title: "Created PR #42", - description: "feat: ship github shortcuts and improve PR CTA in success toast", + assert.deepEqual(update, { + branch: "effect-atom", }); }); - it("truncates long description text", () => { - const result = summarizeGitResult({ - action: "commit_push_pr", - branch: { status: "skipped_not_requested" }, - commit: { - status: "created", - commitSha: "89abcdef01234567", - subject: "short subject", - }, - push: { status: "pushed", branch: "foo" }, - pr: { - status: "created", - number: 99, - title: - "feat: this title is intentionally extremely long so we can validate that toast descriptions are truncated with an ellipsis suffix", - }, + it("returns null when live git status is unavailable", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "feature/old-branch", + gitStatus: null, }); - assert.deepEqual(result, { - title: "Created PR #99", - description: "feat: this title is intentionally extremely long so we can validate t...", + assert.equal(update, null); + }); + + it("returns null when the stored thread branch already matches git status", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "effect-atom", + gitStatus: status({ branch: "effect-atom" }), }); + + assert.equal(update, null); + }); + + it("returns null when git status is detached HEAD but the thread already has a branch", () => { + const update = resolveLiveThreadBranchUpdate({ + threadBranch: "effect-atom", + gitStatus: status({ branch: null }), + }); + + assert.equal(update, null); }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 8f7f023ef7..80906a982b 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -31,43 +31,36 @@ export interface DefaultBranchActionDialogCopy { continueLabel: string; } -export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; - -const SHORT_SHA_LENGTH = 7; -const TOAST_DESCRIPTION_MAX = 72; - -function shortenSha(sha: string | undefined): string | null { - if (!sha) return null; - return sha.slice(0, SHORT_SHA_LENGTH); -} - -function truncateText( - value: string | undefined, - maxLength = TOAST_DESCRIPTION_MAX, -): string | undefined { - if (!value) return undefined; - if (value.length <= maxLength) return value; - if (maxLength <= 3) return "...".slice(0, maxLength); - return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; -} +export type DefaultBranchConfirmableAction = + | "push" + | "create_pr" + | "commit_push" + | "commit_push_pr"; export function buildGitActionProgressStages(input: { action: GitStackedAction; hasCustomCommitMessage: boolean; hasWorkingTreeChanges: boolean; - forcePushOnly?: boolean; pushTarget?: string; featureBranch?: boolean; + shouldPushBeforePr?: boolean; }): string[] { const branchStages = input.featureBranch ? ["Preparing feature branch..."] : []; - const shouldIncludeCommitStages = - !input.forcePushOnly && (input.action === "commit" || input.hasWorkingTreeChanges); + const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; + + if (input.action === "push") { + return [pushStage]; + } + if (input.action === "create_pr") { + return input.shouldPushBeforePr ? [pushStage, "Creating PR..."] : ["Creating PR..."]; + } + + const shouldIncludeCommitStages = input.action === "commit" || input.hasWorkingTreeChanges; const commitStages = !shouldIncludeCommitStages ? [] : input.hasCustomCommitMessage ? ["Committing..."] : ["Generating commit message...", "Committing..."]; - const pushStage = input.pushTarget ? `Pushing to ${input.pushTarget}...` : "Pushing..."; if (input.action === "commit") { return [...branchStages, ...commitStages]; } @@ -77,39 +70,6 @@ export function buildGitActionProgressStages(input: { return [...branchStages, ...commitStages, pushStage, "Creating PR..."]; } -const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - -export function summarizeGitResult(result: GitRunStackedActionResult): { - title: string; - description?: string; -} { - if (result.pr.status === "created" || result.pr.status === "opened_existing") { - const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; - const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; - return withDescription(title, truncateText(result.pr.title)); - } - - if (result.push.status === "pushed") { - const shortSha = shortenSha(result.commit.commitSha); - const branch = result.push.upstreamBranch ?? result.push.branch; - const pushedCommitPart = shortSha ? ` ${shortSha}` : ""; - const branchPart = branch ? ` to ${branch}` : ""; - return withDescription( - `Pushed${pushedCommitPart}${branchPart}`, - truncateText(result.commit.subject), - ); - } - - if (result.commit.status === "created") { - const shortSha = shortenSha(result.commit.commitSha); - const title = shortSha ? `Committed ${shortSha}` : "Committed changes"; - return withDescription(title, truncateText(result.commit.subject)); - } - - return { title: "Done" }; -} - export function buildMenuItems( gitStatus: GitStatusResult | null, isBusy: boolean, @@ -250,13 +210,18 @@ export function resolveQuickAction( }; } if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + return { + label: "Push", + disabled: false, + kind: "run_action", + action: isDefaultBranch ? "commit_push" : "push", + }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "commit_push_pr", + action: "create_pr", }; } @@ -279,13 +244,18 @@ export function resolveQuickAction( if (isAhead) { if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; + return { + label: "Push", + disabled: false, + kind: "run_action", + action: isDefaultBranch ? "commit_push" : "push", + }; } return { label: "Push & create PR", disabled: false, kind: "run_action", - action: "commit_push_pr", + action: "create_pr", }; } @@ -306,7 +276,12 @@ export function requiresDefaultBranchConfirmation( isDefaultBranch: boolean, ): boolean { if (!isDefaultBranch) return false; - return action === "commit_push" || action === "commit_push_pr"; + return ( + action === "push" || + action === "create_pr" || + action === "commit_push" || + action === "commit_push_pr" + ); } export function resolveDefaultBranchActionDialogCopy(input: { @@ -317,7 +292,7 @@ export function resolveDefaultBranchActionDialogCopy(input: { const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; - if (input.action === "commit_push") { + if (input.action === "push" || input.action === "commit_push") { if (input.includesCommit) { return { title: "Commit & push to default branch?", @@ -346,5 +321,38 @@ export function resolveDefaultBranchActionDialogCopy(input: { }; } +export function resolveThreadBranchUpdate( + result: GitRunStackedActionResult, +): { branch: string } | null { + if (result.branch.status !== "created" || !result.branch.name) { + return null; + } + + return { + branch: result.branch.name, + }; +} + +export function resolveLiveThreadBranchUpdate(input: { + threadBranch: string | null; + gitStatus: GitStatusResult | null; +}): { branch: string | null } | null { + if (!input.gitStatus) { + return null; + } + + if (input.gitStatus.branch === null && input.threadBranch !== null) { + return null; + } + + if (input.threadBranch === input.gitStatus.branch) { + return null; + } + + return { + branch: input.gitStatus.branch, + }; +} + // Re-export from shared for backwards compatibility in this module's exports export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6384709620..d8d86dd4f0 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1,5 +1,6 @@ import type { GitActionProgressEvent, + GitRunStackedActionResult, GitStackedAction, GitStatusResult, ThreadId, @@ -17,8 +18,9 @@ import { type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveDefaultBranchActionDialogCopy, + resolveLiveThreadBranchUpdate, resolveQuickAction, - summarizeGitResult, + resolveThreadBranchUpdate, } from "./GitActionsControl.logic"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -47,9 +49,10 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { randomUUID } from "~/lib/utils"; +import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; +import { useStore } from "~/store"; interface GitActionsControlProps { gitCwd: string | null; @@ -61,7 +64,6 @@ interface PendingDefaultBranchAction { branchName: string; includesCommit: boolean; commitMessage?: string; - forcePushOnlyProgress: boolean; onConfirmed?: () => void; filePaths?: string[]; } @@ -82,12 +84,10 @@ interface ActiveGitActionProgress { interface RunGitActionWithToastInput { action: GitStackedAction; commitMessage?: string; - forcePushOnlyProgress?: boolean; onConfirmed?: () => void; skipDefaultBranchPrompt?: boolean; statusOverride?: GitStatusResult | null; featureBranch?: boolean; - isDefaultBranchOverride?: boolean; progressToastId?: GitActionToastId; filePaths?: string[]; } @@ -196,7 +196,9 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; - if (quickAction.action === "commit_push") return ; + if (quickAction.action === "push" || quickAction.action === "commit_push") { + return ; + } return ; } if (quickAction.label === "Commit") return ; @@ -208,6 +210,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], ); + const activeServerThread = useStore((store) => + activeThreadId ? store.threads.find((thread) => thread.id === activeThreadId) : undefined, + ); + const setThreadBranch = useStore((store) => store.setThreadBranch); const queryClient = useQueryClient(); const [isCommitDialogOpen, setIsCommitDialogOpen] = useState(false); const [dialogCommitMessage, setDialogCommitMessage] = useState(""); @@ -231,6 +237,43 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); }, [threadToastData]); + const persistThreadBranchSync = useCallback( + (branch: string | null) => { + if (!activeThreadId || !activeServerThread || activeServerThread.branch === branch) { + return; + } + + const worktreePath = activeServerThread.worktreePath; + const api = readNativeApi(); + if (api) { + void api.orchestration + .dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: activeThreadId, + branch, + worktreePath, + }) + .catch(() => undefined); + } + + setThreadBranch(activeThreadId, branch, worktreePath); + }, + [activeServerThread, activeThreadId, setThreadBranch], + ); + + const syncThreadBranchAfterGitAction = useCallback( + (result: GitRunStackedActionResult) => { + const branchUpdate = resolveThreadBranchUpdate(result); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch); + }, + [persistThreadBranchSync], + ); + const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); @@ -267,6 +310,28 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + + useEffect(() => { + if (isGitActionRunning) { + return; + } + + const branchUpdate = resolveLiveThreadBranchUpdate({ + threadBranch: activeServerThread?.branch ?? null, + gitStatus: gitStatusForActions, + }); + if (!branchUpdate) { + return; + } + + persistThreadBranchSync(branchUpdate.branch); + }, [ + activeServerThread?.branch, + gitStatusForActions, + isGitActionRunning, + persistThreadBranchSync, + ]); + const isDefaultBranch = useMemo(() => { const branchName = gitStatusForActions?.branch; if (!branchName) return false; @@ -340,27 +405,32 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions async ({ action, commitMessage, - forcePushOnlyProgress = false, onConfirmed, skipDefaultBranchPrompt = false, statusOverride, featureBranch = false, - isDefaultBranchOverride, progressToastId, filePaths, }: RunGitActionWithToastInput) => { const actionStatus = statusOverride ?? gitStatusForActions; const actionBranch = actionStatus?.branch ?? null; - const actionIsDefaultBranch = - isDefaultBranchOverride ?? (featureBranch ? false : isDefaultBranch); + const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; + const actionCanCommit = + action === "commit" || action === "commit_push" || action === "commit_push_pr"; const includesCommit = - !forcePushOnlyProgress && (action === "commit" || !!actionStatus?.hasWorkingTreeChanges); + actionCanCommit && + (action === "commit" || !!actionStatus?.hasWorkingTreeChanges || featureBranch); if ( !skipDefaultBranchPrompt && requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { - if (action !== "commit_push" && action !== "commit_push_pr") { + if ( + action !== "push" && + action !== "create_pr" && + action !== "commit_push" && + action !== "commit_push_pr" + ) { return; } setPendingDefaultBranchAction({ @@ -368,7 +438,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions branchName: actionBranch, includesCommit, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -380,8 +449,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, - forcePushOnly: forcePushOnlyProgress, featureBranch, + shouldPushBeforePr: + action === "create_pr" && + (!actionStatus?.hasUpstream || (actionStatus?.aheadCount ?? 0) > 0), }); const actionId = randomUUID(); const resolvedProgressToastId = @@ -483,79 +554,48 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions try { const result = await promise; activeGitActionProgressRef.current = null; - const resultToast = summarizeGitResult(result); - - const existingOpenPrUrl = - actionStatus?.pr?.state === "open" ? actionStatus.pr.url : undefined; - const prUrl = result.pr.url ?? existingOpenPrUrl; - const shouldOfferPushCta = action === "commit" && result.commit.status === "created"; - const shouldOfferOpenPrCta = - (action === "commit_push" || action === "commit_push_pr") && - !!prUrl && - (!actionIsDefaultBranch || - result.pr.status === "created" || - result.pr.status === "opened_existing"); - const shouldOfferCreatePrCta = - action === "commit_push" && - !prUrl && - result.push.status === "pushed" && - !actionIsDefaultBranch; + syncThreadBranchAfterGitAction(result); const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; + const toastCta = result.toast.cta; + let toastActionProps: { + children: string; + onClick: () => void; + } | null = null; + if (toastCta.kind === "run_action") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + closeResultToast(); + void runGitActionWithToast({ + action: toastCta.action.kind, + }); + }, + }; + } else if (toastCta.kind === "open_pr") { + toastActionProps = { + children: toastCta.label, + onClick: () => { + const api = readNativeApi(); + if (!api) return; + closeResultToast(); + void api.shell.openExternal(toastCta.url); + }, + }; + } + toastManager.update(resolvedProgressToastId, { type: "success", - title: resultToast.title, - description: resultToast.description, + title: result.toast.title, + description: result.toast.description, timeout: 0, data: { ...threadToastData, dismissAfterVisibleMs: 10_000, }, - ...(shouldOfferPushCta - ? { - actionProps: { - children: "Push", - onClick: () => { - void runGitActionWithToast({ - action: "commit_push", - forcePushOnlyProgress: true, - onConfirmed: closeResultToast, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : shouldOfferOpenPrCta - ? { - actionProps: { - children: "View PR", - onClick: () => { - const api = readNativeApi(); - if (!api) return; - closeResultToast(); - void api.shell.openExternal(prUrl); - }, - }, - } - : shouldOfferCreatePrCta - ? { - actionProps: { - children: "Create PR", - onClick: () => { - closeResultToast(); - void runGitActionWithToast({ - action: "commit_push_pr", - forcePushOnlyProgress: true, - statusOverride: actionStatus, - isDefaultBranchOverride: actionIsDefaultBranch, - }); - }, - }, - } - : {}), + ...(toastActionProps ? { actionProps: toastActionProps } : {}), }); } catch (err) { activeGitActionProgressRef.current = null; @@ -571,13 +611,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const continuePendingDefaultBranchAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), skipDefaultBranchPrompt: true, @@ -586,13 +624,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const checkoutFeatureBranchAndContinuePendingAction = useCallback(() => { if (!pendingDefaultBranchAction) return; - const { action, commitMessage, forcePushOnlyProgress, onConfirmed, filePaths } = - pendingDefaultBranchAction; + const { action, commitMessage, onConfirmed, filePaths } = pendingDefaultBranchAction; setPendingDefaultBranchAction(null); void runGitActionWithToast({ action, ...(commitMessage ? { commitMessage } : {}), - forcePushOnlyProgress, ...(onConfirmed ? { onConfirmed } : {}), ...(filePaths ? { filePaths } : {}), featureBranch: true, @@ -666,11 +702,11 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } if (item.dialogAction === "push") { - void runGitActionWithToast({ action: "commit_push", forcePushOnlyProgress: true }); + void runGitActionWithToast({ action: "push" }); return; } if (item.dialogAction === "create_pr") { - void runGitActionWithToast({ action: "commit_push_pr" }); + void runGitActionWithToast({ action: "create_pr" }); return; } setExcludedFiles(new Set()); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index d4cd25db4c..7c83d36536 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { isContextMenuPointerDown, orderItemsByPreferredIds, resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -164,6 +165,68 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("resolveSidebarNewThreadSeedContext", () => { + it("inherits the active server thread context when creating a new thread in the same project", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "local", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: null, + }), + ).toEqual({ + branch: "effect-atom", + worktreePath: null, + envMode: "local", + }); + }); + + it("prefers the active draft thread context when it matches the target project", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "local", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: { + projectId: "project-1", + branch: "feature/new-draft", + worktreePath: "/repo/worktree", + envMode: "worktree", + }, + }), + ).toEqual({ + branch: "feature/new-draft", + worktreePath: "/repo/worktree", + envMode: "worktree", + }); + }); + + it("falls back to the default env mode when there is no matching active thread context", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-2", + defaultEnvMode: "worktree", + activeThread: { + projectId: "project-1", + branch: "effect-atom", + worktreePath: null, + }, + activeDraftThread: null, + }), + ).toEqual({ + envMode: "worktree", + }); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 16c7752f74..c606853392 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -161,6 +161,46 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function resolveSidebarNewThreadSeedContext(input: { + projectId: string; + defaultEnvMode: SidebarNewThreadEnvMode; + activeThread?: { + projectId: string; + branch: string | null; + worktreePath: string | null; + } | null; + activeDraftThread?: { + projectId: string; + branch: string | null; + worktreePath: string | null; + envMode: SidebarNewThreadEnvMode; + } | null; +}): { + branch?: string | null; + worktreePath?: string | null; + envMode: SidebarNewThreadEnvMode; +} { + if (input.activeDraftThread?.projectId === input.projectId) { + return { + branch: input.activeDraftThread.branch, + worktreePath: input.activeDraftThread.worktreePath, + envMode: input.activeDraftThread.envMode, + }; + } + + if (input.activeThread?.projectId === input.projectId) { + return { + branch: input.activeThread.branch, + worktreePath: input.activeThread.worktreePath, + envMode: input.activeThread.worktreePath ? "worktree" : "local", + }; + } + + return { + envMode: input.defaultEnvMode, + }; +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c819ce81cd..1e3340cb24 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -111,6 +111,7 @@ import { resolveAdjacentThreadId, isContextMenuPointerDown, resolveProjectStatusIndicator, + resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, @@ -460,7 +461,7 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const appSettings = useSettings(); const { updateSettings } = useUpdateSettings(); - const { handleNewThread } = useHandleNewThread(); + const { activeDraftThread, activeThread, handleNewThread } = useHandleNewThread(); const { archiveThread, deleteThread } = useThreadActions(); const routeThreadId = useParams({ strict: false, @@ -1680,10 +1681,35 @@ export default function Sidebar() { onClick={(event) => { event.preventDefault(); event.stopPropagation(); - void handleNewThread(project.id, { - envMode: resolveSidebarNewThreadEnvMode({ + const seedContext = resolveSidebarNewThreadSeedContext({ + projectId: project.id, + defaultEnvMode: resolveSidebarNewThreadEnvMode({ defaultEnvMode: appSettings.defaultThreadEnvMode, }), + activeThread: + activeThread && activeThread.projectId === project.id + ? { + projectId: activeThread.projectId, + branch: activeThread.branch, + worktreePath: activeThread.worktreePath, + } + : null, + activeDraftThread: + activeDraftThread && activeDraftThread.projectId === project.id + ? { + projectId: activeDraftThread.projectId, + branch: activeDraftThread.branch, + worktreePath: activeDraftThread.worktreePath, + envMode: activeDraftThread.envMode, + } + : null, + }); + void handleNewThread(project.id, { + ...(seedContext.branch !== undefined ? { branch: seedContext.branch } : {}), + ...(seedContext.worktreePath !== undefined + ? { worktreePath: seedContext.worktreePath } + : {}), + envMode: seedContext.envMode, }); }} > diff --git a/packages/contracts/src/git.test.ts b/packages/contracts/src/git.test.ts index d2bfac6028..d5b2d7dfd8 100644 --- a/packages/contracts/src/git.test.ts +++ b/packages/contracts/src/git.test.ts @@ -4,6 +4,7 @@ import { Schema } from "effect"; import { GitCreateWorktreeInput, GitPreparePullRequestThreadInput, + GitRunStackedActionResult, GitRunStackedActionInput, GitResolvePullRequestResult, } from "./git"; @@ -13,6 +14,7 @@ const decodePreparePullRequestThreadInput = Schema.decodeUnknownSync( GitPreparePullRequestThreadInput, ); const decodeRunStackedActionInput = Schema.decodeUnknownSync(GitRunStackedActionInput); +const decodeRunStackedActionResult = Schema.decodeUnknownSync(GitRunStackedActionResult); const decodeResolvePullRequestResult = Schema.decodeUnknownSync(GitResolvePullRequestResult); describe("GitCreateWorktreeInput", () => { @@ -60,18 +62,55 @@ describe("GitResolvePullRequestResult", () => { }); describe("GitRunStackedActionInput", () => { - it("requires a client-provided actionId for progress correlation", () => { + it("accepts explicit stacked actions and requires a client-provided actionId", () => { const parsed = decodeRunStackedActionInput({ actionId: "action-1", cwd: "/repo", - action: "commit", - modelSelection: { - provider: "codex", - model: "gpt-5.4-mini", - }, + action: "create_pr", }); expect(parsed.actionId).toBe("action-1"); - expect(parsed.action).toBe("commit"); + expect(parsed.action).toBe("create_pr"); + }); +}); + +describe("GitRunStackedActionResult", () => { + it("decodes a server-authored completion toast", () => { + const parsed = decodeRunStackedActionResult({ + action: "commit_push", + branch: { + status: "created", + name: "feature/server-owned-toast", + }, + commit: { + status: "created", + commitSha: "89abcdef01234567", + subject: "feat: move toast state into git manager", + }, + push: { + status: "pushed", + branch: "feature/server-owned-toast", + upstreamBranch: "origin/feature/server-owned-toast", + }, + pr: { + status: "skipped_not_requested", + }, + toast: { + title: "Pushed 89abcde to origin/feature/server-owned-toast", + description: "feat: move toast state into git manager", + cta: { + kind: "run_action", + label: "Create PR", + action: { + kind: "create_pr", + }, + }, + }, + }); + + expect(parsed.toast.cta.kind).toBe("run_action"); + if (parsed.toast.cta.kind === "run_action") { + expect(parsed.toast.cta.action.kind).toBe("create_pr"); + } }); }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 65504fabc1..03fc050d4a 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -5,7 +5,13 @@ const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; // Domain Types -export const GitStackedAction = Schema.Literals(["commit", "commit_push", "commit_push_pr"]); +export const GitStackedAction = Schema.Literals([ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr", +]); export type GitStackedAction = typeof GitStackedAction.Type; export const GitActionProgressPhase = Schema.Literals(["branch", "commit", "push", "pr"]); export type GitActionProgressPhase = typeof GitActionProgressPhase.Type; @@ -21,7 +27,11 @@ export const GitActionProgressKind = Schema.Literals([ export type GitActionProgressKind = typeof GitActionProgressKind.Type; export const GitActionProgressStream = Schema.Literals(["stdout", "stderr"]); export type GitActionProgressStream = typeof GitActionProgressStream.Type; -const GitCommitStepStatus = Schema.Literals(["created", "skipped_no_changes"]); +const GitCommitStepStatus = Schema.Literals([ + "created", + "skipped_no_changes", + "skipped_not_requested", +]); const GitPushStepStatus = Schema.Literals([ "pushed", "skipped_not_requested", @@ -33,6 +43,32 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +export const GitRunStackedActionToastRunAction = Schema.Struct({ + kind: GitStackedAction, +}); +export type GitRunStackedActionToastRunAction = typeof GitRunStackedActionToastRunAction.Type; +const GitRunStackedActionToastCta = Schema.Union([ + Schema.Struct({ + kind: Schema.Literal("none"), + }), + Schema.Struct({ + kind: Schema.Literal("open_pr"), + label: TrimmedNonEmptyStringSchema, + url: Schema.String, + }), + Schema.Struct({ + kind: Schema.Literal("run_action"), + label: TrimmedNonEmptyStringSchema, + action: GitRunStackedActionToastRunAction, + }), +]); +export type GitRunStackedActionToastCta = typeof GitRunStackedActionToastCta.Type; +const GitRunStackedActionToast = Schema.Struct({ + title: TrimmedNonEmptyStringSchema, + description: Schema.optional(TrimmedNonEmptyStringSchema), + cta: GitRunStackedActionToastCta, +}); +export type GitRunStackedActionToast = typeof GitRunStackedActionToast.Type; export const GitBranch = Schema.Struct({ name: TrimmedNonEmptyStringSchema, @@ -213,6 +249,7 @@ export const GitRunStackedActionResult = Schema.Struct({ headBranch: Schema.optional(TrimmedNonEmptyStringSchema), title: Schema.optional(TrimmedNonEmptyStringSchema), }), + toast: GitRunStackedActionToast, }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;