feat(vcs): stage Unity metadata with new assets (#1509)

In a Unity project, Add File to Git for a new asset under Assets/ now takes its .meta along: an untracked .meta that already exists is listed in the same confirmation, and one Unity writes later is added once the asset's addition has succeeded. The ask / silently / never setting applies as before; pending metadata is dropped when the asset is unstaged or deleted, or the project or repository changes. File-operation paths are compared in IPC form, so they match the status paths on Windows.

Closes #1429

Co-authored-by: hacbit <[email protected]>
Цей коміт міститься в:
Hacbit authored and GitHub committed 2026-11-05 21:42:08 +08:00
1 джерело 9fb38769ad
коміт 36fd117b29
3 files changed
+651 -10

No files matched your search

+454
Переглянути файл
@@ -0,0 +1,454 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FileStatus, ProjectInfo } from "@/bindings";
const mocks = vi.hoisted(() => ({ stage: vi.fn(), ask: vi.fn(), refresh: vi.fn(), notify: vi.fn(), mode: "ask" }));
vi.mock("@/platform/ipc", async (original) => {
const real = await original<typeof import("@/platform/ipc")>();
return {
...real,
isTauri: () => true,
commands: { ...real.commands, vcsStage: mocks.stage },
};
});
vi.mock("@/workbench/messages", () => ({ ask: mocks.ask }));
vi.mock("@/platform/workflowSettings", () => ({
workflowSettings: () => ({ vcs: { confirmation: { onCreate: mocks.mode, onDelete: "never" } } }),
}));
vi.mock("../store", async () => {
const { create } = await import("zustand");
return { useVcs: create(() => ({})), refreshNow: mocks.refresh };
});
import { notifyFileOperation } from "@/platform/fileOps";
import { useProject } from "@/platform/project";
import { provideNotificationService } from "@/workbench/contributions";
import { installFileConfirmations } from "../confirmations";
import { clearConsole } from "../ops";
import { useVcs } from "../store";
const asset = "/p/Assets/New.cs";
const meta = asset + ".meta";
const file = (
path: string,
staged: FileStatus["staged"] = null,
unstaged: FileStatus["unstaged"] = "untracked",
): FileStatus => ({ path, relPath: path.slice(3), staged, unstaged, renamedFrom: null });
const publish = (...status: FileStatus[]) => useVcs.setState({ status });
const createFile = (path = asset) => notifyFileOperation({ kind: "created", paths: [path] });
const settle = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
let dispose: () => void;
beforeEach(() => {
vi.clearAllMocks();
mocks.mode = "ask";
mocks.ask.mockResolvedValue({ button: "ok", checked: false });
mocks.refresh.mockResolvedValue(undefined);
mocks.stage.mockImplementation(async (paths: string[]) => {
publish(
...useVcs
.getState()
.status.map((f) => (paths.includes(f.path) ? { ...f, staged: "added" as const, unstaged: null } : f)),
);
return { status: "ok", data: null };
});
clearConsole();
provideNotificationService({ notify: mocks.notify });
useProject.setState({ project: { root: "/p", unity: {} } as ProjectInfo, opening: false, openingPath: null });
useVcs.setState({
available: true,
root: "/p",
roots: ["/p"],
repo: "/p",
statusLoaded: true,
loading: false,
error: null,
status: [file(asset)],
});
dispose = installFileConfirmations();
});
afterEach(() => dispose());
describe("Unity new-file Git confirmations", () => {
it("includes existing metadata in the confirmation and stages both files", async () => {
publish(file(asset), file(meta));
createFile();
await settle();
expect(mocks.ask.mock.calls[0]![0].body).toContain("Assets/New.cs.meta");
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
});
it("adds delayed metadata only, preserving later unstaged script edits", async () => {
createFile();
await settle();
publish(file(asset, "added", "modified"), file(meta), file("/p/Assets/Other.cs.meta"));
await settle();
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
expect(useVcs.getState().status[0]!.unstaged).toBe("modified");
expect(mocks.ask).toHaveBeenCalledTimes(1);
});
it("finds metadata generated while the confirmation is open", async () => {
mocks.ask.mockImplementationOnce(async () => {
publish(file(asset), file(meta));
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
});
it("also pairs metadata in silent mode", async () => {
mocks.mode = "silently";
createFile();
await settle();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.ask).not.toHaveBeenCalled();
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
});
it("waits for a completed status refresh before adding delayed metadata", async () => {
createFile();
await settle();
useVcs.setState({ loading: true });
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
useVcs.setState({ loading: false });
await settle();
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
});
it("finds metadata generated during the initial staging operation", async () => {
mocks.stage.mockImplementationOnce(async () => {
publish(file(asset, "added", null), file(meta));
return { status: "ok", data: null };
});
createFile();
await settle();
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
});
it.each(["declined", "never", "failure"])("does not follow metadata after %s", async (reason) => {
if (reason === "declined") mocks.ask.mockResolvedValue({ button: "cancel", checked: false });
if (reason === "never") mocks.mode = "never";
if (reason === "failure") mocks.stage.mockRejectedValueOnce(new Error("stage failed"));
createFile();
await settle();
const calls = mocks.stage.mock.calls.length;
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(calls);
});
it.each(["unstage", "delete", "switch", "opening", "repository", "dispose"])(
"cancels pending metadata after %s",
async (reason) => {
createFile();
await settle();
if (reason === "unstage") publish(file(asset));
if (reason === "delete") publish(file(asset, "added", "deleted"));
if (reason === "switch") useProject.setState({ project: { root: "/other", unity: {} } as ProjectInfo });
if (reason === "opening") {
useProject.setState({ opening: true, openingPath: "/other" });
useProject.setState({ opening: false, openingPath: null });
}
if (reason === "repository") {
useVcs.setState({ repo: "/p/other" });
useVcs.setState({ repo: "/p" });
}
if (reason === "dispose") dispose();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
},
);
it.each(["normal project", "outside Assets", "metadata itself"])("does not pair files in %s", async (reason) => {
if (reason === "normal project") useProject.setState({ project: { root: "/p" } as ProjectInfo });
const path = reason === "outside Assets" ? "/p/New.cs" : reason === "metadata itself" ? meta : asset;
publish(file(path), file(path + ".meta"));
createFile(path);
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path]);
});
it.each(["modified", "ignored"] as const)("does not stage %s metadata", async (kind) => {
createFile();
await settle();
publish(file(asset, "added", null), file(meta, null, kind));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
});
it("does not repeatedly retry a failed metadata addition", async () => {
createFile();
await settle();
mocks.stage.mockRejectedValueOnce(new Error("index locked"));
publish(file(asset, "added", null), file(meta));
await settle();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(2);
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "index locked" }));
});
it("does not stage files if the project changed while the dialog was open", async () => {
mocks.ask.mockImplementationOnce(async () => {
useProject.setState({ project: { root: "/other" } as ProjectInfo });
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it.each(["repository", "opening", "dispose"])("discards an open confirmation after %s", async (reason) => {
mocks.ask.mockImplementationOnce(async () => {
if (reason === "repository") useVcs.setState({ repo: "/p/nested" });
if (reason === "opening") useProject.setState({ opening: true, openingPath: "/other" });
if (reason === "dispose") dispose();
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it("does not stage a source or metadata staged by the user while the dialog was open", async () => {
publish(file(asset), file(meta));
mocks.ask.mockImplementationOnce(async () => {
publish(file(asset, "added", "modified"), file(meta, "added", "modified"));
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it("preserves manually staged metadata even when it was explicitly included in the creation event", async () => {
publish(file(asset), file(meta));
mocks.ask.mockImplementationOnce(async () => {
publish(file(asset), file(meta, "added", "modified"));
return { button: "ok", checked: false };
});
notifyFileOperation({ kind: "created", paths: [asset, meta] });
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
expect(useVcs.getState().status[1]!.unstaged).toBe("modified");
});
it.each(["added", "modified", "ignored", "missing"] as const)(
"rechecks metadata that becomes %s while the dialog is open",
async (kind) => {
publish(file(asset), file(meta));
mocks.ask.mockImplementationOnce(async () => {
publish(
file(asset),
...(kind === "missing"
? []
: [file(meta, kind === "added" ? "added" : null, kind === "added" ? "modified" : kind)]),
);
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
},
);
it("waits for successful staging before following metadata", async () => {
let complete!: () => void;
mocks.stage.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = () => resolve({ status: "ok", data: null });
}),
);
createFile();
await settle();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
complete();
await settle();
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
});
it("remembers an unstage while the initial staging response is still in flight", async () => {
let complete!: () => void;
mocks.stage.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = () => resolve({ status: "ok", data: null });
}),
);
createFile();
await settle();
publish(file(asset, "added", null));
publish(file(asset));
publish(file(asset, "added", null), file(meta));
complete();
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
});
it("does not re-add metadata already included in the successful initial operation", async () => {
publish(file(asset), file(meta));
let complete!: () => void;
mocks.stage.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = () => resolve({ status: "ok", data: null });
}),
);
createFile();
await settle();
publish(file(asset, "added", null), file(meta, "added", null));
publish(file(asset, "added", null), file(meta));
complete();
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
});
it("does not follow metadata after an in-flight addition outlives its project", async () => {
let complete!: () => void;
mocks.stage.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = () => resolve({ status: "ok", data: null });
}),
);
createFile();
await settle();
useProject.setState({ opening: true, openingPath: "/other" });
complete();
await settle();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
});
it("ignores incomplete or failed status snapshots", async () => {
createFile();
await settle();
useVcs.setState({ statusLoaded: false });
publish(file(asset));
useVcs.setState({ statusLoaded: true, error: "refresh failed" });
publish(file(asset, "added", "modified"), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
useVcs.setState({ error: null });
await settle();
expect(mocks.stage).toHaveBeenLastCalledWith([meta]);
});
it("does not cancel an addition when the first refresh discovers the repository", async () => {
useVcs.setState({ repo: null, roots: [], statusLoaded: false });
mocks.refresh.mockImplementationOnce(async () => {
useVcs.setState({ roots: ["/p"], repo: "/p", statusLoaded: true });
});
createFile();
await settle();
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset], [meta]]);
});
it("does not stage from a failed pre-addition refresh", async () => {
mocks.ask.mockImplementationOnce(async () => {
useVcs.setState({ error: "refresh failed" });
return { button: "ok", checked: false };
});
createFile();
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it("rechecks the project after the pre-staging promise resolves", async () => {
mocks.refresh.mockResolvedValueOnce(undefined).mockImplementationOnce(async () => {
queueMicrotask(() => queueMicrotask(() => useProject.setState({ opening: true, openingPath: "/other" })));
});
createFile();
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it("does not infer metadata consent for children of a copied directory", async () => {
createFile("/p/Assets");
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset]);
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
});
it("keeps a batch limited to the explicitly created files", async () => {
const other = "/p/Assets/Other.cs";
publish(file(asset), file(meta), file(other), file(`${other}.meta`));
notifyFileOperation({ kind: "created", paths: [asset, asset] });
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([asset, meta]);
});
it.each([
["D:/Unity Project", "D:\\Unity Project\\Assets\\New.cs", "D:/Unity Project/Assets/New.cs"],
["//server/share/project", "\\\\server\\share\\project\\Assets\\New.cs", "//server/share/project/Assets/New.cs"],
["/p", "/p/Assets/Name\\Part.cs", "/p/Assets/Name\\Part.cs"],
["/project space", "/project space/Assets/角色.cs", "/project space/Assets/角色.cs"],
])("pairs files with native or literal path characters under %s", async (root, input, path) => {
useProject.setState({ project: { root, unity: {} } as ProjectInfo });
useVcs.setState({ root, roots: [root], repo: root });
publish(file(path), file(`${path}.meta`));
createFile(input);
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path, `${path}.meta`]);
});
it.each(["/p/AssetsOther/New.cs", "/p/Packages/New.cs"])("does not pair %s outside Assets", async (path) => {
publish(file(path), file(`${path}.meta`));
createFile(path);
await settle();
expect(mocks.stage).toHaveBeenCalledExactlyOnceWith([path]);
});
it("does not add files from another project's similarly named path", async () => {
const other = "/project/Assets/New.cs";
publish(file(other), file(`${other}.meta`));
createFile(other);
await settle();
expect(mocks.stage).not.toHaveBeenCalled();
});
it("cancels metadata when the asset's repository disappears", async () => {
useVcs.setState({ roots: ["/p", "/p/Assets"] });
createFile();
await settle();
useVcs.setState({ roots: ["/p"] });
publish(file(asset, "added", null), file(meta));
await settle();
expect(mocks.stage).toHaveBeenCalledTimes(1);
});
it("still cancels other pending additions while metadata staging is in flight", async () => {
const other = "/p/Assets/Other.cs";
publish(file(asset), file(other));
notifyFileOperation({ kind: "created", paths: [asset, other] });
await settle();
let complete!: () => void;
mocks.stage.mockImplementationOnce(
() =>
new Promise((resolve) => {
complete = () => resolve({ status: "ok", data: null });
}),
);
publish(file(asset, "added", null), file(meta), file(other, "added", null));
await settle();
publish(file(asset, "added", null), file(meta), file(other));
publish(file(asset, "added", null), file(meta, "added", null), file(other, "added", null), file(`${other}.meta`));
complete();
await settle();
expect(mocks.stage.mock.calls.map(([paths]) => paths)).toEqual([[asset, other], [meta]]);
});
});
+25 -10
Переглянути файл
@@ -6,20 +6,20 @@
import { onFileOperation, type FileOperation } from "@/platform/fileOps";
import { tx } from "@/platform/i18n";
import { call, commands, isTauri } from "@/platform/ipc";
import { ipcPath, isSameOrInside } from "@/platform/paths";
import { loadSettings } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { ask } from "@/workbench/messages";
import { effectiveKind, relTo, stripSlash } from "./model";
import { runOp } from "./ops";
import { refreshNow, useVcs } from "./store";
const inside = (root: string, p: string) => p === root || p.startsWith(root.endsWith("/") ? root : root + "/");
import { createUnityMetadataStaging } from "./unityMetadata";
/** Status paths affected by `paths` (a deleted folder covers its content). */
function matching(paths: readonly string[], kind: "untracked" | "deleted"): string[] {
const out = new Set<string>();
for (const f of useVcs.getState().status) {
const p = stripSlash(f.path);
const p = stripSlash(ipcPath(f.path));
if (effectiveKind(f) !== kind || f.staged) continue;
for (const q of paths) {
if (p === q || p.startsWith(q + "/")) out.add(p);
@@ -59,27 +59,33 @@ async function confirmFiles(op: FileOperation["kind"], files: string[]): Promise
return { ok: r.button === "ok", remember: r.checked };
}
async function handle(op: FileOperation) {
async function handle(op: FileOperation, metadata: ReturnType<typeof createUnityMetadataStaging>) {
const vcs = useVcs.getState();
if (!vcs.available || !vcs.root || !isTauri()) return;
const conf = workflowSettings().vcs.confirmation;
const mode = op.kind === "created" ? conf.onCreate : conf.onDelete;
if (mode === "never") return;
const paths = op.paths.map(stripSlash).filter((p) => inside(vcs.root!, p));
const paths = op.paths.map((p) => stripSlash(ipcPath(p))).filter((p) => isSameOrInside(p, vcs.root!));
if (!paths.length) return;
const addition = metadata.begin(op.kind === "created" ? paths : []);
await refreshNow();
if (!addition.current()) return;
const files = matching(paths, op.kind === "created" ? "untracked" : "deleted");
if (!files.length) return;
const confirmed = addition.include(files);
if (mode === "ask") {
const { ok, remember } = await confirmFiles(op.kind, files);
const { ok, remember } = await confirmFiles(op.kind, confirmed);
if (!addition.current()) return;
if (remember) {
const key = op.kind === "created" ? "vcs.confirmation.onCreate" : "vcs.confirmation.onDelete";
await rememberSetting(key, ok ? "silently" : "never");
}
if (!ok) return;
}
const n = files.length;
await runOp(
const stageFiles = await addition.beforeStage(files);
if (!addition.current() || !stageFiles.length) return;
const n = stageFiles.length;
const ok = await runOp(
op.kind === "created"
? n === 1
? tx("Add 1 file to Git")
@@ -87,10 +93,19 @@ async function handle(op: FileOperation) {
: n === 1
? tx("Remove 1 file from Git")
: tx`Remove ${n} files from Git`,
() => call(commands.vcsStage(files)),
async () => {
await call(commands.vcsStage(stageFiles));
return true;
},
);
await addition.finished(!!ok);
}
export function installFileConfirmations(): () => void {
return onFileOperation((op) => void handle(op));
const metadata = createUnityMetadataStaging();
const stopFiles = onFileOperation((op) => void handle(op, metadata));
return () => {
stopFiles();
metadata.dispose();
};
}
+172
Переглянути файл
@@ -0,0 +1,172 @@
import type { FileStatus } from "@/bindings";
import { tx } from "@/platform/i18n";
import { call, commands } from "@/platform/ipc";
import { ipcPath, isSameOrInside } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { rootOf, stripSlash } from "./model";
import { runOp } from "./ops";
import { refreshNow, useVcs, type VcsState } from "./store";
const pathKey = (path: string) => stripSlash(ipcPath(path));
const untracked = (file: FileStatus | undefined) =>
file?.unstaged === "untracked" && !file.staged && !file.path.endsWith("/");
const ready = (vcs: VcsState) => vcs.available && vcs.statusLoaded && !vcs.loading && !vcs.error;
const selectedRepo = (vcs: VcsState) => vcs.repo ?? vcs.roots[0] ?? null;
const statusByPath = () => new Map(useVcs.getState().status.map((file) => [pathKey(file.path), file]));
interface PendingMetadata {
repo: string;
confirmed: boolean;
seenAdded: boolean;
}
/** Pair only IDE-created files whose addition succeeded, for this project/repository session. */
export function createUnityMetadataStaging() {
const pending = new Map<string, PendingMetadata>();
let generation = 0;
let disposed = false;
let staging = false;
const clear = () => {
generation++;
pending.clear();
};
const drain = () => {
const vcs = useVcs.getState();
if (disposed || !pending.size || !ready(vcs)) return;
const status = statusByPath();
const files: string[] = [];
for (const [asset, entry] of pending) {
const source = status.get(asset);
const meta = status.get(`${asset}.meta`);
if (source?.staged === "added") entry.seenAdded = true;
if (
((entry.confirmed || entry.seenAdded) && source?.staged !== "added") ||
source?.unstaged === "deleted" ||
rootOf(asset, vcs.roots) !== entry.repo ||
rootOf(`${asset}.meta`, vcs.roots) !== entry.repo ||
(meta && !untracked(meta))
) {
pending.delete(asset);
} else if (meta && entry.confirmed && !staging) {
// Consume consent before invoking: a failed addition must not retry on every refresh.
pending.delete(asset);
files.push(meta.path);
}
}
if (!files.length) return;
staging = true;
void runOp(files.length === 1 ? tx("Add 1 file to Git") : tx`Add ${files.length} files to Git`, () =>
call(commands.vcsStage(files)),
).finally(() => {
staging = false;
drain();
});
};
const stopVcs = useVcs.subscribe((s, prev) => {
const before = selectedRepo(prev);
// The first refresh discovers the primary repository; that is not a user switch.
if (s.root !== prev.root || !s.available || (before && selectedRepo(s) !== before)) clear();
else drain();
});
const stopProject = useProject.subscribe((s, prev) => {
if (
s.project?.root !== prev.project?.root ||
!!s.project?.unity !== !!prev.project?.unity ||
(s.opening && !prev.opening)
)
clear();
});
return {
begin(paths: readonly string[]) {
const started = generation;
const current = () => !disposed && generation === started && !useProject.getState().opening;
const project = useProject.getState().project;
const assetsDir = project?.unity ? `${pathKey(project.root)}/Assets` : null;
const requested = new Set(paths.map(pathKey));
const attempt = new Map<string, PendingMetadata>();
let assets: Map<string, string> | undefined;
const include = (files: readonly string[]) => {
const vcs = useVcs.getState();
const status = statusByPath();
const paired = (assets ??= new Map(
files.flatMap((file) => {
const path = pathKey(file);
const source = status.get(path);
const repo = rootOf(path, vcs.roots);
return assetsDir &&
requested.has(path) &&
path !== assetsDir &&
isSameOrInside(path, assetsDir) &&
!path.toLowerCase().endsWith(".meta") &&
source &&
untracked(source) &&
repo &&
rootOf(`${path}.meta`, vcs.roots) === repo
? [[path, repo] as const]
: [];
}),
));
const out = new Set(
files.filter((file) => {
const path = pathKey(file);
return !(paired.has(path) || paired.has(path.replace(/\.meta$/, ""))) || untracked(status.get(path));
}),
);
for (const [asset, repo] of paired) {
if (!untracked(status.get(asset)) || rootOf(asset, vcs.roots) !== repo) {
out.delete(asset);
out.delete(`${asset}.meta`);
paired.delete(asset);
continue;
}
const meta = status.get(`${asset}.meta`);
if (meta && untracked(meta) && rootOf(pathKey(meta.path), vcs.roots) === repo) out.add(meta.path);
}
return [...out];
};
return {
current,
include,
async beforeStage(files: readonly string[]) {
if (assets?.size) {
await refreshNow();
if (!ready(useVcs.getState())) return [];
}
if (!current()) return [];
const out = include(files);
const selected = new Set(out.map(pathKey));
for (const [asset, repo] of assets ?? []) {
// Observe cancellation while the initial IPC is in flight, but do not
// add metadata until it succeeds. Already included metadata is finished.
if (selected.has(`${asset}.meta`)) continue;
const entry = { repo, confirmed: false, seenAdded: false };
attempt.set(asset, entry);
pending.set(asset, entry);
}
return out;
},
async finished(ok: boolean) {
for (const [asset, entry] of attempt) {
if (pending.get(asset) !== entry) continue;
if (ok && current()) entry.confirmed = true;
else pending.delete(asset);
}
if (!ok || !current() || !attempt.size) return;
await refreshNow();
if (current()) drain();
},
};
},
dispose() {
disposed = true;
clear();
stopVcs();
stopProject();
},
};
}