fix(coding-agent): reinstall dependencies if git clean fails (#7570)

On Windows some files might be held open causing `git clean` to error.
This would leave the extension in a broken state and fail next startup.
This commit is contained in:
Duncan Ogilvie 2026-08-04 00:02:49 +02:00 • committed by GitHub
parent a0014c1a83
commit b06dc76fd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 118 additions and 9 deletions

View file

@ -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<void> {
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<void> {
// 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<void> {
// 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<void> {
@ -1893,8 +1945,8 @@ export class DefaultPackageManager implements PackageManager {
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
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));
}

View file

@ -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"],