From 0c637e3d5ec54b79b3df72b074b998ddc439b14a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 1 Mar 2026 11:28:44 -0800 Subject: [PATCH] Handle no-upstream branches by comparing commits against base - Compute ahead count from base branch when upstream is missing - Skip push/PR actions when no local commits are ahead - Update server and UI tests for no-upstream push/PR behavior --- apps/server/src/git/Layers/GitCore.test.ts | 36 +++++++ apps/server/src/git/Layers/GitCore.ts | 94 ++++++++++++++++++- apps/server/src/git/Layers/GitManager.test.ts | 50 ++++++++++ .../GitActionsControl.logic.test.ts | 40 +++++--- .../src/components/GitActionsControl.logic.ts | 22 +++-- apps/web/src/components/GitActionsControl.tsx | 6 -- 6 files changed, 220 insertions(+), 28 deletions(-) diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index b8a0dc17d2b0ef2d113d5771b6aa9a0833e581fe..53fc7bf3be4fe3db479cdd4af785dd117ca283a1 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1141,6 +1141,42 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("computes ahead count against base branch when no upstream is configured", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-ahead" }); + yield* writeTextFile(path.join(tmp, "feature.txt"), "ahead of base\n"); + yield* git(tmp, ["add", "feature.txt"]); + yield* git(tmp, ["commit", "-m", "feature commit"]); + + const details = yield* core.statusDetails(tmp); + expect(details.branch).toBe("feature/no-upstream-ahead"); + 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(); + yield* initRepoWithCommit(tmp); + const core = yield* GitCore; + + yield* core.createBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); + yield* core.checkoutBranch({ cwd: tmp, branch: "feature/no-upstream-no-ahead" }); + + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("skipped_up_to_date"); + expect(pushed.branch).toBe("feature/no-upstream-no-ahead"); + expect(pushed.setUpstream).toBeUndefined(); + }), + ); + 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 84cb88cbc1095d4a1e8c600c1a27d8949d316e78..543a721eff817ad3ceec1330bb411d3ecc5ae776 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -7,6 +7,7 @@ import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; +const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; class StatusUpstreamRefreshCacheKey extends Data.Class<{ cwd: string; @@ -161,6 +162,16 @@ function commandLabel(args: readonly string[]): string { return `git ${args.join(" ")}`; } +function parseDefaultBranchFromRemoteHeadRef(value: string): string | null { + const trimmed = value.trim(); + const prefix = "refs/remotes/origin/"; + if (!trimmed.startsWith(prefix)) { + return null; + } + const branch = trimmed.slice(prefix.length).trim(); + return branch.length > 0 ? branch : null; +} + function createGitCommandError( operation: string, cwd: string, @@ -340,6 +351,79 @@ const makeGitCore = Effect.gen(function* () { yield* fetchUpstreamRef(cwd, upstream); }); + const resolveDefaultBranchName = (cwd: string): Effect.Effect => + executeGit("GitCore.resolveDefaultBranchName", cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], { + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + if (result.code !== 0) { + return null; + } + return parseDefaultBranchFromRemoteHeadRef(result.stdout); + }), + ); + + const branchExists = (cwd: string, branch: string): Effect.Effect => + executeGit("GitCore.branchExists", cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.code === 0)); + + const resolveBaseBranchForNoUpstream = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const configuredBaseBranch = yield* runGitStdout( + "GitCore.resolveBaseBranchForNoUpstream.config", + cwd, + ["config", "--get", `branch.${branch}.gh-merge-base`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + + const defaultBranch = yield* resolveDefaultBranchName(cwd); + const candidates = [ + configuredBaseBranch.length > 0 ? configuredBaseBranch : null, + defaultBranch, + ...DEFAULT_BASE_BRANCH_CANDIDATES, + ]; + + for (const candidate of candidates) { + if (!candidate || candidate === branch) { + continue; + } + + if (yield* branchExists(cwd, candidate)) { + return candidate; + } + } + + return null; + }); + + const computeAheadCountAgainstBase = ( + cwd: string, + branch: string, + ): Effect.Effect => + Effect.gen(function* () { + const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); + if (!baseBranch) { + return 0; + } + + const result = yield* executeGit( + "GitCore.computeAheadCountAgainstBase", + cwd, + ["rev-list", "--count", `${baseBranch}..HEAD`], + { allowNonZeroExit: true }, + ); + if (result.code !== 0) { + return 0; + } + + const parsed = Number.parseInt(result.stdout.trim(), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + }); + const readBranchRecency = (cwd: string): Effect.Effect, GitCommandError> => Effect.gen(function* () { const branchRecency = yield* executeGit( @@ -429,6 +513,14 @@ const makeGitCore = Effect.gen(function* () { if (pathValue) changedFilesWithoutNumstat.add(pathValue); } } + + if (!upstreamRef && branch) { + aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(0)), + ); + behindCount = 0; + } + const stagedEntries = parseNumstatEntries(stagedNumstatStdout); const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); const fileStatMap = new Map(); @@ -538,7 +630,7 @@ const makeGitCore = Effect.gen(function* () { ); } - if (details.hasUpstream && details.aheadCount === 0 && details.behindCount === 0) { + if (details.aheadCount === 0 && details.behindCount === 0) { return { status: "skipped_up_to_date" as const, branch, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 88d26f7710877e9f9eaf01a4dd5f153afedde0e6..0e870503d8cb73543bff10522eefed705cb37fa5 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -551,6 +551,56 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect( + "pushes and creates PR from a no-upstream branch when local commits are ahead of base", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/no-upstream-pr"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + fs.writeFileSync(path.join(repoDir, "feature.txt"), "feature\n"); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + "[]", + JSON.stringify([ + { + number: 77, + title: "Add no-upstream PR flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/77", + baseRefName: "main", + headRefName: "feature/no-upstream-pr", + }, + ]), + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + }); + + expect(result.commit.status).toBe("created"); + expect(result.push.status).toBe("pushed"); + expect(result.push.setUpstream).toBe(true); + expect(result.pr.status).toBe("created"); + expect( + yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"]).pipe( + Effect.map((result) => result.stdout.trim()), + ), + ).toBe("origin/feature/no-upstream-pr"); + expect( + ghCalls.some((call) => + call.includes("pr create --base main --head feature/no-upstream-pr"), + ), + ).toBe(true); + }), + ); + it.effect("skips push when branch is already up to date", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 35b5a48e322f048a4b29d65bb39e9f7440cad417..c8a9a55c49cd0ad000f0c1f22b2645f365701feb 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -515,20 +515,20 @@ describe("when: HEAD is detached and there are no local changes", () => { }); describe("when: branch has no upstream configured", () => { - it("resolveQuickAction runs push when clean, no upstream, and no local commits are ahead", () => { + it("resolveQuickAction is disabled when clean, no upstream, and no local commits are ahead", () => { const quick = resolveQuickAction( status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, ); assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", + kind: "show_hint", label: "Push", - disabled: false, + hint: "No local commits to push.", + disabled: true, }); }); - it("resolveQuickAction runs push when clean and no upstream even if PR metadata exists", () => { + it("resolveQuickAction opens PR when clean, no upstream, no local commits are ahead, and PR exists", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -545,9 +545,8 @@ describe("when: branch has no upstream configured", () => { false, ); assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push", - label: "Push", + kind: "open_pr", + label: "Open PR", disabled: false, }); }); @@ -576,7 +575,7 @@ describe("when: branch has no upstream configured", () => { }); }); - it("buildMenuItems enables push but keeps create PR disabled when no commits are ahead", () => { + it("buildMenuItems disables push and create PR when no commits are ahead", () => { const items = buildMenuItems( status({ hasUpstream: false, pr: null, aheadCount: 0 }), false, @@ -593,7 +592,7 @@ describe("when: branch has no upstream configured", () => { { id: "push", label: "Push", - disabled: false, + disabled: true, icon: "push", kind: "open_dialog", dialogAction: "push", @@ -659,7 +658,7 @@ describe("when: branch has no upstream configured", () => { ]); }); - it("resolveQuickAction uses push-only on default branch when no upstream exists", () => { + it("resolveQuickAction is disabled on default branch when no upstream exists and no commits are ahead", () => { const quick = resolveQuickAction( status({ branch: "main", @@ -670,6 +669,25 @@ describe("when: branch has no upstream configured", () => { false, true, ); + assert.deepInclude(quick, { + kind: "show_hint", + label: "Push", + hint: "No local commits to push.", + disabled: true, + }); + }); + + it("resolveQuickAction uses push-only on default branch when no upstream exists and commits are ahead", () => { + const quick = resolveQuickAction( + status({ + branch: "main", + hasUpstream: false, + aheadCount: 1, + pr: null, + }), + false, + true, + ); assert.deepInclude(quick, { kind: "run_action", action: "commit_push", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 4c669d9d5045b27f7ddb487dd64c64edbcfed72a..b0868ded51d34b78b2e72367193dbd3b74edb9b9 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -111,14 +111,8 @@ export function buildMenuItems( const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; const isBehind = gitStatus.behindCount > 0; - const canPublishUntrackedBranch = !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; - const canPush = - !isBusy && - hasBranch && - !hasChanges && - !isBehind && - (gitStatus.aheadCount > 0 || canPublishUntrackedBranch); + const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && gitStatus.aheadCount > 0; const canCreatePr = !isBusy && hasBranch && @@ -211,10 +205,18 @@ export function resolveQuickAction( } if (!gitStatus.hasUpstream) { - if (hasOpenPr || isDefaultBranch) { - return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; - } if (!isAhead) { + if (hasOpenPr) { + return { label: "Open PR", disabled: false, kind: "open_pr" }; + } + return { + label: "Push", + disabled: true, + kind: "show_hint", + hint: "No local commits to push.", + }; + } + if (hasOpenPr || isDefaultBranch) { return { label: "Push", disabled: false, kind: "run_action", action: "commit_push" }; } return { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 908689044709107cc142706d9e00358bb59025bd..fd6f7bac4736f9b98fb09bab097b4e15cfeebc88 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -87,9 +87,6 @@ function getMenuActionDisabledReason( if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } - if (!gitStatus.hasUpstream) { - return "Branch has no upstream. Push to publish it."; - } if (!isAhead) { return "No local commits to push."; } @@ -105,9 +102,6 @@ function getMenuActionDisabledReason( if (hasChanges) { return "Commit local changes before creating a PR."; } - if (!gitStatus.hasUpstream) { - return "Set an upstream branch before creating a PR."; - } if (!isAhead) { return "No local commits to include in a PR."; } -- 2.54.0