Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andTodd Miller 56f4af0436 fix(plugins): preserve local work across repeated updates
Retiring only one previous installation delays data loss until the next update. Keep every outgoing installation until explicit removal so its local commits and dirty files remain recoverable.

Co-authored-by: Todd Miller <todd@themillertribe.org>
2026-10-26 11:43:38 +00:00
Todd Miller 9072bce0c3 fix(server): keep the previous plugin installation when an update replaces it
A Git-source install is checked out under
`~/.paseo/plugins/<plugin>/<install-id>/checkout`, and `updateSource` deleted
that directory once the new install was active. Anything in it that was never
pushed went with it: uncommitted files, and commits on a branch or a detached
HEAD, which is how the checkout arrives.

`removeVersion` now retires the outgoing version root as
`<version-root>.old-<timestamp>` instead of removing it. The copy kept by the
previous update is reaped first, so one retired root sits beside the active one
and they do not accumulate. Uninstalling still deletes the whole plugin root,
kept copies included, and a failed preparation or activation still discards its
staging candidate without touching the running installation.

The retired root is logged at info level so the path is discoverable; the update
result keeps its single `warning` field for the case where the old root could
not be kept.

Regression test: "keeps the previous Git installation and reaps it on the next
update" installs from a Git repository, writes into the checkout without
committing, applies a reviewed update, and asserts the retired checkout still
holds that file. It fails on the previous code (`expected [] to have a length of
1`) and passes here. A second update asserts the older copy is reaped and the
newer one is kept.

