diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 352eca18..de1b4847 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -66,6 +66,7 @@ There are multiple SDK breaking changes since v0.49.3. For the quickest migratio ### Fixed +- Git extension updates now handle force-pushed remotes gracefully instead of failing ([#961](https://github.com/badlogic/pi-mono/pull/961) by [@aliou](https://github.com/aliou)) - Extension `setWorkingMessage()` calls in `agent_start` handlers now work correctly; previously the message was silently ignored because the loading animation didn't exist yet ([#935](https://github.com/badlogic/pi-mono/issues/935)) - Fixed package auto-discovery to respect loader rules, config overrides, and force-exclude patterns - Fixed /reload restoring the correct editor after reload ([#949](https://github.com/badlogic/pi-mono/pull/949) by [@Perlence](https://github.com/Perlence)) diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index 86ccaf42..6c156174 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -935,13 +935,67 @@ export class DefaultPackageManager implements PackageManager { await this.installGit(source, scope); return; } - await this.runCommand("git", ["pull"], { cwd: targetDir }); + + // Fetch latest from remote (handles force-push by getting new history) + await this.runCommand("git", ["fetch", "--prune", "origin"], { cwd: targetDir }); + + // Detect default branch and reset to it + const defaultBranch = this.getGitDefaultBranch(targetDir); + await this.runCommand("git", ["reset", "--hard", `origin/${defaultBranch}`], { 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.runCommand("npm", ["install"], { cwd: targetDir }); } } + /** + * Detect the default branch of a git repository. + * Tries multiple methods with fallbacks. + */ + private getGitDefaultBranch(repoDir: string): string { + // Try symbolic-ref first (fast and reliable) + const symbolicRef = spawnSync("git", ["symbolic-ref", "-q", "refs/remotes/origin/HEAD"], { + cwd: repoDir, + encoding: "utf-8", + }); + if (symbolicRef.status === 0 && symbolicRef.stdout) { + // Returns something like "refs/remotes/origin/main" + const match = symbolicRef.stdout.trim().match(/refs\/remotes\/origin\/(.+)/); + if (match) { + return match[1]; + } + } + + // Try common branch names + for (const branch of ["main", "master"]) { + const showRef = spawnSync("git", ["show-ref", "--verify", `refs/remotes/origin/${branch}`], { + cwd: repoDir, + encoding: "utf-8", + }); + if (showRef.status === 0) { + return branch; + } + } + + // Last resort: use current branch + const revParse = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd: repoDir, + encoding: "utf-8", + }); + if (revParse.status === 0 && revParse.stdout) { + const branch = revParse.stdout.trim(); + if (branch && branch !== "HEAD") { + return branch; + } + } + + return "main"; + } + 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 new file mode 100644 index 00000000..f2e0831a --- /dev/null +++ b/packages/coding-agent/test/git-update.test.ts @@ -0,0 +1,223 @@ +/** + * Tests for git-based extension updates, specifically handling force-push scenarios. + * + * These tests verify that DefaultPackageManager.update() handles: + * - Normal git updates (no force-push) + * - Force-pushed remotes gracefully (currently fails, fix needed) + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DefaultPackageManager } from "../src/core/package-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; + +// Helper to run git commands in a directory +function git(args: string[], cwd: string): string { + const result = spawnSync("git", args, { + cwd, + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`Command failed: git ${args.join(" ")}\n${result.stderr}`); + } + return result.stdout.trim(); +} + +// Helper to create a commit with a file +function createCommit(repoDir: string, filename: string, content: string, message: string): string { + writeFileSync(join(repoDir, filename), content); + git(["add", filename], repoDir); + git(["commit", "-m", message], repoDir); + return git(["rev-parse", "HEAD"], repoDir); +} + +// Helper to get current commit hash +function getCurrentCommit(repoDir: string): string { + return git(["rev-parse", "HEAD"], repoDir); +} + +// Helper to get file content +function getFileContent(repoDir: string, filename: string): string { + return readFileSync(join(repoDir, filename), "utf-8"); +} + +describe("DefaultPackageManager git update", () => { + let tempDir: string; + let remoteDir: string; // Simulates the "remote" repository + let agentDir: string; // The agent directory where extensions are installed + let installedDir: string; // The installed extension directory + let settingsManager: SettingsManager; + let packageManager: DefaultPackageManager; + + // Git source that maps to our installed directory structure + const gitSource = "github.com/test/extension"; + + beforeEach(() => { + tempDir = join(tmpdir(), `git-update-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + remoteDir = join(tempDir, "remote"); + agentDir = join(tempDir, "agent"); + + // This matches the path structure: agentDir/git// + installedDir = join(agentDir, "git", "github.com", "test", "extension"); + + mkdirSync(agentDir, { recursive: true }); + + settingsManager = SettingsManager.inMemory(); + packageManager = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + }); + }); + + afterEach(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + /** + * Sets up a "remote" repository and clones it to the installed directory. + * This simulates what packageManager.install() would do. + */ + function setupRemoteAndInstall(): void { + // Create "remote" repository + mkdirSync(remoteDir, { recursive: true }); + git(["init"], remoteDir); + git(["config", "user.email", "test@test.com"], remoteDir); + git(["config", "user.name", "Test"], remoteDir); + createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + + // Clone to installed directory (simulating what install() does) + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + git(["config", "user.email", "test@test.com"], installedDir); + git(["config", "user.name", "Test"], installedDir); + + // Add to settings so update() only processes this scope + settingsManager.setExtensionPaths([gitSource]); + } + + describe("normal updates (no force-push)", () => { + it("should update to latest commit when remote has new commits", async () => { + setupRemoteAndInstall(); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); + + // Add a new commit to remote + const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); + + // Update via package manager (no args = uses settings) + await packageManager.update(); + + // Verify update succeeded + expect(getCurrentCommit(installedDir)).toBe(newCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + }); + + it("should handle multiple commits ahead", async () => { + setupRemoteAndInstall(); + + // Add multiple commits to remote + createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); + createCommit(remoteDir, "extension.ts", "// v3", "Third commit"); + const latestCommit = createCommit(remoteDir, "extension.ts", "// v4", "Fourth commit"); + + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(latestCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v4"); + }); + }); + + describe("force-push scenarios", () => { + it("should recover when remote history is rewritten", async () => { + setupRemoteAndInstall(); + const initialCommit = getCurrentCommit(remoteDir); + + // Add commit to remote + createCommit(remoteDir, "extension.ts", "// v2", "Commit to keep"); + + // Update to get the new commit + await packageManager.update(); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2"); + + // Now force-push to rewrite history on remote + git(["reset", "--hard", initialCommit], remoteDir); + const rewrittenCommit = createCommit(remoteDir, "extension.ts", "// v2-rewritten", "Rewritten commit"); + + // Update should succeed despite force-push + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(rewrittenCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-rewritten"); + }); + + it("should recover when local commit no longer exists in remote", async () => { + setupRemoteAndInstall(); + + // Add commits to remote + createCommit(remoteDir, "extension.ts", "// v2", "Commit A"); + createCommit(remoteDir, "extension.ts", "// v3", "Commit B"); + + // Update to get all commits + await packageManager.update(); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); + + // Force-push remote to remove commits A and B + git(["reset", "--hard", "HEAD~2"], remoteDir); + const newCommit = createCommit(remoteDir, "extension.ts", "// v2-new", "New commit replacing A and B"); + + // Update should succeed - the commits we had locally no longer exist + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(newCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v2-new"); + }); + + it("should handle complete history rewrite", async () => { + setupRemoteAndInstall(); + + // Remote gets several commits + createCommit(remoteDir, "extension.ts", "// v2", "v2"); + createCommit(remoteDir, "extension.ts", "// v3", "v3"); + + await packageManager.update(); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v3"); + + // Maintainer force-pushes completely different history + git(["reset", "--hard", "HEAD~2"], remoteDir); + createCommit(remoteDir, "extension.ts", "// rewrite-a", "Rewrite A"); + const finalCommit = createCommit(remoteDir, "extension.ts", "// rewrite-b", "Rewrite B"); + + // Should handle this gracefully + await packageManager.update(); + + expect(getCurrentCommit(installedDir)).toBe(finalCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// rewrite-b"); + }); + }); + + describe("pinned sources", () => { + it("should not update pinned git sources (with @ref)", async () => { + setupRemoteAndInstall(); + const initialCommit = getCurrentCommit(installedDir); + + // Reconfigure with pinned ref + settingsManager.setExtensionPaths([`${gitSource}@${initialCommit}`]); + + // Add new commit to remote + createCommit(remoteDir, "extension.ts", "// v2", "Second commit"); + + // Update should be skipped for pinned sources + await packageManager.update(); + + // Should still be on initial commit + expect(getCurrentCommit(installedDir)).toBe(initialCommit); + expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); + }); + }); +});