Files
openidea/src/features/vcs/__tests__/changes.test.ts
T
l0ng-ai b8f422d451 fix(vcs): Route a nested repository's own entry to the enclosing one and harden multi-root
Review fixes for several repositories in one project:

- A path equal to a nested repository's root (a submodule's entry, an untracked
  clone) belongs to the enclosing repository, in the backend and the Commit view.
  It was routed to the nested repository as ".", so Rollback ran `git restore` and
  `git clean` over the whole submodule and Commit staged everything in it.
- Repository-scoped commands take the repository root exactly and fail when it is
  gone instead of running in an enclosing repository; path-based ones resolve the
  file's repository (Project::with_repo_of).
- The rescan on a repository appearing or going watches `.git/HEAD`: the watcher
  never reports a `.git` path itself, so the old check never fired.
- Status skips a repository that cannot be read; refresh falls back to the primary
  repository when the selected one is gone instead of failing every time.
- Commit and Compare-with-Local diffs resolve repository-relative paths against the
  selected repository's root, not the project root.
- Update Project and Fetch go on after a failing repository and report the failures
  together; Update skips repositories with a detached HEAD or no upstream.
- The Push dialog leaves out a repository that cannot be read.
- Shelve and patches fail clearly for changes of a nested repository instead of
  leaving them out silently (they still support the main repository only).
- Fix the repository discovery test on Windows (path separators); Chinese text for
  the new strings.
2026-10-23 23:15:09 +08:00

147 lines
5.0 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { FileStatus } from "@/bindings";
import {
aggregateCheck,
buildChangeTree,
commitRepos,
initialCheck,
planCommit,
type CheckState,
type ChangeNode,
} from "../changes";
import { rootOf } from "../model";
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
relPath: rel,
staged,
unstaged,
renamedFrom: null,
});
const status = [
fs("src/main/kotlin/App.kt", null, "modified"),
fs("src/main/kotlin/Partial.kt", "modified", "modified"),
fs("src/test/AppTest.kt", "added", null),
fs("README.md", "modified", null),
fs("notes.txt", null, "untracked"),
fs("conf.yml", null, "conflicted"),
fs("out/", null, "ignored"),
];
const shape = (nodes: ChangeNode[], depth = 0): string[] =>
nodes.flatMap((n) => [`${" ".repeat(depth)}${n.label}`, ...shape(n.children ?? [], depth + 1)]);
describe("buildChangeTree", () => {
it("groups conflicts, changes and unversioned files (flat)", () => {
expect(shape(buildChangeTree(status, "/r", false))).toEqual([
"Merge Conflicts",
" conf.yml",
"Changes",
" App.kt",
" AppTest.kt",
" Partial.kt",
" README.md",
"Unversioned Files",
" notes.txt",
]);
});
it("groups by directory with compacted chains", () => {
const changes = buildChangeTree(status, "/r", true).find((n) => n.group === "changes")!;
expect(shape([changes])).toEqual([
"Changes",
" src",
" main/kotlin",
" App.kt",
" Partial.kt",
" test",
" AppTest.kt",
" README.md",
]);
expect(changes.children![0]!.files).toHaveLength(3);
});
});
describe("several repositories", () => {
const nested = [...status, fs("libs/core/Lib.kt", null, "modified"), fs("libs/core/new.txt", null, "untracked")];
const repos = ["/r", "/r/libs/core"];
it("groups each change group by repository", () => {
expect(shape(buildChangeTree(nested, "/r", false, undefined, repos))).toEqual([
"Merge Conflicts",
" r",
" conf.yml",
"Changes",
" r",
" App.kt",
" AppTest.kt",
" Partial.kt",
" README.md",
" libs/core",
" Lib.kt",
"Unversioned Files",
" r",
" notes.txt",
" libs/core",
" new.txt",
]);
const changes = buildChangeTree(nested, "/r", true, undefined, repos)[1]!;
expect(changes.children!.map((n) => [n.label, n.path])).toEqual([
["r", "/r"],
["libs/core", "/r/libs/core"],
]);
expect(shape(changes.children![1]!.children!)).toEqual(["Lib.kt"]);
// One repository: no repository level.
const single = buildChangeTree(status, "/r", false, undefined, ["/r"]);
expect(shape(single)).toEqual(shape(buildChangeTree(status, "/r", false)));
});
it("commits every repository with included changes, the selected one last", () => {
const included = [nested[0]!, nested[7]!];
expect(commitRepos(included, repos, "/r")).toEqual(["/r/libs/core", "/r"]);
expect(commitRepos(included.slice(0, 1), repos, "/r/libs/core")).toEqual(["/r"]);
expect(commitRepos([], repos, "/r/libs/core")).toEqual(["/r/libs/core"]);
});
it("routes a nested repository's own entry (submodule) to the enclosing repository", () => {
expect(rootOf("/r/libs/core/Lib.kt", repos)).toBe("/r/libs/core");
expect(rootOf("/r/libs/core", repos)).toBe("/r");
expect(rootOf("/r/libs/core/", repos)).toBe("/r");
expect(rootOf("/r", repos)).toBe("/r");
expect(rootOf("/other", repos)).toBeNull();
});
});
describe("commit inclusion", () => {
it("defaults: tracked changes included, partial files keep their staged part, unversioned excluded", () => {
expect(initialCheck(status[0]!)).toBe("all");
expect(initialCheck(status[1]!)).toBe("staged");
expect(initialCheck(status[4]!)).toBe("none");
expect(initialCheck(status[5]!)).toBe("none");
});
it("plans staging so the index holds exactly the checked changes", () => {
const checks = new Map<string, CheckState>([
["/r/README.md", "none"],
["/r/notes.txt", "all"],
]);
const plan = planCommit(status, checks);
expect(plan.stage.sort()).toEqual(["/r/notes.txt", "/r/src/main/kotlin/App.kt"]);
expect(plan.unstage).toEqual(["/r/README.md"]);
expect(plan.included.map((f) => f.relPath).sort()).toEqual([
"notes.txt",
"src/main/kotlin/App.kt",
"src/main/kotlin/Partial.kt",
"src/test/AppTest.kt",
]);
});
it("aggregates checkbox state for groups", () => {
const checks = new Map<string, CheckState>();
expect(aggregateCheck([status[0]!, status[3]!], checks)).toMatchObject({ checked: true, indeterminate: false });
expect(aggregateCheck([status[0]!, status[4]!], checks)).toMatchObject({ checked: false, indeterminate: true });
expect(aggregateCheck([status[1]!], checks)).toMatchObject({ checked: false, indeterminate: true });
});
});