diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 2c6818e5..895014c1 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -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(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(() => { diff --git a/src/features/vcs/__tests__/rollback.test.ts b/src/features/vcs/__tests__/rollback.test.ts new file mode 100644 index 00000000..16472c86 --- /dev/null +++ b/src/features/vcs/__tests__/rollback.test.ts @@ -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"]); + }); +}); diff --git a/src/features/vcs/actions.tsx b/src/features/vcs/actions.tsx index 88329241..8ca8316f 100644 --- a/src/features/vcs/actions.tsx +++ b/src/features/vcs/actions.tsx @@ -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()); diff --git a/src/features/vcs/changeOps.tsx b/src/features/vcs/changeOps.tsx index b24bffe4..7855ce38 100644 --- a/src/features/vcs/changeOps.tsx +++ b/src/features/vcs/changeOps.tsx @@ -77,20 +77,32 @@ export async function commitChanges(): Promise { } /** 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 + "/")); } /** diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 3e866045..1f74a907 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1945,6 +1945,7 @@ "Structure unavailable": "结构不可用", "Subject line length": "主题行长度", "Submit": "提交", + "Submodule changes cannot be rolled back here": "子模块的变更无法在此回滚", "Subpixel": "子像素", "Suggested names": "建议的名称", "Super Method": "超方法",