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.
Цей коміт міститься в:
l0ng-ai committed 2026-10-23 23:15:09 +08:00
1 джерело c868ec8b68
коміт b8f422d451
11 files changed
+211 -68

No files matched your search

+9
Переглянути файл
@@ -9,6 +9,7 @@ import {
type CheckState,
type ChangeNode,
} from "../changes";
import { rootOf } from "../model";
const fs = (rel: string, staged: FileStatus["staged"], unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
@@ -102,6 +103,14 @@ describe("several repositories", () => {
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", () => {
+5 -3
Переглянути файл
@@ -4,7 +4,8 @@ import { call, commands } from "@/platform/ipc";
import { editorService, type EditorInput } from "@/workbench/contributions";
import { baseName, effectiveKind } from "./model";
import { readWorkingText } from "./ops";
import { statusOf, useVcs } from "./store";
import { renamedFromPath } from "./changes";
import { currentRepoRoot, statusOf } from "./store";
export type DiffSide =
/** File content at a git revision (`HEAD`, `:0` = index, a commit id, `<id>^`). */
@@ -104,15 +105,16 @@ export function workingDiffData(path: string, line?: number): DiffData {
kind === "untracked" || kind === "added"
? { type: "text", text: "", label: "(does not exist)" }
: st?.renamedFrom
? { type: "rev", rev: "HEAD", path: joinRoot(st.renamedFrom), label: `HEAD (${st.renamedFrom})` }
? { type: "rev", rev: "HEAD", path: renamedFromPath(st) ?? undefined, label: `HEAD (${st.renamedFrom})` }
: { type: "rev", rev: "HEAD" };
const right: DiffSide =
kind === "deleted" ? { type: "text", text: "", label: "(deleted)" } : { type: "worktree" };
return { path, left, right, line };
}
/** A path of the selected repository's log (repository-relative) made absolute. */
function joinRoot(rel: string): string {
const root = useVcs.getState().root;
const root = currentRepoRoot();
return root ? `${root}/${rel}` : rel;
}
+7 -3
Переглянути файл
@@ -92,12 +92,16 @@ export function relTo(root: string, path: string): string {
return path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
}
/** The innermost of `roots` (repository roots) containing `path`. */
/**
* The innermost of `roots` (repository roots) containing `path`. A nested repository's root
* is an entry of the enclosing one (submodule, untracked folder), so it belongs there; to
* itself only when no root encloses it (as the backend routes it).
*/
export function rootOf(path: string, roots: readonly string[]): string | null {
const p = stripSlash(path);
let best: string | null = null;
for (const r of roots) if ((p === r || p.startsWith(`${r}/`)) && (!best || r.length > best.length)) best = r;
return best;
for (const r of roots) if (p.startsWith(`${r}/`) && (!best || r.length > best.length)) best = r;
return best ?? (roots.includes(p) ? p : null);
}
/** Repository name for display: its folder relative to the project, else its folder name. */
+6 -4
Переглянути файл
@@ -14,7 +14,7 @@ import { openDialog, promptText } from "./dialogs";
import { KIND_COLOR, baseName, dirName } from "./model";
import { runGitOperation, runOp } from "./ops";
import { reloadLog, useGitLog } from "./logStore";
import { conflictedFiles, currentRepo, refreshNow, repoOf, useVcs } from "./store";
import { conflictedFiles, currentRepo, currentRepoRoot, refreshNow, repoOf, useVcs } from "./store";
import styles from "./common.module.css";
// ---------------------------------------------------------------------------
@@ -416,12 +416,14 @@ function CompareWithLocalDialog({ rev, label, onClose }: { rev: string; label: s
alive = false;
};
}, [rev]);
const open = (f: ChangedFile) =>
const open = (f: ChangedFile) => {
const repoRoot = currentRepoRoot() ?? root;
void openDiff({
path: `${root}/${f.path}`,
left: { type: "rev", rev, label, path: f.oldPath ? `${root}/${f.oldPath}` : undefined },
path: `${repoRoot}/${f.path}`,
left: { type: "rev", rev, label, path: f.oldPath ? `${repoRoot}/${f.oldPath}` : undefined },
right: { type: "worktree" },
});
};
const current = files?.[active];
return (
<Dialog
+20 -2
Переглянути файл
@@ -176,10 +176,28 @@ export async function runRemote(
task(text || null, progressFraction(text), false);
};
try {
// Every repository is tried; the failures are reported together. A pull skips the
// repositories that have nothing to pull from (detached HEAD, e.g. submodules; no upstream).
const failures: string[] = [];
for (const repo of repos) {
if (repos.length > 1 && repo) logConsole("info", repoName(repo));
await call(commands.vcsRemote(repo, request, op, channel));
const name = repos.length > 1 && repo ? repoName(repo) : null;
if (name) logConsole("info", name);
if (op.op === "pull" && repos.length > 1) {
const info = await call(commands.vcsBranchInfo(repo)).catch(() => null);
if (info && (!info.head || !info.upstream)) {
logConsole("info", info.head ? "No upstream branch, skipped" : "Detached HEAD, skipped");
continue;
}
}
try {
await call(commands.vcsRemote(repo, request, op, channel));
} catch (e) {
if (isCancelled(e) || !name) throw e;
logConsole("err", errorMessage(e));
failures.push(`${name}: ${errorMessage(e)}`);
}
}
if (failures.length) throw new Error(failures.join("\n"));
lastRemoteError = null;
notifications().notify({
group: "vcs",
+16 -10
Переглянути файл
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import type { BranchInfo, Commit, PushTags, RemoteOp } from "@/bindings";
import { call, commands } from "@/platform/ipc";
import { tx } from "@/platform/i18n";
import { workflowSettings } from "@/platform/workflowSettings";
import { notifications } from "@/workbench/contributions";
import { Checkbox, Dialog, List, Select, Spinner, T } from "@/ui";
@@ -129,21 +130,26 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
useEffect(() => {
let alive = true;
Promise.all(
// A repository that cannot be read (e.g. its upstream branch was deleted) is left out.
Promise.allSettled(
roots.map(async (root) => {
const branch = await call(commands.vcsBranchInfo(root));
const q = outgoingQuery(branch.upstream);
const commits = branch.head ? await logOf(q.branch, q.limit, q.range, root) : [];
return { root, branch, commits };
}),
)
.then((all) => {
if (!alive) return;
const list = pushCandidates(all);
setRepos(list);
setChecked(defaultPushChecked(list, currentRepo()));
})
.catch((e) => alive && setError(e instanceof Error ? e.message : String(e)));
).then((results) => {
if (!alive) return;
const loaded = results.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (!loaded.length && failed) {
setError(failed.reason instanceof Error ? failed.reason.message : String(failed.reason));
return;
}
const list = pushCandidates(loaded);
setRepos(list);
setChecked(defaultPushChecked(list, currentRepo()));
});
return () => {
alive = false;
};
@@ -240,7 +246,7 @@ function MultiPushDialog({ onClose }: { onClose: () => void }) {
label={repoName(r.repo.root)}
/>
<span className={styles.commitMeta}>
{r.repo.branch.head} → {r.repo.branch.upstream ?? "new remote branch"}
{r.repo.branch.head} → {r.repo.branch.upstream ?? tx("new remote branch")}
</span>
</span>
) : (
+23 -3
Переглянути файл
@@ -72,6 +72,12 @@ export function currentRepo(): string | null {
return useVcs.getState().repo;
}
/** Root of the selected repository, which its log's and diffs' repository-relative paths are relative to. */
export function currentRepoRoot(): string | null {
const s = useVcs.getState();
return s.repo ?? s.roots[0] ?? s.root;
}
/** Root of the innermost repository containing `path`. */
export function repoOf(path: string, roots: readonly string[] = useVcs.getState().roots): string | null {
return rootOf(path, roots);
@@ -148,14 +154,28 @@ export function refreshNow(): Promise<void> {
useVcs.setState({ loading: true });
try {
const s = useVcs.getState();
const [vcsRoots, status, branch, branches, stashes] = await Promise.all([
call(commands.vcsRoots()),
call(commands.vcsStatus()),
const scoped = Promise.all([
call(commands.vcsBranchInfo(s.repo)),
s.branchesLoaded ? call(commands.vcsBranches(s.repo)) : Promise.resolve(s.branches),
s.stashesLoaded ? call(commands.vcsStashes(s.repo)) : Promise.resolve(s.stashes),
]).then(
(values) => ({ values }),
(error: unknown) => ({ error }),
);
const [vcsRoots, status, selected] = await Promise.all([
call(commands.vcsRoots()),
call(commands.vcsStatus()),
scoped,
]);
const roots = vcsRoots.map((r) => r.root);
if ("error" in selected) {
// The selected repository is gone (removed since the last scan): fall back to the primary one.
if (!s.repo || roots.includes(s.repo)) throw selected.error;
useVcs.setState({ roots, repo: null, branchesLoaded: false, stashesLoaded: false });
void refreshNow();
return;
}
const [branch, branches, stashes] = selected.values;
const operations = Object.fromEntries(vcsRoots.filter((r) => r.operation).map((r) => [r.root, r.operation!]));
// `null` (before the first refresh) is the primary repository, listed first.
const shown = s.repo && roots.includes(s.repo) ? s.repo : (roots[0] ?? null);
+4
Переглянути файл
@@ -630,6 +630,7 @@
"Evaluate expression (↩) or add a watch (⇧⌘↩)": "计算表达式 (↩) 或添加监视 (⇧⌘↩)",
"Evaluate in the debug console": "在调试控制台中计算",
"Evaluation result": "计算结果",
"Everything is up to date": "一切都是最新的",
"Exception Breakpoints": "异常断点",
"Exception breakpoints": "异常断点",
"Exclude": "排除",
@@ -1436,6 +1437,7 @@
"Purple": "紫色",
"Push": "推送",
"Push All up to Here…": "推送到此处为止的所有提交…",
"Push Commits": "推送提交",
"Push rejected": "推送被拒绝",
"Push tags:": "推送标签:",
"Push…": "推送…",
@@ -1564,6 +1566,7 @@
"Replacements": "替换项",
"Replacing…": "正在替换…",
"Reports errors and warnings from language servers.": "报告来自语言服务器的错误和警告。",
"Repository": "仓库",
"Request": "请求",
"Request Changes…": "请求更改…",
"Require": "必需",
@@ -2256,6 +2259,7 @@
"match expression": "match 表达式",
"match one node,": "匹配一个节点,",
"new column definition": "新列定义",
"new remote branch": "新远程分支",
"new table definition": "新表定义",
"new view definition": "新视图定义",
"no file is open in the editor": "编辑器中没有打开的文件",