diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 67ba7f80..ebea5840 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed temporary git package caches (`-e `) to refresh on cache hits for unpinned sources, including detached/no-upstream checkouts + ## [0.52.7] - 2026-02-06 ### New Features diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 4fd10f2b..a5fd60c8 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -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 { + 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 { const targetDir = this.getGitInstallPath(source, scope); if (!existsSync(targetDir)) return; diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index 7a68ba0b..169bc937 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -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; + }; + 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; + }; + 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();