mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
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>
This commit is contained in:
co-authored by
Todd Miller
parent
9072bce0c3
commit
56f4af0436
@@ -883,7 +883,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
await service.stopAllPlugins();
|
||||
}, 30_000);
|
||||
|
||||
it("keeps the previous Git installation and reaps it on the next update", async () => {
|
||||
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-"));
|
||||
@@ -915,8 +915,17 @@ export default function contribute(server: PluginServerContext) {
|
||||
const pluginRoot = path.dirname(path.dirname(installed.path));
|
||||
expect(await readdir(pluginRoot)).toHaveLength(1);
|
||||
|
||||
// Work that exists only inside the checkout: an update must not take it away.
|
||||
// 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 });
|
||||
@@ -929,18 +938,28 @@ export default function contribute(server: PluginServerContext) {
|
||||
await readFile(path.join(pluginRoot, retired[0]!, "checkout", "marker.txt"), "utf8"),
|
||||
).toBe("uncommitted work");
|
||||
|
||||
// A second update replaces the kept copy with the one it just retired.
|
||||
// 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 reaped = (await readdir(pluginRoot)).filter((entry) => /\.old-\d+$/.test(entry));
|
||||
expect(reaped).toHaveLength(1);
|
||||
expect(await readdir(pluginRoot)).toHaveLength(2);
|
||||
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 readFile(path.join(pluginRoot, reaped[0]!, "checkout", "marker.txt"), "utf8"),
|
||||
).toBe("second");
|
||||
(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");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
import { resolveRegistryPlugin, type RegistryOptions } from "./managed-source/registry.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, readdir, realpath, rename, rm } from "node:fs/promises";
|
||||
import { mkdir, realpath, rename, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import compareVersions from "semver/functions/compare.js";
|
||||
@@ -28,7 +28,6 @@ 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-";
|
||||
const RETIRED_VERSION_PATTERN = /\.old-\d+$/;
|
||||
// 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({
|
||||
@@ -386,26 +385,16 @@ export class ManagedPluginSources {
|
||||
}
|
||||
/**
|
||||
* Keeps the outgoing installation root instead of deleting it, so a Git checkout's uncommitted
|
||||
* files and unpushed commits survive an update. The copy from the previous update is reaped
|
||||
* first, which leaves exactly one retired root beside the active one.
|
||||
* 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);
|
||||
const pluginRoot = path.dirname(location.versionRoot);
|
||||
await assertRealContainment(this.root, pluginRoot);
|
||||
await this.reapRetiredVersions(pluginRoot);
|
||||
const retired = `${location.versionRoot}${RETIRED_VERSION_SUFFIX}${Date.now()}`;
|
||||
await rename(location.versionRoot, retired);
|
||||
return retired;
|
||||
}
|
||||
private async reapRetiredVersions(pluginRoot: string): Promise<void> {
|
||||
for (const entry of await readdir(pluginRoot)) {
|
||||
if (!RETIRED_VERSION_PATTERN.test(entry)) continue;
|
||||
const retired = path.join(pluginRoot, entry);
|
||||
await assertRealContainment(this.root, retired);
|
||||
await rm(retired, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
async remove(pluginId: string): Promise<void> {
|
||||
if (!this.records[pluginId]) return;
|
||||
const pluginRoot = path.join(this.root, PluginIdSchema.parse(pluginId));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,允许同一个目录或仓库被安装多次。
|
||||
|
||||
Reference in New Issue
Block a user