mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
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:
@@ -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).
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ Git 工具窗口的 **日志** 标签页显示提交图。
|
||||
|
||||
### 改写历史
|
||||
|
||||
在日志中可以 **编辑提交消息…**、**修补提交…**、**压缩到…**、**丢弃提交**、**压缩提交…** 和 **从此处进行交互式变基…**。交互式变基对话框可以对提交执行 pick、reword、edit、squash、fixup、drop,并调整顺序。已推送的提交不能使用这些操作。
|
||||
在日志中可以 **编辑提交消息…**、**修补提交…**、**压缩到…**、**丢弃提交**、**压缩提交…** 和 **从此处进行交互式变基…**。交互式变基对话框可以对提交执行 pick、reword、edit、squash、fixup、drop,并调整顺序。这些操作也可以用于已推送的提交:改写前会先确认,之后推送需要强制推送。受保护分支上已推送的提交不能被改写。
|
||||
|
||||
限制:交互式变基不支持包含合并提交的范围;**撤消提交…** 只能用于最新的提交(其更改会放回某个更改列表)。
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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 })));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user