feat(vcs): allow rewriting history of pushed commits (#2644)

Edit Commit Message, Fixup, Squash Into, Drop Commit, Squash Commits and Interactively Rebase from Here are no longer disabled for pushed commits. A rewrite that reaches commits already on the upstream asks for confirmation first (pushing afterwards needs a force push); on a protected branch such a rewrite is refused, while its local commits can still be rewritten. Undo Commit follows the same rule.

Co-authored-by: muutot <92625353+muutot@users.noreply.github.com>
This commit is contained in:
Muu
2026-11-11 16:41:45 +08:00
committed by GitHub
co-authored by muutot
parent c67e07a4f9
commit f7a507a5db
8 changed files with 90 additions and 36 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ Right-click a commit for **Copy Revision Number** (`⌥⇧⌘C` / `Ctrl+Alt+Shif
### Rewrite history
From the log you can **Edit Commit Message…**, **Fixup…**, **Squash Into…**, **Drop Commit**, **Squash Commits…** and **Interactively Rebase from Here…**. The interactive rebase dialog lets you pick, reword, edit, squash, fixup, drop and reorder commits. These actions are disabled for commits that are already pushed.
From the log you can **Edit Commit Message…**, **Fixup…**, **Squash Into…**, **Drop Commit**, **Squash Commits…** and **Interactively Rebase from Here…**. The interactive rebase dialog lets you pick, reword, edit, squash, fixup, drop and reorder commits. These actions also work for commits that are already pushed: rewriting them is confirmed first, and pushing afterwards needs a force push. Commits already pushed from a protected branch cannot be rewritten.
Limits: interactive rebase doesn't support ranges with merge commits, and **Undo Commit…** works only on the latest commit (its changes go back to a changelist).
+1 -1
View File
@@ -181,7 +181,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
### 改写历史
在日志中可以 **编辑提交消息…**、**修补提交…**、**压缩到…**、**丢弃提交**、**压缩提交…** 和 **从此处进行交互式变基…**。交互式变基对话框可以对提交执行 pick、reword、edit、squash、fixup、drop,并调整顺序。已推送的提交不能使用这些操作。
在日志中可以 **编辑提交消息…**、**修补提交…**、**压缩到…**、**丢弃提交**、**压缩提交…** 和 **从此处进行交互式变基…**。交互式变基对话框可以对提交执行 pick、reword、edit、squash、fixup、drop,并调整顺序。这些操作也可以用于已推送的提交:改写前会先确认,之后推送需要强制推送。受保护分支上已推送的提交不能被改写。
限制:交互式变基不支持包含合并提交的范围;**撤消提交…** 只能用于最新的提交(其更改会放回某个更改列表)。
+7 -12
View File
@@ -60,7 +60,6 @@ import {
goToChild,
goToParent,
initLogBranchFilter,
isPushed,
isMine,
loadMore,
logState,
@@ -1155,10 +1154,7 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
const vcs = useVcs.getState();
const head = (repo ? vcs.heads[repo]?.head : vcs.branch?.head) ?? null;
const { outgoing } = useGitLog.getState();
const pushed = (x: Commit) =>
commitRepo(x) === undefined ? !!outgoing && !outgoing.has(x.id) : isPushed(commitKey(x));
if (many) {
const anyPushed = selection.some(pushed);
return [
{ kind: "action", action: "vcs.log.copyRevision", arg: selection, label: "Copy Revision Numbers" },
{ kind: "action", action: "vcs.log.createPatch", arg: selection },
@@ -1169,11 +1165,10 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "separator" },
{ kind: "action", action: "vcs.log.revert", arg: selection, label: "Revert Commits" },
{ kind: "separator" },
{ kind: "action", action: "vcs.log.squashCommits", arg: selection, disabled: anyPushed },
{ kind: "action", action: "vcs.log.dropCommit", arg: selection, label: "Drop Commits", disabled: anyPushed },
{ kind: "action", action: "vcs.log.squashCommits", arg: selection },
{ kind: "action", action: "vcs.log.dropCommit", arg: selection, label: "Drop Commits" },
];
}
const pushedCommit = pushed(c);
// Without an upstream there is no outgoing set: allow pushing up to any commit of HEAD.
const canPushHere =
!!head &&
@@ -1214,11 +1209,11 @@ export function commitMenu(c: Commit, selection: Commit[] = [c]): MenuItem[] {
{ kind: "action", action: "vcs.log.revert", arg: c },
{ kind: "action", action: "vcs.log.undoCommit", arg: c },
{ kind: "separator" },
{ kind: "action", action: "vcs.log.editMessage", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.fixup", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.squashInto", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.dropCommit", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.interactiveRebase", arg: c, disabled: pushedCommit },
{ kind: "action", action: "vcs.log.editMessage", arg: c },
{ kind: "action", action: "vcs.log.fixup", arg: c },
{ kind: "action", action: "vcs.log.squashInto", arg: c },
{ kind: "action", action: "vcs.log.dropCommit", arg: c },
{ kind: "action", action: "vcs.log.interactiveRebase", arg: c },
{ kind: "action", action: "vcs.log.pushUpToHere", arg: c, disabled: !canPushHere },
{ kind: "separator" },
...(branchActions.length > 2 ? [{ label: "Branches", submenu: branchActions }] : branchActions),
+11
View File
@@ -26,6 +26,7 @@ import {
moveRow,
planIsNoop,
planProblem,
protectedBranchProblem,
rebaseRange,
singleCommitPlan,
squashPlan,
@@ -131,6 +132,16 @@ describe("Undo Commit", () => {
expect(headPushed(info({ upstream: "origin/main", ahead: 1 }))).toBe(false);
expect(headPushed(info())).toBe(false);
});
it("refuses history rewriting on a protected branch with an upstream", () => {
const problem = (head: string | null, upstream: string | null) =>
protectedBranchProblem({ head, upstream }, (name) => /^(main|master)$/.test(name));
expect(problem("main", "origin/main")).toMatch(/protected branch 'main'/);
expect(problem("master", "origin/master")).toMatch(/protected branch 'master'/);
expect(problem("feature", "origin/main")).toBeNull();
expect(problem("main", null)).toBeNull();
expect(problem(null, "origin/main")).toBeNull();
});
});
describe("interactive rebase plan", () => {
+13 -12
View File
@@ -84,22 +84,23 @@ describe("Git Log commit actions", () => {
expect(items.every((i) => !("arg" in i) || i.arg === c)).toBe(true);
});
it("disables history rewriting for pushed commits", () => {
it("offers history rewriting for pushed commits too (IDEA allows it; the run confirms)", () => {
const local = commit("Local", "1".repeat(40));
const pushed = commit("Pushed", "2".repeat(40));
useGitLog.setState({ outgoing: new Set([local.id]) });
const rewriting = (c: Commit) =>
commitMenu(c).flatMap((i) =>
i.kind === "action" &&
i.disabled &&
["editMessage", "fixup", "squashInto", "dropCommit", "interactiveRebase"].some((a) => i.action.endsWith(a))
? [i.action]
: [],
);
expect(rewriting(local)).toEqual([]);
expect(rewriting(pushed)).toEqual([]);
// Push All up to Here keeps its own rule: it cannot push past a pushed commit.
const disabled = (c: Commit) => commitMenu(c).flatMap((i) => (i.kind === "action" && i.disabled ? [i.action] : []));
expect(disabled(local)).not.toContain("vcs.log.editMessage");
expect(disabled(pushed)).toEqual(
expect.arrayContaining([
"vcs.log.editMessage",
"vcs.log.fixup",
"vcs.log.squashInto",
"vcs.log.dropCommit",
"vcs.log.interactiveRebase",
"vcs.log.pushUpToHere",
]),
);
expect(disabled(pushed)).toContain("vcs.log.pushUpToHere");
useGitLog.setState({ outgoing: null });
});
-7
View File
@@ -819,13 +819,6 @@ async function loadOutgoing(id: string) {
if (logState(id).generation === generation) setViewState(id, { outgoing, tracked });
}
/** The commit is on the upstream of the current branch (rewriting it needs a force push). */
export function isPushed(id: string): boolean {
const { outgoing, tracked, commits } = useGitLog.getState();
const c = commits.find((c) => commitKey(c) === id);
return !!c && tracked.has(commitRepo(c) ?? "") && !!outgoing && !outgoing.has(id);
}
export function loadMore(id = useGitLog.getState().shownLog): Promise<void> {
return loadPage(false, id);
}
+46 -3
View File
@@ -4,7 +4,7 @@ import { commitRepo } from "./multiRoot";
* Interactively Rebase from Here (IDEA's Git.Log.ContextMenu).
*/
import { useState } from "react";
import type { Commit, RebaseAction, RebaseEntry } from "@/bindings";
import type { BranchInfo, Commit, RebaseAction, RebaseEntry } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { Dialog, IconButton, Select, T } from "@/ui";
import { workflowSettings } from "@/platform/workflowSettings";
@@ -24,6 +24,7 @@ import {
moveRow,
planIsNoop,
planProblem,
protectedBranchProblem,
rebaseRange,
singleCommitPlan,
squashPlan,
@@ -33,6 +34,7 @@ import {
type RebaseRow,
} from "./rebasePlan";
import { currentRepo, refreshNow, useVcs } from "./store";
import { isProtectedBranch } from "./remoteUi";
import { notifications } from "@/workbench/contributions";
import styles from "./common.module.css";
import { tx } from "@/platform/i18n";
@@ -44,9 +46,43 @@ function refuse(title: string, body: string) {
void showError(title, body);
}
/** Pushed history of a protected branch cannot be rewritten (force push is not allowed there). */
const protectedProblem = (branch: { head: string | null; upstream: string | null } | null) =>
protectedBranchProblem(branch ?? { head: null, upstream: null }, isProtectedBranch);
const pushedWarning = (what: string) =>
`The commit is already pushed to ${useVcs.getState().branch?.upstream ?? "the remote"}. ${what} rewrites published history; pushing afterwards needs a force push.`;
/**
* Whether rewriting `range` (its oldest commit up to HEAD) touches history already
* pushed to the branch's upstream. Every common ancestor of HEAD and the upstream is
* an ancestor of their merge base, so the range is pushed iff its oldest commit is at
* or below that merge base.
*/
async function rangeTouchesUpstream(
branch: BranchInfo | null,
range: RebaseRange,
repo: string | null,
): Promise<boolean> {
if (!branch?.upstream) return false;
if (headPushed(branch)) return true;
const base = await call(commands.vcsMergeBase(repo, "HEAD", branch.upstream)).catch(() => null);
if (!base) return false;
const oldest = range.commits[0]!.id;
const below = await call(
commands.vcsLog(repo, {
branch: null,
path: null,
author: null,
text: null,
range: `${base}..${oldest}`,
skip: 0,
limit: 1,
}),
).catch(() => null);
return below !== null && below.length === 0;
}
// ---------------------------------------------------------------------------
// Undo Commit
@@ -54,7 +90,8 @@ export async function undoCommit(commit: Commit) {
const repo = commitRepo(commit) ?? currentRepo();
await refreshNow();
const branch = useVcs.getState().branch;
const problem = undoCommitProblem(commit, branch);
// Protected branches refuse only rewrites of history that is already pushed.
const problem = undoCommitProblem(commit, branch) ?? (headPushed(branch) ? protectedProblem(branch) : null);
if (problem) return refuse("Cannot undo commit", problem);
if (headPushed(branch)) {
const ok = await confirmDialog({
@@ -114,7 +151,13 @@ async function rangeFrom(target: Commit, verb: string): Promise<(RebaseRange & {
refuse(`Cannot ${verb}`, range.error);
return null;
}
if (headPushed(branch)) {
if (await rangeTouchesUpstream(branch, range, repo)) {
// Protected branches refuse only rewrites of history that is already pushed.
const protectedBranch = protectedProblem(branch);
if (protectedBranch) {
refuse(`Cannot ${verb}`, protectedBranch);
return null;
}
const ok = await confirmDialog({
title: verb.replace(/^\w/, (c) => c.toUpperCase()),
message: pushedWarning(verb.replace(/^\w/, (c) => c.toUpperCase())),
+11
View File
@@ -43,6 +43,17 @@ export function headPushed(branch: BranchInfo | null): boolean {
return !!branch?.upstream && branch.ahead === 0;
}
/** Pushed history of a protected branch cannot be rewritten (force push is not allowed there). */
export function protectedBranchProblem(
branch: { head: string | null; upstream: string | null },
protectedBranch: (name: string) => boolean,
): string | null {
const name = branch.head;
// With no upstream there is no force push to prohibit, so the branch is not protected.
if (!name || !branch.upstream || !protectedBranch(name)) return null;
return `Pushed commits of the protected branch '${name}' cannot be rewritten`;
}
export function initialRows(commits: readonly Commit[]): RebaseRow[] {
return autosquash(commits.map((commit) => ({ commit, action: "pick", message: commit.message })));
}