fix(vcs): keep a deleted-upstream branch pushable in the multi-repo Push dialog (#1004)

`@{u}..HEAD` fatally fails when the tracking config remains but the
remote-tracking ref is gone (the upstream branch was deleted on the
remote and the fetch pruned): the repository was dropped from the
multi-repository Push dialog, or, when every repository failed, the
dialog showed the raw fatal and disabled Push. Fall back to recent
history like the single-repository dialog does, so the branch stays
listed and pushable (a plain push recreates the remote branch).
Цей коміт міститься в:
arookieofc authored and GitHub committed 2026-11-03 20:33:41 +08:00
1 джерело 82130d37e5
коміт a15712b670
2 files changed
+74 -6

No files matched your search

+59 -2
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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();
});
});
+15 -4
Переглянути файл
@@ -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<Commit[]> {
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;