fix(vcs): Keep submodule entries out of Rollback and refresh the log of another repository

- Rollback no longer offers a submodule's own entry: it cannot be reverted from the
  enclosing repository (it did nothing while reporting "Rolled back 1 file"). Rolling
  back only that entry explains how to revert it instead.
- A selected change is rolled back alone; only folders take the changes below them, so
  selecting a submodule's entry no longer pulls in the uncommitted files inside it.
- The Git log loads the branches of the newly selected repository, so it reloads when
  that repository's refs move (a new branch showed up only after a manual refresh).
This commit is contained in:
l0ng-ai
2026-10-24 09:11:58 +08:00
parent b8f422d451
commit 6a8016b888
5 changed files with 77 additions and 14 deletions
+4 -2
View File
@@ -111,14 +111,16 @@ function LogView() {
const commits = useGitLog((s) => s.commits);
const selected = useGitLog((s) => s.selected);
const root = useVcs((s) => s.root);
const repo = useVcs((s) => s.repo);
const lastRefs = useRef<string | null>(null);
// On mount and when the window switches to another repository.
// On mount, when the window switches to another project and when another of its
// repositories is selected (its branches are needed to notice its refs moving).
useEffect(() => {
lastRefs.current = null;
if (!useGitLog.getState().commits.length) void reloadLog();
void loadBranches().catch(() => undefined);
}, [root]);
}, [root, repo]);
// HEAD or a branch tip moved (commit, undo, reset, rebase, fetch…): reload (keeps the selection).
useEffect(() => {
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { FileStatus } from "@/bindings";
import { isNestedRepoEntry, rollbackCandidates } from "../changeOps";
const fs = (rel: string, unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
relPath: rel,
staged: null,
unstaged,
renamedFrom: null,
});
describe("rollback candidates with a submodule", () => {
const roots = ["/r", "/r/libs/core"];
const status = [
fs("app.txt", "modified"),
fs("libs/core", "modified"),
fs("libs/core/core.txt", "modified"),
fs("libs/core/wip.txt", "untracked"),
];
const paths = (files: FileStatus[]) => files.map((f) => f.path);
it("never offers the submodule's own entry", () => {
expect(isNestedRepoEntry("/r/libs/core", roots)).toBe(true);
expect(isNestedRepoEntry("/r", roots)).toBe(false);
expect(paths(rollbackCandidates(undefined, status, roots))).toEqual(["/r/app.txt", "/r/libs/core/core.txt"]);
});
it("takes a selected change alone, not the submodule's changes below its path", () => {
expect(rollbackCandidates(["/r/libs/core"], status, roots)).toEqual([]);
expect(paths(rollbackCandidates(["/r/app.txt", "/r/libs/core"], status, roots))).toEqual(["/r/app.txt"]);
});
it("takes the changes below a selected folder", () => {
expect(paths(rollbackCandidates(["/r/libs"], status, roots))).toEqual(["/r/libs/core/core.txt"]);
});
});
+14 -3
View File
@@ -7,7 +7,7 @@ import { editorService, maybeEditorService, notifications, toolWindowService } f
import { annotationsShown, toggleAnnotations } from "./blame";
import { showBranchesPopup } from "./BranchWidget";
import { checkoutRevision, newBranch } from "./branchOps";
import { addToVcs, rollbackCandidates, showRollbackDialog, stashChanges } from "./changeOps";
import { addToVcs, isNestedRepoEntry, rollbackCandidates, showRollbackDialog, stashChanges } from "./changeOps";
import { useCommit } from "./commitStore";
import { gotoConflict, resolveAtCaret, showConflictsDialog } from "./conflictsUi";
import { focusedDiffNavigator } from "./diffNavigator";
@@ -20,7 +20,7 @@ import { allRepos, runRemote } from "./ops";
import { showPushDialog, showUpdateDialog, type PushTarget } from "./remoteUi";
import { chooseRevision } from "./RevisionChooser";
import { showCloneDialog } from "./clone";
import { refreshNow } from "./store";
import { refreshNow, repoName } from "./store";
const VCS = "vcsAvailable";
@@ -171,7 +171,18 @@ export function registerVcsActions(): () => void {
const explicit = argPaths(arg);
const sel = useCommit.getState().selection;
const paths = explicit?.length ? explicit : commitWindowFocused() && sel.length ? sel : undefined;
if (paths) return showRollbackDialog(rollbackCandidates(paths));
if (paths) {
const candidates = rollbackCandidates(paths);
const submodule = paths.find((p) => isNestedRepoEntry(p));
if (!candidates.length && submodule)
return notifications().notify({
group: "vcs",
level: "info",
title: "Submodule changes cannot be rolled back here",
body: `${repoName(submodule)} points to another commit. Check out the recorded commit in the submodule (git submodule update) to revert it.`,
});
return showRollbackDialog(candidates);
}
const active = maybeEditorService()?.activePath();
const activeChanged = active ? rollbackCandidates([active]) : [];
showRollbackDialog(activeChanged.length ? activeChanged : rollbackCandidates());
+21 -9
View File
@@ -77,20 +77,32 @@ export async function commitChanges(): Promise<string | undefined> {
}
/** Files the Rollback action applies to by default. */
export function rollbackCandidates(paths?: readonly string[]): FileStatus[] {
const status = useVcs.getState().status.filter((f) => {
export function rollbackCandidates(
paths?: readonly string[],
status: readonly FileStatus[] = useVcs.getState().status,
roots: readonly string[] = useVcs.getState().roots,
): FileStatus[] {
// A nested repository's own entry (a submodule's checked-out commit) is not rolled back
// from the enclosing repository: it is changed by checking out a commit in the submodule.
const changed = status.filter((f) => {
const g = groupOf(f);
return g === "changes" || g === "conflicts";
return (g === "changes" || g === "conflicts") && !isNestedRepoEntry(f.path, roots);
});
if (!paths?.length) return status;
if (!paths?.length) return changed;
const set = new Set(paths.map(stripSlash));
const picked = status.filter((f) => {
// A selected change is just that change; other paths are folders and take the changes below them.
const entries = new Set(status.map((f) => stripSlash(f.path)));
const folders = [...set].filter((s) => !entries.has(s));
return changed.filter((f) => {
const p = stripSlash(f.path);
if (set.has(p)) return true;
for (const s of set) if (p.startsWith(s + "/")) return true;
return false;
return set.has(p) || folders.some((s) => p.startsWith(s + "/"));
});
return picked;
}
/** `path` is the root of a repository nested in another one (its submodule / untracked-folder entry there). */
export function isNestedRepoEntry(path: string, roots: readonly string[] = useVcs.getState().roots): boolean {
const p = stripSlash(path);
return roots.includes(p) && roots.some((r) => p.startsWith(r + "/"));
}
/**
+1
View File
@@ -1945,6 +1945,7 @@
"Structure unavailable": "结构不可用",
"Subject line length": "主题行长度",
"Submit": "提交",
"Submodule changes cannot be rolled back here": "子模块的变更无法在此回滚",
"Subpixel": "子像素",
"Suggested names": "建议的名称",
"Super Method": "超方法",