Refs https://github.com/getpaseo/paseo/issues/7904
2026-10-25 23:42:01 -07:00
5 changed files with 108 additions and 7 deletions
@@ -883,6 +883,90 @@ export default function contribute(server: PluginServerContext) {
await service.stopAllPlugins();
}, 30_000);
it("preserves local Git commits and dirty files across repeated updates", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "paseo-plugin-repository-"));
roots.push(repository);
await runGitCommand(["init", "-b", "main"], { cwd: repository });
// Fixture commits must not run the developer's global hooks (core.hooksPath).
await runGitCommand(["config", "core.hooksPath", "/dev/null"], { cwd: repository });
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: repository });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: repository });
await writeFile(
path.join(repository, "paseo-plugin.json"),
JSON.stringify({ id: "kept-update" }),
);
await writeFile(
path.join(repository, "index.server.ts"),
"export default function contribute(plugin: unknown) { void plugin; return () => undefined; }",
);
await writeFile(path.join(repository, "marker.txt"), "first");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "first"], { cwd: repository });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), createStore(home), "0.4.0", {
managedSources: new ManagedPluginSources(home),
}),
);
await service.start();
const installed = await service.installSource({ source: pathToFileURL(repository).href });
const pluginRoot = path.dirname(path.dirname(installed.path));
expect(await readdir(pluginRoot)).toHaveLength(1);
// Both committed and uncommitted work exist only in the installed checkout.
await runGitCommand(["config", "user.name", "Paseo Tests"], { cwd: installed.path });
await runGitCommand(["config", "user.email", "paseo@example.test"], { cwd: installed.path });
await writeFile(path.join(installed.path, "local.txt"), "unpushed commit");
await runGitCommand(["add", "local.txt"], { cwd: installed.path });
await runGitCommand(["commit", "-m", "local work"], { cwd: installed.path });
const localCommit = (
await runGitCommand(["rev-parse", "HEAD"], { cwd: installed.path })
).stdout.trim();
await writeFile(path.join(installed.path, "marker.txt"), "uncommitted work");
await writeFile(path.join(installed.path, "untracked.txt"), "untracked work");
await writeFile(path.join(repository, "marker.txt"), "second");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "second"], { cwd: repository });
await applyReviewedUpdate(service, "kept-update");
const retired = (await readdir(pluginRoot)).filter((entry) => /\.old-\d+$/.test(entry));
expect(retired).toHaveLength(1);
expect(await readdir(pluginRoot)).toHaveLength(2);
expect(
await readFile(path.join(pluginRoot, retired[0]!, "checkout", "marker.txt"), "utf8"),
).toBe("uncommitted work");
// Later updates must not delete the only copy of earlier local work.
await writeFile(path.join(repository, "marker.txt"), "third");
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(["commit", "-m", "third"], { cwd: repository });
await applyReviewedUpdate(service, "kept-update");
const retained = (await readdir(pluginRoot)).filter((entry) => /\.old-\d+$/.test(entry));
expect(retained).toHaveLength(2);
expect(await readdir(pluginRoot)).toHaveLength(3);
const preserved = path.join(pluginRoot, retired[0]!, "checkout");
expect(await readFile(path.join(preserved, "marker.txt"), "utf8")).toBe("uncommitted work");
expect(await readFile(path.join(preserved, "untracked.txt"), "utf8")).toBe("untracked work");
expect((await runGitCommand(["rev-parse", "HEAD"], { cwd: preserved })).stdout.trim()).toBe(
localCommit,
);
expect(
(await runGitCommand(["show", `${localCommit}:local.txt`], { cwd: preserved })).stdout.trim(),
).toBe("unpushed commit");
const second = retained.find((entry) => entry !== retired[0])!;
expect(await readFile(path.join(pluginRoot, second, "checkout", "marker.txt"), "utf8")).toBe(
"second",
);
// Uninstalling still clears the plugin root, kept copies included.
await service.removePlugin("kept-update");
await expect(stat(pluginRoot)).rejects.toThrow();
await service.stopAllPlugins();
}, 30_000);
it("activates an update when the enabled plugin previously failed to start", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+3 -2
View File
@@ -749,9 +749,10 @@ export class PluginService {
this.notify(pluginId);
let warning: string | undefined;
try {
await managedSources.removeVersion(pluginId, source.path);
const retired = await managedSources.retireVersion(pluginId, source.path);
this.logger.info({ pluginId, directory: retired }, "Kept the previous plugin installation");
} catch (error) {
warning = `Plugin updated, but previous installation cleanup failed: ${error instanceof Error ? error.message : String(error)}`;
warning = `Plugin updated, but the previous installation could not be kept: ${error instanceof Error ? error.message : String(error)}`;
}
return {
id: pluginId,
@@ -26,6 +26,8 @@ import { acquireNpm, isNpmSource, readNpmArtifact, resolveNpm } from "./managed-
const GIT_TIMEOUT_MS = 120_000;
const GIT_ENV = { GIT_TERMINAL_PROMPT: "0" } as const;
/** A retired installation root keeps its version id and gains a timestamp: `<uuid>.old-<ms>`. */
const RETIRED_VERSION_SUFFIX = ".old-";
// COMPAT(plugin-source-record): v0.8.0; accept and strip pre-correction selector/revision/root copies until daemon floor supports artifact-derived metadata.
const ManagedPluginRecordSchema = z.union([
z.object({
@@ -381,10 +383,17 @@ export class ManagedPluginSources {
async discard(candidate: ManagedPluginCandidate): Promise<void> {
await rm(candidate.versionRoot, { recursive: true, force: true });
}
async removeVersion(pluginId: string, configuredPath: string): Promise<void> {
/**
* Keeps the outgoing installation root instead of deleting it, so a Git checkout's uncommitted
* files and unpushed commits survive later updates too. Only explicit removal deletes them.
*/
async retireVersion(pluginId: string, configuredPath: string): Promise<string> {
const location = this.locate(pluginId, configuredPath);
await assertRealContainment(this.root, path.dirname(location.versionRoot));
await rm(location.versionRoot, { recursive: true, force: true });
const pluginRoot = path.dirname(location.versionRoot);
await assertRealContainment(this.root, pluginRoot);
const retired = `${location.versionRoot}${RETIRED_VERSION_SUFFIX}${Date.now()}`;
await rename(location.versionRoot, retired);
return retired;
}
async remove(pluginId: string): Promise<void> {
if (!this.records[pluginId]) return;
+5 -1
View File
@@ -2827,7 +2827,11 @@ JSON and noninteractive ordinary updates require `--yes`.
Approval acquires exactly the reviewed commit or npm artifact. If it is unavailable or the installed
plugin changed while reviewing, the update fails and asks you to check again. Failed preparation or
activation retains the previous installation. Manual app update review is not available yet.
activation retains the previous installation. Successful updates keep each previous installation in
an adjacent `<install-id>.old-<timestamp>` directory, including its local files and Git commits.
Later updates do not delete these copies, so disk use grows until you remove them manually or
uninstall the plugin with `paseo plugin remove <id>`. Copy out any work you need before uninstalling.
Manual app update review is not available yet.
Put `--host <url>` before a management command when the target is not the CLI's default daemon. `remove`
never deletes a directory source; it deletes managed files for Git and npm sources. The install-time
+4 -1
View File
@@ -2485,7 +2485,10 @@ paseo plugin remove my-plugin
JSON 模式和非交互的普通更新需要 `--yes`。
批准后会获取被审查的那个确切提交或 npm 制品。如果它不可用,或者在审查期间已安装的插件发生了变化,更新会失败
并要求你重新检查。准备或激活失败会保留之前的安装。目前还不支持在应用中手动审查更新。
并要求你重新检查。准备或激活失败会保留之前的安装。更新成功后,每次被替换的安装都会保留在相邻的
`<install-id>.old-<timestamp>` 目录中,包括本地文件和 Git 提交。后续更新不会删除这些副本,因此磁盘占用会持续
增长,直到你手动删除它们或运行 `paseo plugin remove <id>` 卸载插件。卸载前请先复制出需要保留的工作。
目前还不支持在应用中手动审查更新。
当目标不是 CLI 的默认 daemon 时,请把 `--host <url>` 放在管理命令之前。`remove` 永远不会删除目录来源;对于
Git 和 npm 来源,它会删除托管文件。安装时的 `--id` 就是运行时 ID,允许同一个目录或仓库被安装多次。