Файли
openidea/src/features/vcs/commitStore.ts
T
l0ng-ai 8c381a07c4 fix(vcs): keep local changes and staging intact across Git operations; roll back partial multi-repository operations (#1701)
* fix(vcs): keep local changes and staging intact across commit, checkout and history edits

- Commit: changes left out of a commit keep their staged state. The commit is
  made from a copy of the index (included files staged there, excluded ones
  reset to HEAD), so the user's index and other repositories are untouched.
- Smart Checkout restores staged changes as staged; untracked files a checkout
  would overwrite are listed instead of a raw git error.
- Edit Commit Message rewrites the commit and its descendants without a rebase,
  so the working tree and local changes stay as they are.
- Edit Commit Message, Drop and Squash offer Undo in their notification while
  the branch hasn't moved since.
- Interactive rebases (Drop, Squash, Interactively Rebase) put local changes
  aside themselves and bring them back with their staged state once the rebase
  is over, also after a stop for conflicts.
- Undo Commit asks for the target changelist (a new one named after the
  commit) and puts the commit message back into the commit message field.
- Cherry-pick and revert skip commits whose changes are already applied instead
  of stopping with an empty commit; commits cherry-picked from a protected
  branch get "(cherry picked from commit …)".
- Commit tool window: Commit File…, Accept Yours / Accept Theirs on conflicted
  files and Copy as Patch to Clipboard.

* fix(vcs): restore staged changes after merge, rebase and update; roll back partial multi-repository operations

- Merge, rebase, pull / Update Project and the rebase of a diverged checkout put
  local changes aside themselves and bring them back with --index, so staged
  changes stay staged (git's --autostash restored them unstaged). An operation
  stopped on conflicts keeps them aside until it is continued, skipped, aborted
  or concluded by a commit.
- Checkout, New Branch, Merge, Rebase and Delete over several repositories:
  when one repository fails after others succeeded, ask whether to roll those
  back (check out the previous branch, delete the new branch, reset to the
  commit before, restore the branch); not when it stopped on conflicts.
- Fixup… / Squash Into…: while the prepared message is in the field, the second
  commit button is Commit and Rebase, which commits and autosquashes the new
  commit into its target.
- Undo on a history rewrite replaces its notification, so the link is gone once
  used.
2026-11-06 12:13:59 +08:00

295 рядки
9.8 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/** Commit tool window state shared with actions (selection, message draft). */
import { create } from "zustand";
import { call, commands } from "@/platform/ipc";
import type { Commit } from "@/bindings";
import type { CheckState } from "./changes";
import type { CommitProblem } from "./commitChecks";
import { currentRepo, useVcs } from "./store";
interface CommitState {
message: string;
amend: boolean;
signOff: boolean;
/** Commit Options › Author (`Name <mail>`); blank uses the configured user. */
author: string;
/** Commit Options › Run Git hooks. */
runHooks: boolean;
groupByDir: boolean;
/** Group the changes into Staged / Unstaged instead of one "Changes" group. */
groupByStaging: boolean;
/** Whether the Git window's Local Changes tab previews the selection's diff. */
diffPreview: boolean;
/** Whether the Commit tool window previews the selection's diff beside the changes (#1599). */
commitDiffPreview: boolean;
/** User overrides of the inclusion checkboxes (absolute path → state). */
checks: Map<string, CheckState>;
/** Changed blocks left out of the commit (absolute path → `hunkKeys`) of `hunks` files. */
excludedHunks: Map<string, Set<string>>;
/** Absolute paths selected in the changes tree. */
selection: string[];
/** Bumped to focus the message field. */
focusTick: number;
/** Bumped to open the commit message history popup. */
historyTick: number;
/** Failed before-commit checks; while any are shown, Commit means "Commit Anyway". */
checkProblems: CommitProblem[];
tab: CommitTab;
/** Change list of the selected tree node (list actions without an argument). */
selectedList: string | null;
/** Selected shelf tree key: shelf id, optionally followed by NUL and a file path. */
selectedShelf: string | null;
/** Commit a Fixup… / Squash Into… message was prepared for: offers Commit and Rebase. */
fixupTarget: Commit | null;
}
export type CommitTab = "changes" | "shelf" | "stashes";
export const useCommit = create<CommitState>(() => ({
message: "",
amend: false,
signOff: false,
author: "",
runHooks: true,
groupByDir: readBool("openidea.vcs.groupByDir", false),
groupByStaging: readBool("openidea.vcs.groupByStaging", false),
diffPreview: readBool("openidea.vcs.diffPreview", true),
commitDiffPreview: readBool("openidea.vcs.commitDiffPreview", false),
checks: new Map(),
excludedHunks: new Map(),
selection: [],
focusTick: 0,
historyTick: 0,
checkProblems: [],
tab: "changes",
selectedList: null,
selectedShelf: null,
fixupTarget: null,
}));
/** The Fixup… / Squash Into… target while the message still is the one prepared for it. */
export function pendingFixup(message: string, target: Commit | null): Commit | null {
if (!target) return null;
return message.startsWith(`fixup! ${target.summary}`) || message.startsWith(`squash! ${target.summary}`)
? target
: null;
}
function readBool(key: string, fallback: boolean): boolean {
try {
const v = localStorage.getItem(key);
return v === null ? fallback : v === "true";
} catch {
return fallback;
}
}
export function setGroupByDir(v: boolean) {
useCommit.setState({ groupByDir: v });
try {
localStorage.setItem("openidea.vcs.groupByDir", String(v));
} catch {
// ignore
}
}
export function setGroupByStaging(v: boolean) {
useCommit.setState({ groupByStaging: v });
try {
localStorage.setItem("openidea.vcs.groupByStaging", String(v));
} catch {
// ignore
}
}
export function setDiffPreview(v: boolean) {
useCommit.setState({ diffPreview: v });
try {
localStorage.setItem("openidea.vcs.diffPreview", String(v));
} catch {
// ignore
}
}
export function setCommitDiffPreview(v: boolean) {
useCommit.setState({ commitDiffPreview: v });
try {
localStorage.setItem("openidea.vcs.commitDiffPreview", String(v));
} catch {
// ignore
}
}
const historyKey = () => `openidea.vcs.commitMessages:${useVcs.getState().root ?? ""}`;
const MAX_HISTORY = 20;
export function messageHistory(): string[] {
try {
const raw = localStorage.getItem(historyKey());
const v: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
export function rememberMessage(message: string) {
const m = message.trim();
if (!m) return;
try {
const list = [m, ...messageHistory().filter((x) => x !== m)].slice(0, MAX_HISTORY);
localStorage.setItem(historyKey(), JSON.stringify(list));
} catch {
// ignore
}
}
/** The message typed before Amend replaced it, and the amended commit's message. */
let amendData: { before: string; amend: string } | null = null;
/**
* Amend checkbox, as in IDEA: checking it replaces the message
* with the last commit's (the draft is kept in the message history and restored when
* Amend is unchecked again, unless the amend message was edited meanwhile).
*/
export async function setAmend(on: boolean) {
useCommit.setState({ amend: on });
if (!on) {
const data = amendData;
amendData = null;
if (data && useCommit.getState().message === data.amend) useCommit.setState({ message: data.before });
return;
}
let last: string | undefined;
try {
const [head] = await call(
commands.vcsLog(currentRepo(), {
branch: "HEAD",
path: null,
author: null,
text: null,
range: null,
skip: 0,
limit: 1,
}),
);
last = head?.message.replace(/\s+$/, "");
} catch {
// No commits yet.
}
const before = useCommit.getState().message;
if (!last || !useCommit.getState().amend || before.trim() === last.trim()) return;
rememberMessage(before);
amendData = { before, amend: last };
useCommit.setState((s) => ({ message: last, focusTick: s.focusTick + 1 }));
}
/** After a commit: nothing to restore. */
export function forgetAmendDraft() {
amendData = null;
}
/** The message a commit left in the field (not typed since). */
let keptMessage: string | null = null;
/**
* The message field after a commit, as IDEA's: the committed message stays for the
* next commit unless Settings › Version Control › Commit › "Clear initial commit
* message" is on.
*/
export function messageAfterCommit(committed: string, clear: boolean): string {
return clear ? "" : committed.trim();
}
export function keepMessageAfterCommit(committed: string, clear: boolean) {
const message = messageAfterCommit(committed, clear);
keptMessage = message || null;
useCommit.setState({ message });
if (!message) void loadTemplate();
}
/** The field holds nothing the user typed: empty, or what the last commit left there. */
function messageReplaceable(): boolean {
const m = useCommit.getState().message.trim();
return !m || m === keptMessage;
}
/** Operations whose commit message git prepares in `.git/MERGE_MSG`. */
const PREPARED_MESSAGE_OPS = new Set(["merge", "cherry-pick", "revert"]);
/** `MERGE_MSG` without git's `#` comment lines (the conflict list etc.). */
export function preparedMessageText(raw: string): string {
return raw
.split("\n")
.filter((l) => !l.startsWith("#"))
.join("\n")
.trim();
}
/** `commit.template` resolved as git does: `~/` in the home directory, a relative path in the repository. */
export function templatePath(value: string, root: string, home: string): string {
if (value === "~" || value.startsWith("~/")) return home.replace(/[/\\]$/, "") + value.slice(1);
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) return value;
return `${root}/${value}`;
}
/** Fills an empty message field with the repository's commit template (`commit.template`), without its `#` lines. */
async function loadTemplate() {
const root = useVcs.getState().root;
if (!root || useCommit.getState().message.trim()) return;
try {
const value = await call(commands.vcsConfigGet(root, "commit.template"));
if (!value) return;
const home = value.startsWith("~") ? await (await import("@tauri-apps/api/path")).homeDir() : "";
const text = preparedMessageText(await call(commands.fsReadText(templatePath(value, root, home))));
if (!text || useVcs.getState().root !== root || useCommit.getState().message.trim()) return;
// Not typed: a prepared merge message may replace it.
keptMessage = text;
useCommit.setState({ message: text });
} catch {
// No readable template: the field stays empty.
}
}
/**
* Like IDEA, pre-fills the commit message (unless something was typed) with the
* message git prepared for a merge / cherry-pick / revert that stopped (e.g. on conflicts),
* otherwise with the commit template.
*/
export function installPreparedMessage(): () => void {
const load = async () => {
const root = useVcs.getState().root;
if (!root || !messageReplaceable()) return;
try {
const text = preparedMessageText(await call(commands.fsReadText(`${root}/.git/MERGE_MSG`)));
if (text && messageReplaceable()) useCommit.setState({ message: text });
} catch {
// No prepared message (or `.git` is a worktree file): leave the field empty.
}
};
const op = (s = useVcs.getState()) => s.branch?.operation ?? null;
if (PREPARED_MESSAGE_OPS.has(op() ?? "")) void load();
else void loadTemplate();
return useVcs.subscribe((s, prev) => {
if (s.root !== prev.root) {
// Another repository: nothing typed or ticked for the previous one carries over (Amend least of all).
amendData = null;
keptMessage = null;
useCommit.setState({
message: "",
amend: false,
checks: new Map(),
excludedHunks: new Map(),
selection: [],
tab: "changes",
selectedList: null,
selectedShelf: null,
});
if (PREPARED_MESSAGE_OPS.has(op(s) ?? "")) void load();
else void loadTemplate();
return;
}
const now = op(s);
if (now !== op(prev) && PREPARED_MESSAGE_OPS.has(now ?? "")) void load();
});
}