pi-mono/packages/coding-agent/test/git-update.test.ts
Petr Houška 342514bdf8
feat(coding-agent): list updated packages with version changes in pi update (#11707)
## Problem

`pi update`, `pi update --all` and `pi update --extensions` print only
`Updating <source>...` progress lines and a final `Updated packages`.
Users cannot see which extensions changed or to which version. Several
outdated npm packages in the same scope are installed as one batch, so
the progress output does not even name them.

## Change

`pi update` now prints a summary of what actually changed:

```
Updated 2 packages:
  npm:foo 1.2.0 -> 1.3.0
  git:github.com/user/bar (project) abc1234 -> def5678
```

When nothing changed it prints `Packages are already up to date` (or
`<source> is already up to date`).

- npm: compares the installed `package.json` version before and after
the batch install. The "before" version comes from the existing
`shouldUpdateNpmSource` check, so this adds no extra registry calls.
- git: compares `git rev-parse HEAD` before and after `updateGit`.
Commits are shortened to 7 characters.
- Packages whose version or commit did not change are omitted, including
npm installs that hit stale registry metadata (those already get the
existing warning).

## API

`PackageManager.update()` still returns `Promise<void>`.
`ProgressEvent.type` gains a `"result"` member. Each changed package is
reported through the existing progress callback as:

```ts
{ type: "result", action: "update", source: "npm:foo", message: "npm:foo 1.2.0 -> 1.3.0" }
```

The CLI collects these messages to print the summary. A separate type
keeps the existing contract that each `start` is followed by exactly one
`complete` or `error` for the same source. Reusing `complete` would have
produced a second `complete` for git packages and a `complete` without a
`start` for batched npm packages. The only compatibility cost is for
TypeScript consumers with an exhaustive `switch` on `event.type`.

## Tests

- `test/package-manager.test.ts`: npm update emits `start`, `complete`,
then a `result` with `npm:example@^1.0.0 (project) 1.0.0 -> 1.2.0`; an
up-to-date package emits no result.
- `test/git-update.test.ts`: git update emits a result with the old and
new commit; an up-to-date clone emits no result.

`should expand ~ in exclusion and exact patterns` fails locally on
Windows. It tests path resolution and is unrelated to this change.
2026-11-02 21:21:23 +01:00

346 lines
13 KiB
TypeScript

/**
* 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 after a complete history rewrite
*/
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.ts";
import { SettingsManager } from "../src/core/settings-manager.ts";
import { allowNetwork } from "./test-network-env.ts";
// 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();
}
function initGitRepo(repoDir: string): void {
git(["init", "--initial-branch=main"], repoDir);
git(["config", "--local", "user.email", "test@test.com"], repoDir);
git(["config", "--local", "user.name", "Test"], repoDir);
}
// 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");
}
type GitSourceForTest = {
type: "git";
repo: string;
host: string;
path: string;
pinned: boolean;
ref?: string;
};
interface PackageManagerPathInternals {
parseSource(source: string): GitSourceForTest;
getGitInstallPath(source: GitSourceForTest, scope: "temporary"): string;
}
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.
// Must use "git:" prefix so parseSource() treats it as a git source
// (bare "github.com/..." is not recognized as a git URL).
const gitSource = "git:github.com/test/extension";
beforeEach(() => {
allowNetwork();
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. */
function setupRemoteAndInstall(): void {
// Create "remote" repository
mkdirSync(remoteDir, { recursive: true });
initGitRepo(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", "--local", "user.email", "test@test.com"], installedDir);
git(["config", "--local", "user.name", "Test"], installedDir);
// Add to global packages so update() processes this source
settingsManager.setPackages([gitSource]);
}
describe("normal updates (no force-push)", () => {
it("should skip reset, clean, and install when already up to date", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(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<void>;
};
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}`);
}
};
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
expect(updated).toEqual([]);
expect(executedCommands).toContain(
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
);
expect(executedCommands).not.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");
const oldCommit = getCurrentCommit(installedDir);
// Add a new commit to remote
const newCommit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
// Update via package manager (no args = uses settings)
const updated: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "result" && event.action === "update" && event.message) updated.push(event.message);
});
await packageManager.update();
// Verify update succeeded
expect(updated).toEqual([`${gitSource} ${oldCommit.slice(0, 7)} -> ${newCommit.slice(0, 7)}`]);
expect(getCurrentCommit(installedDir)).toBe(newCommit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});
});
describe("git clean failures", () => {
// https://github.com/earendil-works/pi/issues/11597
it("should install dependencies and retry the clean later when git clean fails", async () => {
setupRemoteAndInstall();
const newCommit = createCommit(
remoteDir,
"package.json",
JSON.stringify({ name: "test-extension", version: "1.0.0" }),
"Add package.json",
);
const executedCommands: string[] = [];
let failClean = true;
const managerWithInternals = packageManager as unknown as {
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
};
managerWithInternals.runCommand = async (command, args, options) => {
const commandLine = `${command} ${args.join(" ")}`;
executedCommands.push(commandLine);
if (command === "npm") return;
if (commandLine === "git clean -fdx" && failClean) {
throw new Error("git clean -fdx failed with code 1");
}
const result = spawnSync(command, args, { cwd: options?.cwd, encoding: "utf-8" });
if (result.status !== 0) {
throw new Error(`Command failed: ${commandLine}\n${result.stderr}`);
}
};
const progressMessages: string[] = [];
packageManager.setProgressCallback((event) => {
if (event.type === "progress" && event.message) progressMessages.push(event.message);
});
const markerPath = join(agentDir, "git", "github.com", "test", ".extension.pi-update-incomplete");
await packageManager.update();
expect(getCurrentCommit(installedDir)).toBe(newCommit);
expect(executedCommands).toContain("git clean -fdx");
expect(executedCommands.some((command) => command.startsWith("npm install"))).toBe(true);
expect(progressMessages.some((message) => message.includes("Could not remove all untracked files"))).toBe(
true,
);
expect(existsSync(markerPath)).toBe(true);
failClean = false;
executedCommands.length = 0;
await packageManager.update();
expect(executedCommands).toContain("git clean -fdx");
expect(existsSync(markerPath)).toBe(false);
});
});
describe("force-push scenarios", () => {
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 checkout the configured pinned git ref during full and targeted updates", async () => {
mkdirSync(remoteDir, { recursive: true });
initGitRepo(remoteDir);
const v1Commit = createCommit(remoteDir, "extension.ts", "// v1", "Initial commit");
git(["tag", "v1"], remoteDir);
const v2Commit = createCommit(remoteDir, "extension.ts", "// v2", "Second commit");
git(["tag", "v2"], remoteDir);
mkdirSync(join(agentDir, "git", "github.com", "test"), { recursive: true });
git(["clone", remoteDir, installedDir], tempDir);
git(["checkout", "v1"], installedDir);
expect(getCurrentCommit(installedDir)).toBe(v1Commit);
const pinnedSource = `${gitSource}@v2`;
settingsManager.setPackages([pinnedSource]);
await packageManager.update();
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
git(["checkout", "v1"], installedDir);
await packageManager.update(pinnedSource);
expect(getCurrentCommit(installedDir)).toBe(v2Commit);
expect(getFileContent(installedDir, "extension.ts")).toBe("// v2");
});
});
describe("temporary git sources", () => {
it("should refresh cached temporary git sources when resolving", async () => {
const managerWithPaths = packageManager as unknown as PackageManagerPathInternals;
const cachedDir = managerWithPaths.getGitInstallPath(managerWithPaths.parseSource(gitSource), "temporary");
const extensionFile = join(cachedDir, "pi-extensions", "session-breakdown.ts");
rmSync(cachedDir, { recursive: true, force: true });
mkdirSync(join(cachedDir, "pi-extensions"), { recursive: true });
writeFileSync(
join(cachedDir, "package.json"),
JSON.stringify({ pi: { extensions: ["./pi-extensions"] } }, null, 2),
);
writeFileSync(extensionFile, "// stale");
const executedCommands: string[] = [];
const managerWithInternals = packageManager as unknown as {
runCommand: (command: string, args: string[], options?: { cwd?: string }) => Promise<void>;
runCommandCapture: (command: string, args: string[], options?: { cwd?: string }) => Promise<string>;
};
managerWithInternals.runCommand = async (command, args) => {
executedCommands.push(`${command} ${args.join(" ")}`);
if (command === "git" && args[0] === "reset") {
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";
}
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref") {
return "origin/main";
}
return "";
};
await packageManager.resolveExtensionSources([gitSource], { temporary: true });
expect(executedCommands).toContain(
"git fetch --prune --no-tags origin +refs/heads/main:refs/remotes/origin/main",
);
expect(getFileContent(cachedDir, "pi-extensions/session-breakdown.ts")).toBe("// fresh");
});
});
});