diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 09cd6821..831e363a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,10 @@ - Built-in tools now work like custom tools in extensions. To get built-in tool definitions, import `readToolDefinition` / `createReadToolDefinition()` and the equivalent `bash`, `edit`, `write`, `grep`, `find`, and `ls` exports from `@mariozechner/pi-coding-agent`. - Cleaned up `buildSystemPrompt()` so built-in tool snippets and tool-local guidelines come from built-in `ToolDefinition` metadata, while cross-tool and global prompt rules stay in system prompt construction. +### Fixed + +- Fixed `pi update` for git packages to skip destructive reset, clean, and reinstall steps when the fetched target already matches the local checkout ([#2503](https://github.com/badlogic/pi-mono/issues/2503)) + ## [0.61.1] - 2026-03-20 ### New Features diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index cacd5e1c..e67b0492 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -1278,6 +1278,23 @@ export class DefaultPackageManager implements PackageManager { return match[1]; } + private async getLocalGitUpdateTarget(installedPath: string): Promise<{ ref: string; head: string }> { + try { + const head = await this.runCommandCapture("git", ["rev-parse", "@{upstream}"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + return { ref: "@{upstream}", head }; + } catch { + await this.runCommand("git", ["remote", "set-head", "origin", "-a"], { cwd: installedPath }).catch(() => {}); + const head = await this.runCommandCapture("git", ["rev-parse", "origin/HEAD"], { + cwd: installedPath, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + return { ref: "origin/HEAD", head }; + } + } + private async getGitUpstreamRef(installedPath: string): Promise { try { const upstream = await this.runCommandCapture("git", ["rev-parse", "--abbrev-ref", "@{upstream}"], { @@ -1463,14 +1480,17 @@ 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 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 }); + const localHead = await this.runCommandCapture("git", ["rev-parse", "HEAD"], { + cwd: targetDir, + timeoutMs: NETWORK_TIMEOUT_MS, + }); + const target = await this.getLocalGitUpdateTarget(targetDir); + if (localHead.trim() === target.head.trim()) { + return; } + await this.runCommand("git", ["reset", "--hard", target.ref], { cwd: targetDir }); + // Clean untracked files (extensions should be pristine) await this.runCommand("git", ["clean", "-fdx"], { cwd: targetDir }); diff --git a/packages/coding-agent/test/git-update.test.ts b/packages/coding-agent/test/git-update.test.ts index c8c2cd68..2f59f9ee 100644 --- a/packages/coding-agent/test/git-update.test.ts +++ b/packages/coding-agent/test/git-update.test.ts @@ -107,6 +107,45 @@ describe("DefaultPackageManager git update", () => { } describe("normal updates (no force-push)", () => { + it("should skip reset, clean, and install when already up to date", async () => { + mkdirSync(remoteDir, { recursive: true }); + git(["init"], remoteDir); + git(["config", "--local", "user.email", "test@test.com"], remoteDir); + git(["config", "--local", "user.name", "Test"], remoteDir); + writeFileSync(join(remoteDir, "package.json"), JSON.stringify({ name: "test-extension", version: "1.0.0" })); + createCommit(remoteDir, "extension.ts", "// v1", "Initial commit"); + + mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true }); + git(["clone", remoteDir, installedDir], tempDir); + settingsManager.setPackages([gitSource]); + + const executedCommands: string[] = []; + const managerWithInternals = packageManager as unknown as { + runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; + }; + managerWithInternals.runCommand = async (command, args, options) => { + executedCommands.push(`${command} ${args.join(" ")}`); + if (command === "npm") { + return; + } + const result = spawnSync(command, args, { + cwd: options?.cwd, + encoding: "utf-8", + }); + if (result.status !== 0) { + throw new Error(`Command failed: ${command} ${args.join(" ")}\n${result.stderr}`); + } + }; + + await packageManager.update(); + + expect(executedCommands).toContain("git fetch --prune origin"); + expect(executedCommands).not.toContain("git reset --hard @{upstream}"); + expect(executedCommands).not.toContain("git reset --hard origin/HEAD"); + expect(executedCommands).not.toContain("git clean -fdx"); + expect(executedCommands).not.toContain("npm install"); + }); + it("should update to latest commit when remote has new commits", async () => { setupRemoteAndInstall(); expect(getFileContent(installedDir, "extension.ts")).toBe("// v1"); @@ -269,6 +308,7 @@ describe("DefaultPackageManager git update", () => { const executedCommands: string[] = []; const managerWithInternals = packageManager as unknown as { runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise; + runCommandCapture: (command: string, args: string[], options?: { cwd?: string }) => Promise; }; managerWithInternals.runCommand = async (command, args) => { executedCommands.push(`${command} ${args.join(" ")}`); @@ -276,6 +316,15 @@ describe("DefaultPackageManager git update", () => { writeFileSync(extensionFile, "// fresh"); } }; + managerWithInternals.runCommandCapture = async (_command, args) => { + if (args[0] === "rev-parse" && args[1] === "HEAD") { + return "local-head"; + } + if (args[0] === "rev-parse" && args[1] === "@{upstream}") { + return "remote-head"; + } + return ""; + }; await packageManager.resolveExtensionSources([gitSource], { temporary: true });