fix(coding-agent): preserve VCS cache refresh semantics

(cherry picked from commit 3f3e475109deb7b9e7690720827876fc982b5b8d)
This commit is contained in:
can1357
2026-07-29 22:37:34 +02:00
parent 1d41b269f7
commit f62526f111
2 changed files with 36 additions and 1 deletions
@@ -262,6 +262,7 @@ export class StatusLineComponent implements Component {
#cachedBranch: string | null | undefined = undefined;
#cachedBranchRepoId: string | null | undefined = undefined;
#cachedBranchCwd: string | undefined = undefined;
#cachedBranchHasGitRepository = false;
// In-flight reftable resolve slot. Ownership is the launch id, not the cwd:
// two live resolves can share a cwd string across an invalidation, and a
// stale one must never free (or poison) a slot it no longer owns.
@@ -677,6 +678,9 @@ export class StatusLineComponent implements Component {
// event, re-introducing the render-path spawn churn the async resolve
// was designed to avoid. Explicit Git/repository invalidation (watcher
// HEAD-move, cwd/repo switch) goes through {@link invalidateGitCaches}.
// A tool may open, close, or merge a PR without moving HEAD. Expire the
// settled PR context on ordinary activity while leaving HEAD work intact.
this.#cachedPrContext = undefined;
}
#invalidateSessionCaches(): void {
this.#clearUsageStartTimer();
@@ -700,6 +704,7 @@ export class StatusLineComponent implements Component {
this.#cachedBranch = undefined;
this.#cachedBranchRepoId = undefined;
this.#cachedBranchCwd = undefined;
this.#cachedBranchHasGitRepository = false;
// Abort before releasing the in-flight slot. Releasing alone would allow
// repeated invalidations to fan out still-running git subprocesses.
this.#branchResolveActive?.controller.abort();
@@ -805,6 +810,7 @@ export class StatusLineComponent implements Component {
const prev = this.#cachedBranchCwd === gitCwd ? this.#cachedBranch : undefined;
this.#cachedBranchCwd = gitCwd;
this.#cachedBranchRepoId = repoId;
this.#cachedBranchHasGitRepository = next === null;
this.#cachedBranch = next;
this.#branchLastFetch = Date.now();
if (prev !== next && this.#onBranchChange) this.#onBranchChange();
@@ -1397,7 +1403,10 @@ export class StatusLineComponent implements Component {
// so it must not be mistaken for an absent Git checkout and fall through to
// an ancestor jj workspace.
const gitHeadResolvePending = this.#branchResolveActive?.cwd === activeRepoCache.effectiveGitCwd;
const gitHeadIsJjLike = !gitHeadResolvePending && (gitBranch === "detached" || gitBranch === null);
const gitHeadIsJjLike =
!this.#cachedBranchHasGitRepository &&
!gitHeadResolvePending &&
(gitBranch === "detached" || gitBranch === null);
if (includeGit && gitHeadIsJjLike) {
gitBranch = this.#getJjBranch(activeRepoCache.effectiveGitCwd) ?? gitBranch;
}
@@ -408,6 +408,32 @@ describe("StatusLineComponent reftable branch resolve honors mid-flight invalida
await fs.rm(jjRootDir, { recursive: true, force: true });
}
});
it("does not query an ancestor jj workspace after nested Git HEAD resolution fails", async () => {
const fakeRepo = {
commonDir: "/nested/.git",
gitDir: "/nested/.git",
gitEntryPath: "/nested/.git",
headPath: "/nested/.git/HEAD",
repoRoot: "/nested",
} satisfies GitRepository;
vi.spyOn(git.repo, "resolveSync").mockReturnValue(fakeRepo);
vi.spyOn(git.repo, "isReftableSync").mockReturnValue(true);
vi.spyOn(git.head, "resolve").mockResolvedValue(null);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().promise);
const jjRoot = vi.spyOn(jj.repo, "rootSync").mockReturnValue("/workspace/jj-root");
const component = new StatusLineComponent(makeSession());
component.updateSettings(gitSegment);
component.getTopBorder(80);
await Promise.resolve();
await Promise.resolve();
component.getTopBorder(80);
expect(git.head.resolve).toHaveBeenCalledTimes(1);
expect(jjRoot).not.toHaveBeenCalled();
component.dispose();
});
});
describe("StatusLineComponent VCS watcher and jj request lifecycle", () => {