mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 07:01:30 +00:00
fix(coding-agent): handle force-pushed git extensions in pi update
This commit is contained in:
parent
3c252d50f5
commit
bfb21e31f8
3 changed files with 279 additions and 1 deletions
|
|
@ -66,6 +66,7 @@ There are multiple SDK breaking changes since v0.49.3. For the quickest migratio
|
||||||
|
|
||||||
### Fixed
|
### 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))
|
- 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 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))
|
- Fixed /reload restoring the correct editor after reload ([#949](https://github.com/badlogic/pi-mono/pull/949) by [@Perlence](https://github.com/Perlence))
|
||||||
|
|
|
||||||
|
|
@ -935,13 +935,67 @@ export class DefaultPackageManager implements PackageManager {
|
||||||
await this.installGit(source, scope);
|
await this.installGit(source, scope);
|
||||||
return;
|
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");
|
const packageJsonPath = join(targetDir, "package.json");
|
||||||
if (existsSync(packageJsonPath)) {
|
if (existsSync(packageJsonPath)) {
|
||||||
await this.runCommand("npm", ["install"], { cwd: targetDir });
|
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<void> {
|
private async removeGit(source: GitSource, scope: SourceScope): Promise<void> {
|
||||||
const targetDir = this.getGitInstallPath(source, scope);
|
const targetDir = this.getGitInstallPath(source, scope);
|
||||||
if (!existsSync(targetDir)) return;
|
if (!existsSync(targetDir)) return;
|
||||||
|
|
|
||||||
223
packages/coding-agent/test/git-update.test.ts
Normal file
223
packages/coding-agent/test/git-update.test.ts
Normal file
|
|
@ -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/<host>/<path>
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue