diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 547f51a5d..e221d2e7e 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1817,6 +1817,7 @@ export class DefaultPackageManager implements PackageManager { this.ensureGitIgnore(gitRoot); } mkdirSync(dirname(targetDir), { recursive: true }); + rmSync(this.getGitUpdateMarkerPath(targetDir), { force: true }); try { await this.runCommand("git", ["clone", source.repo, targetDir]); @@ -1850,6 +1851,57 @@ export class DefaultPackageManager implements PackageManager { await this.ensureGitRef(targetDir, target.fetchArgs, target.ref); } + private hasMissingGitDependencies(targetDir: string): boolean { + const packageJsonPath = join(targetDir, "package.json"); + if (!existsSync(packageJsonPath)) return false; + + try { + const manifest = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { dependencies?: unknown }; + if ( + !manifest.dependencies || + typeof manifest.dependencies !== "object" || + Array.isArray(manifest.dependencies) + ) { + return false; + } + + const nodeModulesDir = resolve(targetDir, "node_modules"); + return Object.keys(manifest.dependencies).some((name) => { + const dependencyPath = resolve(nodeModulesDir, name); + if (!dependencyPath.startsWith(`${nodeModulesDir}${sep}`)) return false; + return !existsSync(dependencyPath); + }); + } catch { + return false; + } + } + + private async repairMissingGitDependencies(targetDir: string): Promise { + if (!this.hasMissingGitDependencies(targetDir)) return; + await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); + } + + private getGitUpdateMarkerPath(targetDir: string): string { + return join(dirname(targetDir), `.${basename(targetDir)}.pi-update-incomplete`); + } + + private async cleanAndInstallGitDependencies(targetDir: string, markerPath: string): Promise { + // Clean untracked files (extensions should be pristine). If this fails after + // deleting dependencies, repair them so the existing extension still loads. + try { + await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); + } catch (error) { + await this.repairMissingGitDependencies(targetDir).catch(() => {}); + throw error; + } + + const packageJsonPath = join(targetDir, "package.json"); + if (existsSync(packageJsonPath)) { + await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); + } + rmSync(markerPath, { force: true }); + } + private async ensureGitRef(targetDir: string, fetchArgs: string[], ref: string): Promise { // Fetch only the ref we will reset to, avoiding unrelated branch/tag noise. await this.runCommand("git", fetchArgs, { cwd: targetDir }); @@ -1863,19 +1915,19 @@ export class DefaultPackageManager implements PackageManager { cwd: targetDir, timeoutMs: NETWORK_TIMEOUT_MS, }); + const markerPath = this.getGitUpdateMarkerPath(targetDir); if (localHead.trim() === targetHead.trim()) { + if (existsSync(markerPath)) { + await this.cleanAndInstallGitDependencies(targetDir, markerPath); + } else { + await this.repairMissingGitDependencies(targetDir); + } return; } + writeFileSync(markerPath, "", "utf-8"); await this.runCommand("git", ["reset", "--hard", commitRef], { cwd: targetDir }); - - // Clean untracked files (extensions should be pristine) - await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); - - const packageJsonPath = join(targetDir, "package.json"); - if (existsSync(packageJsonPath)) { - await this.runNpmCommand(this.getGitDependencyInstallArgs(), { cwd: targetDir }); - } + await this.cleanAndInstallGitDependencies(targetDir, markerPath); } private async refreshTemporaryGitSource(source: GitSource, sourceStr: string): Promise { @@ -1893,8 +1945,8 @@ export class DefaultPackageManager implements PackageManager { private async removeGit(source: GitSource, scope: SourceScope): Promise { const targetDir = this.getGitInstallPath(source, scope); - if (!existsSync(targetDir)) return; rmSync(targetDir, { recursive: true, force: true }); + rmSync(this.getGitUpdateMarkerPath(targetDir), { force: true }); this.pruneEmptyGitParents(targetDir, this.getGitInstallRoot(scope)); } diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index a10959596..31c89faff 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -924,6 +924,63 @@ Content`, expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); }); + it("should repair missing git package dependencies when the checkout is already current", async () => { + const source = "git:github.com/user/repo"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]; + mkdirSync(targetDir, { recursive: true }); + writeFileSync( + join(targetDir, "package.json"), + JSON.stringify({ name: "repo", version: "1.0.0", dependencies: { dependency: "1.0.0" } }), + ); + settingsManager.setPackages([source]); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({ + ref: "@{upstream}", + head: "current-head", + fetchArgs, + }); + vi.spyOn(managerWithInternals, "runCommandCapture").mockResolvedValue("current-head"); + const runCommandSpy = vi.spyOn(managerWithInternals, "runCommand").mockResolvedValue(undefined); + + await packageManager.update(source); + + expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); + expect(runCommandSpy).not.toHaveBeenCalledWith("git", ["clean", "-fdx"], { cwd: targetDir }); + }); + + it("should repair deleted git package dependencies when cleaning fails", async () => { + const source = "git:github.com/user/repo"; + const targetDir = join(agentDir, "git", "github.com", "user", "repo"); + const fetchArgs = ["fetch", "--prune", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"]; + mkdirSync(targetDir, { recursive: true }); + writeFileSync( + join(targetDir, "package.json"), + JSON.stringify({ name: "repo", version: "1.0.0", dependencies: { dependency: "1.0.0" } }), + ); + settingsManager.setPackages([source]); + + const managerWithInternals = packageManager as unknown as PackageManagerInternals; + vi.spyOn(managerWithInternals, "getLocalGitUpdateTarget").mockResolvedValue({ + ref: "@{upstream}", + head: "new-head", + fetchArgs, + }); + vi.spyOn(managerWithInternals, "runCommandCapture").mockImplementation(async (_command, args) => + args[1] === "HEAD" ? "old-head" : "new-head", + ); + const runCommandSpy = vi + .spyOn(managerWithInternals, "runCommand") + .mockImplementation(async (_command, args) => { + if (args[0] === "clean") throw new Error("simulated clean failure"); + }); + + await expect(packageManager.update(source)).rejects.toThrow("simulated clean failure"); + + expect(runCommandSpy).toHaveBeenCalledWith("npm", ["install", "--omit=dev"], { cwd: targetDir }); + }); + it("should use plain install through npmCommand argv when updating git package dependencies", async () => { settingsManager = SettingsManager.inMemory({ npmCommand: ["mise", "exec", "node@20", "--", "pnpm"],