diff --git a/src/features/vcs/__tests__/workflow.test.ts b/src/features/vcs/__tests__/workflow.test.ts index 7a3b6a5dd..4506da6a1 100644 --- a/src/features/vcs/__tests__/workflow.test.ts +++ b/src/features/vcs/__tests__/workflow.test.ts @@ -1,10 +1,31 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import type { BranchInfo } from "@/bindings"; import { messageProblems, wrapAtMargin } from "../CommitView"; import { linkifyIssues } from "../issueLinks"; -import { defaultPushChecked, isProtectedBranch, isPushRejected, pushCandidates, type RepoPush } from "../remoteUi"; +import { + defaultPushChecked, + isProtectedBranch, + isPushRejected, + pushCandidates, + pushRepoCommits, + type RepoPush, +} from "../remoteUi"; import { attentionRepo } from "../store"; import { statusDecorations } from "../model"; +const vcsLog = vi.fn(); + +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + commands: { + ...real.commands, + vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query), + }, + }; +}); + const cfg = { blankLineAfterSubject: true, limitSubject: true, subjectLimit: 10, limitBody: true, bodyLimit: 8 }; describe("commit message inspections", () => { @@ -93,3 +114,39 @@ describe("several repositories", () => { expect([...defaultPushChecked(candidates, "/p/b")]).toEqual(["/p", "/p/b"]); }); }); + +describe("pushRepoCommits", () => { + const branch = (head: string | null, upstream: string | null): BranchInfo => ({ + head, + headCommit: "c1", + upstream, + ahead: 0, + behind: 0, + operation: null, + }); + + it("falls back to recent history when the upstream no longer resolves", async () => { + vcsLog.mockImplementation((_repo: unknown, q: { range: string | null }) => + q.range === "@{u}..HEAD" + ? Promise.resolve({ + status: "error", + error: "fatal: ambiguous argument '@{u}..HEAD': unknown revision or path not in the working tree.", + }) + : Promise.resolve({ status: "ok", data: [{ id: "c1" }] }), + ); + await expect(pushRepoCommits("/p", branch("feature", "origin/feature"))).resolves.toEqual([{ id: "c1" }]); + expect(vcsLog).toHaveBeenLastCalledWith("/p", expect.objectContaining({ branch: "HEAD", range: null, limit: 50 })); + }); + + it("passes the upstream range through when it resolves", async () => { + vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] }); + await pushRepoCommits("/p", branch("main", "origin/main")); + expect(vcsLog).toHaveBeenCalledWith("/p", expect.objectContaining({ range: "@{u}..HEAD", limit: 1000 })); + }); + + it("lists no commits for a detached HEAD", async () => { + vcsLog.mockClear(); + await expect(pushRepoCommits("/p", branch(null, null))).resolves.toEqual([]); + expect(vcsLog).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/vcs/remoteUi.tsx b/src/features/vcs/remoteUi.tsx index 45e247e21..faa007e70 100644 --- a/src/features/vcs/remoteUi.tsx +++ b/src/features/vcs/remoteUi.tsx @@ -95,6 +95,18 @@ export function outgoingQuery(upstream: string | null): { branch: string; range: return upstream ? { branch: "HEAD", range: "@{u}..HEAD", limit: 1000 } : { branch: "HEAD", range: null, limit: 50 }; } +/** + * Outgoing commits of one repository in the multi-repository Push dialog. `@{u}` no + * longer resolves once the upstream branch was deleted on the remote (the tracking + * config remains, `upstream_of` still reports it), so the range query falls back to + * recent history and the repository stays in the list, pushable. + */ +export async function pushRepoCommits(root: string, branch: BranchInfo): Promise { + if (!branch.head) return []; + const q = outgoingQuery(branch.upstream); + return logOf(q.branch, q.limit, q.range, root).catch(() => logOf(q.branch, 50, null, root)); +} + export interface PushTarget { /** Local branch to push (default: the current one). */ branch?: string; @@ -148,13 +160,12 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) { useEffect(() => { let alive = true; - // A repository that cannot be read (e.g. its upstream branch was deleted) is left out. + // A repository whose branch info cannot be read is left out; a deleted upstream + // branch falls back to recent history (pushRepoCommits) and stays pushable. Promise.allSettled( roots.map(async (root) => { const branch = await call(commands.vcsBranchInfo(root)); - const q = outgoingQuery(branch.upstream); - const commits = branch.head ? await logOf(q.branch, q.limit, q.range, root) : []; - return { root, branch, commits }; + return { root, branch, commits: await pushRepoCommits(root, branch) }; }), ).then((results) => { if (!alive) return;