fix(coding-agent): refresh temporary git extension caches on cache hits

This commit is contained in:
Mario Zechner 2026-02-06 22:01:49 +01:00
parent 92fdb53c10
commit 310da43042
3 changed files with 101 additions and 2 deletions

View file

@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed temporary git package caches (`-e <git-url>`) to refresh on cache hits for unpinned sources, including detached/no-upstream checkouts
## [0.52.7] - 2026-02-06
### New Features

View file

@ -870,6 +870,8 @@ export class DefaultPackageManager implements PackageManager {
if (!existsSync(installedPath)) {
const installed = await installMissing();
if (!installed) continue;
} else if (scope === "temporary" && !parsed.pinned) {
await this.refreshTemporaryGitSource(parsed, sourceStr);
}
metadata.baseDir = installedPath;
this.collectPackageResources(installedPath, accumulator, filter, metadata);
@ -1153,8 +1155,13 @@ export class DefaultPackageManager implements PackageManager {
// Fetch latest from remote (handles force-push by getting new history)
await this.runCommand("git", ["fetch", "--prune", "origin"], { cwd: targetDir });
// Reset to upstream tracking branch (handles force-push gracefully)
await this.runCommand("git", ["reset", "--hard", "@{upstream}"], { cwd: targetDir });
// Reset to tracking branch. Fall back to origin/HEAD when no upstream is configured.
try {
await this.runCommand("git", ["reset", "--hard", "@{upstream}"], { cwd: targetDir });
} catch {
await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: targetDir }).catch(() => {});
await this.runCommand("git", ["reset", "--hard", "origin/HEAD"], { cwd: targetDir });
}
// Clean untracked files (extensions should be pristine)
await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir });
@ -1165,6 +1172,16 @@ export class DefaultPackageManager implements PackageManager {
}
}
private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise<void> {
try {
await this.withProgress("pull", sourceStr, `Refreshing ${sourceStr}...`, async () => {
await this.updateGit(source, "temporary");
});
} catch {
// Keep cached temporary checkout if refresh fails.
}
}
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
const targetDir = this.getGitInstallPath(source, scope);
if (!existsSync(targetDir)) return;

View file

@ -7,6 +7,7 @@
*/
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@ -132,6 +133,20 @@ describe("DefaultPackageManager git update", () => {
expect(getCurrentCommit(installedDir)).toBe(latestCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v4");
});
it("should update even when local checkout has no upstream", async () => {
setupRemoteAndInstall();
createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
const latestCommit = createCommit(remoteDir, "extension.ts", "// v3", "Third commit");
const detachedCommit = getCurrentCommit(installedDir);
git(["checkout", detachedCommit], installedDir);
await packageManager.update();
expect(getCurrentCommit(installedDir)).toBe(latestCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v3");
});
});
describe("force-push scenarios", () => {
@ -233,6 +248,69 @@ describe("DefaultPackageManager git update", () => {
});
});
describe("temporary git sources", () => {
it("should refresh cached temporary git sources when resolving", async () => {
const gitHost = "github.com";
const gitPath = "test/extension";
const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8);
const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath);
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
rmSync(cachedDir, { recursive: true, force: true });
mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true });
writeFileSync(
join(cachedDir, "package.json"),
JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2),
);
writeFileSync(extensionFile, "// stale");
const executedCommands: string[] = [];
const managerWithInternals = packageManager as unknown as {
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
};
managerWithInternals.runCommand = async (command, args) => {
executedCommands.push(`${command} ${args.join(" ")}`);
if (command === "git" && args[0] === "reset") {
writeFileSync(extensionFile, "// fresh");
}
};
await packageManager.resolveExtensionSources([gitSource], { temporary: true });
expect(executedCommands).toContain("git fetch --prune origin");
expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// fresh");
});
it("should not refresh pinned temporary git sources", async () => {
const gitHost = "github.com";
const gitPath = "test/extension";
const hash = createHash("sha256").update(`git-${gitHost}-${gitPath}`).digest("hex").slice(0, 8);
const cachedDir = join(tmpdir(), "pi-extensions", `git-${gitHost}`, hash, gitPath);
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
rmSync(cachedDir, { recursive: true, force: true });
mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true });
writeFileSync(
join(cachedDir, "package.json"),
JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2),
);
writeFileSync(extensionFile, "// pinned");
const executedCommands: string[] = [];
const managerWithInternals = packageManager as unknown as {
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
};
managerWithInternals.runCommand = async (command, args) => {
executedCommands.push(`${command} ${args.join(" ")}`);
};
await packageManager.resolveExtensionSources([`${gitSource}@main`], { temporary: true });
expect(executedCommands).toEqual([]);
expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// pinned");
});
});
describe("scope-aware update", () => {
it("should not install locally when source is only registered globally", async () => {
setupRemoteAndInstall();