mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 11:42:52 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56f4af0436 | ||
|
|
9072bce0c3 |
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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