diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 05b2ade03e..d045626e79 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1406,6 +1406,44 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect( + "computes ahead count against a non-origin remote-prefixed gh-merge-base candidate", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const remoteName = "fork-seed"; + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", remoteName, remote]); + yield* git(source, ["push", "-u", remoteName, initialBranch]); + yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); + yield* git(source, [ + "config", + "branch.feature/non-origin-merge-base.gh-merge-base", + `${remoteName}/${initialBranch}`, + ]); + yield* writeTextFile( + path.join(source, "feature.txt"), + `ahead of ${remoteName}/${initialBranch}\n`, + ); + yield* git(source, ["add", "feature.txt"]); + yield* git(source, ["commit", "-m", "feature commit"]); + yield* git(source, ["branch", "-D", initialBranch]); + + const core = yield* GitCore; + const details = yield* core.statusDetails(source); + expect(details.branch).toBe("feature/non-origin-merge-base"); + expect(details.hasUpstream).toBe(false); + expect(details.aheadCount).toBe(1); + expect(details.behindCount).toBe(0); + }), + ); + it.effect("skips push when no upstream is configured and branch is not ahead of base", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -1447,6 +1485,31 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("pushes with upstream setup to the only configured non-origin remote", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* git(tmp, ["init", "--initial-branch=main"]); + yield* git(tmp, ["config", "user.email", "test@test.com"]); + yield* git(tmp, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(tmp, "README.md"), "hello\n"); + yield* git(tmp, ["add", "README.md"]); + yield* git(tmp, ["commit", "-m", "initial"]); + yield* git(remote, ["init", "--bare"]); + yield* git(tmp, ["remote", "add", "fork", remote]); + yield* git(tmp, ["checkout", "-b", "feature/fork-only"]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(pushed.upstreamBranch).toBe("fork/feature/fork-only"); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + "fork/feature/fork-only", + ); + }), + ); + it.effect( "pushes with upstream setup when comparable base exists but remote branch is missing", () => @@ -1483,6 +1546,90 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("prefers branch pushRemote over origin when setting upstream", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const origin = yield* makeTmpDir(); + const fork = yield* makeTmpDir(); + yield* git(origin, ["init", "--bare"]); + yield* git(fork, ["init", "--bare"]); + + yield* initRepoWithCommit(tmp); + const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(tmp, ["remote", "add", "origin", origin]); + yield* git(tmp, ["remote", "add", "fork", fork]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + + const featureBranch = "feature/push-remote"; + yield* git(tmp, ["checkout", "-b", featureBranch]); + yield* git(tmp, ["config", `branch.${featureBranch}.pushRemote`, "fork"]); + yield* writeTextFile(path.join(tmp, "feature.txt"), "push to fork\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature commit"]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(pushed.upstreamBranch).toBe(`fork/${featureBranch}`); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + `fork/${featureBranch}`, + ); + expect(yield* git(tmp, ["ls-remote", "--heads", "fork", featureBranch])).toContain( + featureBranch, + ); + }), + ); + + it.effect( + "pushes renamed PR worktree branches to their tracked upstream branch even when push.default is current", + () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const fork = yield* makeTmpDir(); + yield* git(fork, ["init", "--bare"]); + + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* git(tmp, ["remote", "add", "jasonLaster", fork]); + yield* git(tmp, ["checkout", "-b", "statemachine"]); + yield* writeTextFile(path.join(tmp, "fork.txt"), "fork branch\n"); + yield* git(tmp, ["add", "fork.txt"]); + yield* git(tmp, ["commit", "-m", "fork branch"]); + yield* git(tmp, ["push", "-u", "jasonLaster", "statemachine"]); + yield* git(tmp, ["checkout", initialBranch]); + yield* git(tmp, ["branch", "-D", "statemachine"]); + yield* git(tmp, [ + "checkout", + "-b", + "t3code/pr-488/statemachine", + "--track", + "jasonLaster/statemachine", + ]); + yield* git(tmp, ["config", "push.default", "current"]); + yield* writeTextFile(path.join(tmp, "fork.txt"), "updated fork branch\n"); + yield* git(tmp, ["add", "fork.txt"]); + yield* git(tmp, ["commit", "-m", "update reviewed PR branch"]); + + 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("jasonLaster/statemachine"); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + "jasonLaster/statemachine", + ); + expect( + yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "statemachine"]), + ).toContain("statemachine"); + expect( + yield* git(tmp, ["ls-remote", "--heads", "jasonLaster", "t3code/pr-488/statemachine"]), + ).toBe(""); + }), + ); + 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 05f458af63..819e4abf1b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -191,9 +191,9 @@ function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } -function parseDefaultBranchFromRemoteHeadRef(value: string): string | null { +function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null { const trimmed = value.trim(); - const prefix = "refs/remotes/origin/"; + const prefix = `refs/remotes/${remoteName}/`; if (!trimmed.startsWith(prefix)) { return null; } @@ -417,29 +417,33 @@ const makeGitCore = Effect.gen(function* () { yield* fetchUpstreamRef(cwd, upstream); }); - const resolveDefaultBranchName = (cwd: string): Effect.Effect => + const resolveDefaultBranchName = ( + cwd: string, + remoteName: string, + ): Effect.Effect => executeGit( "GitCore.resolveDefaultBranchName", cwd, - ["symbolic-ref", "refs/remotes/origin/HEAD"], + ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], { allowNonZeroExit: true }, ).pipe( Effect.map((result) => { if (result.code !== 0) { return null; } - return parseDefaultBranchFromRemoteHeadRef(result.stdout); + return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); }), ); const remoteBranchExists = ( cwd: string, + remoteName: string, branch: string, ): Effect.Effect => executeGit( "GitCore.remoteBranchExists", cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], { allowNonZeroExit: true, }, @@ -473,6 +477,34 @@ const makeGitCore = Effect.gen(function* () { ); }); + const resolvePushRemoteName = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const branchPushRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.branchPushRemote", + cwd, + ["config", "--get", `branch.${branch}.pushRemote`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (branchPushRemote.length > 0) { + return branchPushRemote; + } + + const pushDefaultRemote = yield* runGitStdout( + "GitCore.resolvePushRemoteName.remotePushDefault", + cwd, + ["config", "--get", "remote.pushDefault"], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (pushDefaultRemote.length > 0) { + return pushDefaultRemote; + } + + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + }); + const ensureRemote: GitCoreShape["ensureRemote"] = (input) => Effect.gen(function* () { const preferredName = sanitizeRemoteName(input.preferredName); @@ -517,7 +549,11 @@ const makeGitCore = Effect.gen(function* () { true, ).pipe(Effect.map((stdout) => stdout.trim())); - const defaultBranch = yield* resolveDefaultBranchName(cwd); + const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + const defaultBranch = + primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); const candidates = [ configuredBaseBranch.length > 0 ? configuredBaseBranch : null, defaultBranch, @@ -529,9 +565,13 @@ const makeGitCore = Effect.gen(function* () { continue; } + const remotePrefix = + primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; const normalizedCandidate = candidate.startsWith("origin/") ? candidate.slice("origin/".length) - : candidate; + : remotePrefix && candidate.startsWith(remotePrefix) + ? candidate.slice(remotePrefix.length) + : candidate; if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { continue; } @@ -540,8 +580,11 @@ const makeGitCore = Effect.gen(function* () { return normalizedCandidate; } - if (yield* remoteBranchExists(cwd, normalizedCandidate)) { - return `origin/${normalizedCandidate}`; + if ( + primaryRemoteName && + (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) + ) { + return `${primaryRemoteName}/${normalizedCandidate}`; } } @@ -792,17 +835,17 @@ const makeGitCore = Effect.gen(function* () { Effect.catch(() => Effect.succeed(null)), ); if (comparableBaseBranch) { - const hasOriginRemote = yield* originRemoteExists(cwd).pipe( - Effect.catch(() => Effect.succeed(false)), + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), ); - if (!hasOriginRemote) { + if (!publishRemoteName) { return { status: "skipped_up_to_date" as const, branch, }; } - const hasRemoteBranch = yield* remoteBranchExists(cwd, branch).pipe( + const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( Effect.catch(() => Effect.succeed(false)), ); if (hasRemoteBranch) { @@ -815,20 +858,46 @@ const makeGitCore = Effect.gen(function* () { } if (!details.hasUpstream) { + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); + if (!publishRemoteName) { + return yield* createGitCommandError( + "GitCore.pushCurrentBranch", + cwd, + ["push"], + "Cannot push because no git remote is configured for this repository.", + ); + } yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ "push", "-u", - "origin", + publishRemoteName, branch, ]); return { status: "pushed" as const, branch, - upstreamBranch: `origin/${branch}`, + upstreamBranch: `${publishRemoteName}/${branch}`, setUpstream: true, }; } + const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (currentUpstream) { + yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ + "push", + currentUpstream.remoteName, + `HEAD:${currentUpstream.upstreamBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: currentUpstream.upstreamRef, + setUpstream: false, + }; + } + yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); return { status: "pushed" as const, diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 39d47bd639..80ce43659e 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -181,7 +181,7 @@ const makeGitHubCli = Effect.sync(() => { "pr", "list", "--head", - input.headBranch, + input.headSelector, "--state", "open", "--limit", @@ -250,7 +250,7 @@ const makeGitHubCli = Effect.sync(() => { "--base", input.baseBranch, "--head", - input.headBranch, + input.headSelector, "--title", input.title, "--body-file", diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 856eb076b5..cc80eda23b 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -22,6 +22,7 @@ import { makeGitManager } from "./GitManager.ts"; interface FakeGhScenario { prListSequence?: string[]; + prListByHeadSelector?: Record; createdPrUrl?: string; defaultBranch?: string; pullRequest?: { @@ -221,7 +222,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "list") { - const stdout = (prListQueue.shift() ?? "[]") + "\n"; + const headSelectorIndex = args.findIndex((value) => value === "--head"); + const headSelector = + headSelectorIndex >= 0 && headSelectorIndex < args.length - 1 + ? args[headSelectorIndex + 1] + : undefined; + const mappedStdout = + typeof headSelector === "string" + ? scenario.prListByHeadSelector?.[headSelector] + : undefined; + const stdout = (mappedStdout ?? prListQueue.shift() ?? "[]") + "\n"; return Effect.succeed({ stdout, stderr: "", @@ -369,7 +379,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "pr", "list", "--head", - input.headBranch, + input.headSelector, "--state", "open", "--limit", @@ -391,7 +401,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--base", input.baseBranch, "--head", - input.headBranch, + input.headSelector, "--title", input.title, "--body-file", @@ -523,6 +533,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "status detects cross-repo PRs from the upstream remote URL owner", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + fs.writeFileSync(path.join(repoDir, "fork-pr.txt"), "fork pr\n"); + yield* runGit(repoDir, ["add", "fork-pr.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-488/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:jasonLaster/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([]), + JSON.stringify([ + { + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseRefName: "main", + headRefName: "statemachine", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("t3code/pr-488/statemachine"); + expect(status.pr).toEqual({ + number: 488, + title: "Rebase this PR on latest main", + url: "https://github.com/pingdotgg/codething-mvp/pull/488", + baseBranch: "main", + headBranch: "statemachine", + state: "open", + }); + expect(ghCalls).toContain( + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ); + }), + 12_000, + ); + it.effect("status returns merged PR state when latest PR was merged", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -964,6 +1032,172 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "returns existing cross-repo PR metadata using the fork owner selector", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([]), + JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + expect( + ghCalls.some((call) => + call.includes("pr list --head octocat:statemachine --state open --limit 1"), + ), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "prefers owner-qualified selectors before bare branch names for cross-repo PRs", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "t3code/pr-142/statemachine": JSON.stringify([]), + statemachine: JSON.stringify([ + { + number: 41, + title: "Unrelated same-repo PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/41", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + "octocat:statemachine": JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + "fork-seed:statemachine": JSON.stringify([]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("opened_existing"); + expect(result.pr.number).toBe(142); + + const ownerSelectorCallIndex = ghCalls.findIndex((call) => + call.includes("pr list --head octocat:statemachine --state open --limit 1"), + ); + expect(ownerSelectorCallIndex).toBeGreaterThanOrEqual(0); + expect(ghCalls.some((call) => call.startsWith("pr create "))).toBe(false); + }), + 12_000, + ); + + it.effect( + "stops probing head selectors after finding an existing PR", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-142/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "octocat:statemachine": JSON.stringify([ + { + number: 142, + title: "Existing fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/142", + baseRefName: "main", + headRefName: "statemachine", + }, + ]), + "fork-seed:statemachine": JSON.stringify([]), + "t3code/pr-142/statemachine": JSON.stringify([]), + statemachine: JSON.stringify([]), + }, + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + 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( + "pr list --head octocat:statemachine --state open --limit 1", + ); + }), + 12_000, + ); + it.effect("creates PR when one does not already exist", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -1008,6 +1242,65 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + 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-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "statemachine"]); + 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", "fork-seed", "statemachine"]); + yield* runGit(repoDir, ["checkout", "-b", "t3code/pr-91/statemachine"]); + yield* runGit(repoDir, ["branch", "--set-upstream-to", "fork-seed/statemachine"]); + yield* runGit(repoDir, [ + "config", + "remote.fork-seed.url", + "git@github.com:octocat/codething-mvp.git", + ]); + + 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", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.pr.status).toBe("created"); + expect(result.pr.number).toBe(188); + expect( + ghCalls.some((call) => call.includes("pr create --base main --head octocat:statemachine")), + ).toBe(true); + expect( + ghCalls.some((call) => + call.includes("pr create --base statemachine --head octocat:statemachine"), + ), + ).toBe(false); + }), + ); + it.effect("rejects push/pr actions from detached HEAD", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 1c711e3b47..97760e2d31 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -42,6 +42,17 @@ interface PullRequestHeadRemoteInfo { headRepositoryOwnerLogin?: string | null; } +interface BranchHeadContext { + localBranch: string; + headBranch: string; + headSelectors: ReadonlyArray; + preferredHeadSelector: string; + remoteName: string | null; + headRepositoryNameWithOwner: string | null; + headRepositoryOwnerLogin: string | null; + isCrossRepository: boolean; +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); @@ -82,6 +93,30 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } +function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { + const trimmed = url?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const match = + /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + trimmed, + ); + const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; + return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; +} + +function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { + const trimmed = nameWithOwner?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + const [ownerLogin] = trimmed.split("/"); + const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; + return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -217,6 +252,14 @@ function extractBranchFromRef(ref: string): string { 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)) { + return; + } + values.push(trimmed); +} + function toStatusPr(pr: PullRequestInfo): { number: number; title: string; @@ -394,63 +437,162 @@ export const makeGitManager = Effect.gen(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; - const findOpenPr = (cwd: string, branch: string) => - gitHubCli - .listOpenPullRequests({ - cwd, - headBranch: branch, - limit: 1, - }) - .pipe( - Effect.map((prs) => { - const [first] = prs; - if (!first) { - return null; - } + const readConfigValueNullable = (cwd: string, key: string) => + gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + + const resolveRemoteRepositoryContext = (cwd: string, remoteName: string | null) => + Effect.gen(function* () { + if (!remoteName) { + return { + repositoryNameWithOwner: null, + ownerLogin: null, + }; + } + + const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + return { + repositoryNameWithOwner, + ownerLogin: parseRepositoryOwnerLogin(repositoryNameWithOwner), + }; + }); + + const resolveBranchHeadContext = ( + cwd: string, + details: { branch: string; upstreamRef: string | null }, + ) => + Effect.gen(function* () { + const remoteName = yield* readConfigValueNullable(cwd, `branch.${details.branch}.remote`); + const headBranchFromUpstream = details.upstreamRef + ? extractBranchFromRef(details.upstreamRef) + : ""; + const headBranch = + headBranchFromUpstream.length > 0 ? headBranchFromUpstream : details.branch; + + const [remoteRepository, originRepository] = yield* Effect.all( + [ + resolveRemoteRepositoryContext(cwd, remoteName), + resolveRemoteRepositoryContext(cwd, "origin"), + ], + { concurrency: "unbounded" }, + ); + + const isCrossRepository = + remoteRepository.repositoryNameWithOwner !== null && + originRepository.repositoryNameWithOwner !== null + ? remoteRepository.repositoryNameWithOwner.toLowerCase() !== + originRepository.repositoryNameWithOwner.toLowerCase() + : remoteName !== null && + remoteName !== "origin" && + remoteRepository.repositoryNameWithOwner !== null; + + const ownerHeadSelector = + remoteRepository.ownerLogin && headBranch.length > 0 + ? `${remoteRepository.ownerLogin}:${headBranch}` + : null; + const remoteAliasHeadSelector = + remoteName && headBranch.length > 0 ? `${remoteName}:${headBranch}` : null; + const shouldProbeRemoteOwnedSelectors = + isCrossRepository || (remoteName !== null && remoteName !== "origin"); + + const headSelectors: string[] = []; + if (isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + appendUnique(headSelectors, details.branch); + appendUnique(headSelectors, headBranch !== details.branch ? headBranch : null); + if (!isCrossRepository && shouldProbeRemoteOwnedSelectors) { + appendUnique(headSelectors, ownerHeadSelector); + appendUnique( + headSelectors, + remoteAliasHeadSelector !== ownerHeadSelector ? remoteAliasHeadSelector : null, + ); + } + + return { + localBranch: details.branch, + headBranch, + headSelectors, + preferredHeadSelector: + ownerHeadSelector && isCrossRepository ? ownerHeadSelector : headBranch, + remoteName, + headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, + headRepositoryOwnerLogin: remoteRepository.ownerLogin, + isCrossRepository, + } satisfies BranchHeadContext; + }); + + const findOpenPr = (cwd: string, headSelectors: ReadonlyArray) => + Effect.gen(function* () { + for (const headSelector of headSelectors) { + const pullRequests = yield* gitHubCli.listOpenPullRequests({ + cwd, + headSelector, + limit: 1, + }); + + const [firstPullRequest] = pullRequests; + if (firstPullRequest) { return { - number: first.number, - title: first.title, - url: first.url, - baseRefName: first.baseRefName, - headRefName: first.headRefName, + number: firstPullRequest.number, + title: firstPullRequest.title, + url: firstPullRequest.url, + baseRefName: firstPullRequest.baseRefName, + headRefName: firstPullRequest.headRefName, state: "open", updatedAt: null, } satisfies PullRequestInfo; - }), - ); + } + } - const findLatestPr = (cwd: string, branch: string) => + return null; + }); + + const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) => Effect.gen(function* () { - const stdout = yield* gitHubCli - .execute({ - cwd, - args: [ - "pr", - "list", - "--head", - branch, - "--state", - "all", - "--limit", - "20", - "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", - ], - }) - .pipe(Effect.map((result) => result.stdout)); + const headContext = yield* resolveBranchHeadContext(cwd, details); + const parsedByNumber = new Map(); + + for (const headSelector of headContext.headSelectors) { + const stdout = yield* gitHubCli + .execute({ + cwd, + args: [ + "pr", + "list", + "--head", + headSelector, + "--state", + "all", + "--limit", + "20", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt", + ], + }) + .pipe(Effect.map((result) => result.stdout)); + + const raw = stdout.trim(); + if (raw.length === 0) { + continue; + } - const raw = stdout.trim(); - if (raw.length === 0) { - return null; - } + const parsedJson = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => + gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), + }); - const parsedJson = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause), - }); + for (const pr of parsePullRequestList(parsedJson)) { + parsedByNumber.set(pr.number, pr); + } + } - const parsed = parsePullRequestList(parsedJson).toSorted((a, b) => { + const parsed = Array.from(parsedByNumber.values()).toSorted((a, b) => { const left = a.updatedAt ? Date.parse(a.updatedAt) : 0; const right = b.updatedAt ? Date.parse(b.updatedAt) : 0; return right - left; @@ -463,12 +605,17 @@ export const makeGitManager = Effect.gen(function* () { return parsed[0] ?? null; }); - const resolveBaseBranch = (cwd: string, branch: string, upstreamRef: string | null) => + const resolveBaseBranch = ( + cwd: string, + branch: string, + upstreamRef: string | null, + headContext: Pick, + ) => Effect.gen(function* () { const configured = yield* gitCore.readConfigValue(cwd, `branch.${branch}.gh-merge-base`); if (configured) return configured; - if (upstreamRef) { + if (upstreamRef && !headContext.isCrossRepository) { const upstreamBranch = extractBranchFromRef(upstreamRef); if (upstreamBranch.length > 0 && upstreamBranch !== branch) { return upstreamBranch; @@ -571,7 +718,12 @@ export const makeGitManager = Effect.gen(function* () { ); } - const existing = yield* findOpenPr(cwd, branch); + const headContext = yield* resolveBranchHeadContext(cwd, { + branch, + upstreamRef: details.upstreamRef, + }); + + const existing = yield* findOpenPr(cwd, headContext.headSelectors); if (existing) { return { status: "opened_existing" as const, @@ -583,13 +735,13 @@ export const makeGitManager = Effect.gen(function* () { }; } - const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef); + const baseBranch = yield* resolveBaseBranch(cwd, branch, details.upstreamRef, headContext); const rangeContext = yield* gitCore.readRangeContext(cwd, baseBranch); const generated = yield* textGeneration.generatePrContent({ cwd, baseBranch, - headBranch: branch, + headBranch: headContext.headBranch, commitSummary: limitContext(rangeContext.commitSummary, 20_000), diffSummary: limitContext(rangeContext.diffSummary, 20_000), diffPatch: limitContext(rangeContext.diffPatch, 60_000), @@ -607,18 +759,18 @@ export const makeGitManager = Effect.gen(function* () { .createPullRequest({ cwd, baseBranch, - headBranch: branch, + headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); - const created = yield* findOpenPr(cwd, branch); + const created = yield* findOpenPr(cwd, headContext.headSelectors); if (!created) { return { status: "created" as const, baseBranch, - headBranch: branch, + headBranch: headContext.headBranch, title: generated.title, }; } @@ -638,7 +790,10 @@ export const makeGitManager = Effect.gen(function* () { const pr = details.branch !== null - ? yield* findLatestPr(input.cwd, details.branch).pipe( + ? yield* findLatestPr(input.cwd, { + branch: details.branch, + upstreamRef: details.upstreamRef, + }).pipe( Effect.map((latest) => (latest ? toStatusPr(latest) : null)), Effect.catch(() => Effect.succeed(null)), ) diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index aa95bf628a..f10339af47 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -47,7 +47,7 @@ export interface GitHubCliShape { */ readonly listOpenPullRequests: (input: { readonly cwd: string; - readonly headBranch: string; + readonly headSelector: string; readonly limit?: number; }) => Effect.Effect, GitHubCliError>; @@ -73,7 +73,7 @@ export interface GitHubCliShape { readonly createPullRequest: (input: { readonly cwd: string; readonly baseBranch: string; - readonly headBranch: string; + readonly headSelector: string; readonly title: string; readonly bodyFile: string; }) => Effect.Effect; diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index b61e11c73f..5aef38ccf6 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -149,7 +149,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { ]); }); - it("dedupes remote refs for remotes whose names contain slashes", () => { + it("keeps non-origin remote refs visible even when a matching local branch exists", () => { const input: GitBranch[] = [ { name: "feature/demo", @@ -169,10 +169,11 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ "feature/demo", + "my-org/upstream/feature/demo", ]); }); - it("dedupes remote refs when git tracks with first-slash local naming", () => { + it("keeps non-origin remote refs visible when git tracks with first-slash local naming", () => { const input: GitBranch[] = [ { name: "upstream/feature", @@ -192,6 +193,7 @@ describe("dedupeRemoteBranchesWithLocalMatches", () => { expect(dedupeRemoteBranchesWithLocalMatches(input).map((branch) => branch.name)).toEqual([ "upstream/feature", + "my-org/upstream/feature", ]); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 64ec2956c6..888c52cfd0 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -81,6 +81,10 @@ export function dedupeRemoteBranchesWithLocalMatches( return true; } + if (branch.remoteName !== "origin") { + return true; + } + const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( branch.name, branch.remoteName, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index df4b18b234..c4c88f579c 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -279,14 +279,12 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions } onConfirmed?.(); - const pushTarget = !featureBranch && actionBranch ? `origin/${actionBranch}` : undefined; const progressStages = buildGitActionProgressStages({ action, hasCustomCommitMessage: !!commitMessage?.trim(), hasWorkingTreeChanges: !!actionStatus?.hasWorkingTreeChanges, forcePushOnly: forcePushOnlyProgress, featureBranch, - ...(pushTarget ? { pushTarget } : {}), }); const resolvedProgressToastId = progressToastId ??