xz-pi-worktree 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lajarre
4
+ Copyright (c) 2026 xz-pi-vim contributors
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # xz-pi-worktree
2
+
3
+ 一个职责单一的 Pi 扩展:管理本地 Git Worktree 的创建、检查、Patch 捕获、应用和清理。
4
+
5
+ 它不启动或调度 Agent,不依赖 `xz-pi-subagents`,也不会自动响应任何 Agent 生命周期。
6
+
7
+ ## 安装
8
+
9
+ 要求 Node.js 22+、Pi 0.84.4+:
10
+
11
+ ```bash
12
+ pi install ./xz-pi-worktree
13
+ ```
14
+
15
+ 安装后执行 `/reload` 或重启 Pi。
16
+
17
+ 扩展以当前用户权限执行 Git 命令,不是安全沙箱。修改操作仅允许在 Pi 已信任的项目中运行。
18
+
19
+ ## 使用
20
+
21
+ 直接告诉 main:
22
+
23
+ > 创建一个名为 feature-api 的 Git Worktree。
24
+
25
+ 扩展只注册一个模型工具 `xz_worktree`,支持以下 action:
26
+
27
+ | action | 必需参数 | 行为 |
28
+ | --- | --- | --- |
29
+ | `create` | `name` | 从当前 HEAD 创建受管 Worktree |
30
+ | `status` | `id` | 查看状态、路径以及是否有修改 |
31
+ | `list` | 无 | 列出当前仓库的受管 Worktree |
32
+ | `capture` | `id` | 捕获 tracked、staged、删除、未跟踪及二进制修改 |
33
+ | `apply` | `id` | 检查并应用 Patch 到主 checkout,成功后清理 |
34
+ | `remove` | `id` | 删除无修改的 Worktree;`force: true` 可丢弃修改 |
35
+
36
+ `cwd` 可用于 `create/list`,省略时使用 Pi 当前目录。
37
+
38
+ ## 生命周期
39
+
40
+ 典型流程:
41
+
42
+ 1. `create` 返回稳定的 `id`、`worktreePath` 和 `executionCwd`。
43
+ 2. 用户或外部程序在 `executionCwd` 中工作。
44
+ 3. `capture` 生成二进制 Patch 并返回 `changedFiles`。
45
+ 4. `apply` 再次捕获最新修改,要求主 checkout 的 HEAD 未变化且工作区干净。
46
+ 5. Patch 通过 `git apply --check` 后应用;成功时删除 Worktree 和临时分支。
47
+ 6. 冲突时不修改主 checkout,并保留 Worktree、Patch 和 manifest。
48
+
49
+ 该流程不会 commit、merge 或 rebase。
50
+
51
+ ## 文件位置
52
+
53
+ Worktree 默认创建在仓库同级目录:
54
+
55
+ ```text
56
+ <repo-parent>/.xz-pi-worktrees/<repo>-<name>-<id前8位>
57
+ ```
58
+
59
+ 持久状态和 Patch 位于 Pi agent 目录:
60
+
61
+ ```text
62
+ <agent-dir>/xz-pi-worktree/runs/<id>/
63
+ ├── manifest.json
64
+ └── changes.patch
65
+ ```
66
+
67
+ 删除 Worktree 不删除 manifest 和 Patch,便于审计和恢复。
68
+
69
+ ## 安全边界
70
+
71
+ - `create` 要求主仓库干净。
72
+ - `apply` 要求主仓库仍位于创建时的 HEAD,并且工作区干净。
73
+ - `remove` 默认拒绝删除有修改的 Worktree。
74
+ - 只有显式 `force: true` 才丢弃 Worktree 修改。
75
+ - Git 凭据提示被关闭,命令不会等待交互输入。
76
+ - 同一扩展实例中的 Worktree 操作串行执行,避免并行 Git 元数据修改。
77
+ - 状态目录不是长期备份;重要 Patch 应另行保存。
78
+
79
+ ## 与 xz-pi-subagents 的关系
80
+
81
+ 两个包完全独立:
82
+
83
+ - 不互相依赖或调用
84
+ - 不共享类型或运行时状态
85
+ - 可以单独安装、更新和卸载
86
+ - `xz-pi-subagents` 始终在当前项目目录运行,不会自动进入这里创建的 Worktree
87
+
88
+ ## 开发
89
+
90
+ 从仓库根目录运行:
91
+
92
+ ```bash
93
+ npm run check -w xz-pi-worktree
94
+ npm pack --dry-run -w xz-pi-worktree
95
+ ```
package/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { resolve } from "node:path";
2
+ import { StringEnum } from "@earendil-works/pi-ai";
3
+ import { getAgentDir, truncateHead, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import { WorktreeService } from "./src/service.js";
7
+
8
+ const ACTIONS = ["create", "status", "list", "capture", "apply", "remove"] as const;
9
+ type Action = typeof ACTIONS[number];
10
+ interface ToolInput { action: Action; id?: string; name?: string; cwd?: string; force?: boolean }
11
+
12
+ function serialQueue() {
13
+ let tail: Promise<void> = Promise.resolve();
14
+ return <T>(work: () => Promise<T>): Promise<T> => {
15
+ const result = tail.then(work, work);
16
+ tail = result.then(() => undefined, () => undefined);
17
+ return result;
18
+ };
19
+ }
20
+
21
+ function pathFrom(cwd: string, value?: string): string {
22
+ const normalized = value?.startsWith("@") ? value.slice(1) : value;
23
+ return resolve(cwd, normalized || ".");
24
+ }
25
+
26
+ export default function xzPiWorktree(pi: ExtensionAPI): void {
27
+ const service = new WorktreeService({ stateRoot: resolve(getAgentDir(), "xz-pi-worktree", "runs") });
28
+ const serial = serialQueue();
29
+
30
+ pi.registerTool({
31
+ name: "xz_worktree",
32
+ label: "Git worktree",
33
+ description: "Manage standalone Git worktrees. Actions: create a managed sibling worktree, inspect status, list manifests, capture a binary patch, apply a captured worktree to a clean unchanged main checkout, or remove it. State and patches persist under the Pi agent directory. Mutating actions require a trusted project.",
34
+ promptSnippet: "Create, inspect, capture, apply, or remove standalone Git worktrees",
35
+ promptGuidelines: [
36
+ "Use xz_worktree only for explicit Git worktree lifecycle requests.",
37
+ "Before xz_worktree apply or remove, preserve the returned id. Never use force removal unless the user accepts discarding remaining worktree changes.",
38
+ ],
39
+ parameters: Type.Object({
40
+ action: StringEnum(ACTIONS, { description: "Worktree lifecycle action" }),
41
+ id: Type.Optional(Type.String({ description: "Managed worktree id; required except for create/list" })),
42
+ name: Type.Optional(Type.String({ minLength: 1, maxLength: 40, pattern: "^[a-zA-Z0-9_-]+$", description: "Label for create" })),
43
+ cwd: Type.Optional(Type.String({ description: "Repository path for create/list; defaults to current cwd" })),
44
+ force: Type.Optional(Type.Boolean({ description: "Allow remove to discard worktree changes" })),
45
+ }),
46
+ async execute(_toolCallId, input: ToolInput, signal, _onUpdate, ctx) {
47
+ signal?.throwIfAborted();
48
+ const mutating = input.action !== "status" && input.action !== "list";
49
+ if (mutating && !ctx.isProjectTrusted()) throw new Error("xz_worktree mutation requires a trusted project");
50
+ const result = await serial(async () => {
51
+ signal?.throwIfAborted();
52
+ if (input.action === "create") {
53
+ if (!input.name) throw new Error("create requires name");
54
+ return service.create({ cwd: pathFrom(ctx.cwd, input.cwd), name: input.name }, signal);
55
+ }
56
+ if (input.action === "list") return service.list(pathFrom(ctx.cwd, input.cwd), signal);
57
+ if (!input.id) throw new Error(`${input.action} requires id`);
58
+ if (input.action === "status") return service.status(input.id, signal);
59
+ if (input.action === "capture") return service.capture(input.id, signal);
60
+ if (input.action === "apply") return service.apply(input.id, signal);
61
+ return service.remove(input.id, input.force ?? false, signal);
62
+ });
63
+ const json = JSON.stringify(result, null, 2);
64
+ const bounded = truncateHead(json, { maxBytes: 50_000, maxLines: 2000 });
65
+ return {
66
+ content: [{ type: "text", text: bounded.content + (bounded.truncated ? "\n[Result truncated]" : "") }],
67
+ details: { action: input.action, result },
68
+ };
69
+ },
70
+ renderCall(args, theme) {
71
+ const target = args.name ?? args.id ?? args.cwd ?? "";
72
+ return new Text(theme.fg("toolTitle", `Git worktree · ${args.action}${target ? ` · ${target}` : ""}`), 0, 0);
73
+ },
74
+ renderResult(result, { expanded, isPartial }, theme) {
75
+ if (isPartial) return new Text(theme.fg("muted", "Git worktree operation running…"), 0, 0);
76
+ const details = result.details as { action?: string; result?: unknown } | undefined;
77
+ const summary = details?.action ? `${details.action} completed` : "completed";
78
+ const body = expanded ? `\n${result.content.filter(item => item.type === "text").map(item => item.text).join("\n")}` : "";
79
+ return new Text(theme.fg("success", `✓ ${summary}`) + body, 0, 0);
80
+ },
81
+ });
82
+ }
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "xz-pi-worktree",
3
+ "version": "0.1.0",
4
+ "description": "Standalone Git worktree lifecycle management for Pi",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "git",
10
+ "worktree"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Xuzan9396/xz-pi.git",
16
+ "directory": "xz-pi-worktree"
17
+ },
18
+ "homepage": "https://github.com/Xuzan9396/xz-pi/tree/main/xz-pi-worktree",
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "provenance": true
22
+ },
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "files": [
27
+ "index.ts",
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ]
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "node --import tsx/esm --test 'test/**/*.test.ts'",
40
+ "check": "npm run typecheck && npm test"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-ai": ">=0.84.4",
44
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
45
+ "@earendil-works/pi-tui": ">=0.84.4",
46
+ "typebox": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@earendil-works/pi-ai": {
50
+ "optional": true
51
+ },
52
+ "@earendil-works/pi-coding-agent": {
53
+ "optional": true
54
+ },
55
+ "@earendil-works/pi-tui": {
56
+ "optional": true
57
+ },
58
+ "typebox": {
59
+ "optional": true
60
+ }
61
+ },
62
+ "devDependencies": {
63
+ "@earendil-works/pi-ai": "0.84.4",
64
+ "@earendil-works/pi-coding-agent": "0.84.4",
65
+ "@earendil-works/pi-tui": "0.84.4",
66
+ "@types/node": "^22.0.0",
67
+ "tsx": "^4.20.0",
68
+ "typebox": "1.3.7",
69
+ "typescript": "^5.9.0"
70
+ }
71
+ }
package/src/git.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { execFile } from "node:child_process";
2
+
3
+ const MAX_GIT_OUTPUT = 64 * 1024 * 1024;
4
+
5
+ export function git(cwd: string, args: string[], signal?: AbortSignal): Promise<{ stdout: string; stderr: string }> {
6
+ return new Promise((resolve, reject) => {
7
+ execFile("git", ["-C", cwd, ...args], {
8
+ encoding: "utf8",
9
+ maxBuffer: MAX_GIT_OUTPUT,
10
+ windowsHide: true,
11
+ signal,
12
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
13
+ }, (error, stdout, stderr) => {
14
+ if (error) {
15
+ const detail = String(stderr).trim();
16
+ reject(new Error(`${error.message}${detail ? `\n${detail}` : ""}`));
17
+ } else resolve({ stdout: String(stdout), stderr: String(stderr) });
18
+ });
19
+ });
20
+ }
package/src/service.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { git } from "./git.js";
6
+ import { WorktreeStore } from "./store.js";
7
+ import type { CreateOptions, WorktreeManifest, WorktreeStoreOptions } from "./types.js";
8
+
9
+ const NAME_PATTERN = /^[a-zA-Z0-9_-]{1,40}$/;
10
+
11
+ export class WorktreeService {
12
+ readonly store: WorktreeStore;
13
+ private readonly worktreeParentName: string;
14
+
15
+ constructor(options: WorktreeStoreOptions) {
16
+ this.store = new WorktreeStore(options.stateRoot);
17
+ this.worktreeParentName = options.worktreeParentName ?? ".xz-pi-worktrees";
18
+ }
19
+
20
+ async create(options: CreateOptions, signal?: AbortSignal): Promise<WorktreeManifest> {
21
+ if (!NAME_PATTERN.test(options.name)) throw new Error("Worktree name must be 1–40 letters, digits, underscores, or hyphens");
22
+ const [{ stdout: rootOut }, { stdout: prefixOut }] = await Promise.all([
23
+ git(options.cwd, ["rev-parse", "--show-toplevel"], signal),
24
+ git(options.cwd, ["rev-parse", "--show-prefix"], signal),
25
+ ]);
26
+ const repoRoot = rootOut.trim();
27
+ const { stdout: status } = await git(repoRoot, ["status", "--porcelain", "--untracked-files=all"], signal);
28
+ if (status.trim()) throw new Error("Creating a managed worktree requires a clean Git checkout. Commit or stash existing changes first.");
29
+ const { stdout: baseOut } = await git(repoRoot, ["rev-parse", "HEAD"], signal);
30
+ const id = randomUUID();
31
+ const suffix = `${options.name}-${id.slice(0, 8)}`;
32
+ const parent = join(dirname(repoRoot), this.worktreeParentName);
33
+ const worktreePath = join(parent, `${basename(repoRoot)}-${suffix}`);
34
+ const runDir = this.store.runDir(id);
35
+ const now = new Date().toISOString();
36
+ const manifest: WorktreeManifest = {
37
+ version: 1,
38
+ id,
39
+ name: options.name,
40
+ repoRoot,
41
+ sourcePrefix: prefixOut.trim(),
42
+ worktreePath,
43
+ executionCwd: join(worktreePath, prefixOut.trim()),
44
+ branch: `xz-pi-worktree/${suffix}`,
45
+ baseCommit: baseOut.trim(),
46
+ patchPath: join(runDir, "changes.patch"),
47
+ changedFiles: [],
48
+ status: "created",
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ };
52
+ await Promise.all([
53
+ mkdir(parent, { recursive: true, mode: 0o700 }),
54
+ mkdir(runDir, { recursive: true, mode: 0o700 }),
55
+ ]);
56
+ try {
57
+ await git(repoRoot, ["worktree", "add", "-b", manifest.branch, worktreePath, manifest.baseCommit], signal);
58
+ await this.store.save(manifest);
59
+ return manifest;
60
+ } catch (error) {
61
+ if (existsSync(worktreePath)) await git(repoRoot, ["worktree", "remove", "--force", worktreePath]).catch(() => {});
62
+ await git(repoRoot, ["branch", "-D", manifest.branch]).catch(() => {});
63
+ await rm(runDir, { recursive: true, force: true }).catch(() => {});
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ async status(id: string, signal?: AbortSignal): Promise<WorktreeManifest & { worktreeExists: boolean; dirty: boolean }> {
69
+ const manifest = await this.store.load(id);
70
+ const worktreeExists = existsSync(manifest.worktreePath);
71
+ let dirty = false;
72
+ if (worktreeExists) {
73
+ const result = await git(manifest.worktreePath, ["status", "--porcelain", "--untracked-files=all"], signal);
74
+ dirty = Boolean(result.stdout.trim());
75
+ }
76
+ return { ...manifest, worktreeExists, dirty };
77
+ }
78
+
79
+ async list(cwd?: string, signal?: AbortSignal): Promise<WorktreeManifest[]> {
80
+ const manifests = await this.store.list();
81
+ if (!cwd) return manifests;
82
+ const { stdout } = await git(cwd, ["rev-parse", "--show-toplevel"], signal);
83
+ const root = stdout.trim();
84
+ return manifests.filter(manifest => manifest.repoRoot === root);
85
+ }
86
+
87
+ async capture(id: string, signal?: AbortSignal): Promise<WorktreeManifest> {
88
+ const manifest = await this.store.load(id);
89
+ this.requireLive(manifest);
90
+ await git(manifest.worktreePath, ["add", "-N", "--all"], signal);
91
+ const [{ stdout: patch }, { stdout: names }] = await Promise.all([
92
+ git(manifest.worktreePath, ["diff", "--binary", "--no-ext-diff", manifest.baseCommit, "--"], signal),
93
+ git(manifest.worktreePath, ["diff", "--name-only", "-z", manifest.baseCommit, "--"], signal),
94
+ ]);
95
+ manifest.changedFiles = names.split("\0").filter(Boolean);
96
+ manifest.status = manifest.changedFiles.length ? "captured" : "no_changes";
97
+ manifest.error = undefined;
98
+ await writeFile(manifest.patchPath, patch, { mode: 0o600 });
99
+ await this.store.save(manifest);
100
+ return manifest;
101
+ }
102
+
103
+ async apply(id: string, signal?: AbortSignal): Promise<WorktreeManifest> {
104
+ const manifest = await this.capture(id, signal);
105
+ if (!manifest.changedFiles.length) {
106
+ manifest.status = "no_changes";
107
+ await this.cleanup(manifest, signal);
108
+ await this.store.save(manifest);
109
+ return manifest;
110
+ }
111
+ try {
112
+ const [{ stdout: headOut }, { stdout: mainStatus }] = await Promise.all([
113
+ git(manifest.repoRoot, ["rev-parse", "HEAD"], signal),
114
+ git(manifest.repoRoot, ["status", "--porcelain", "--untracked-files=all"], signal),
115
+ ]);
116
+ if (headOut.trim() !== manifest.baseCommit) throw new Error("Main HEAD changed after the managed worktree was created");
117
+ if (mainStatus.trim()) throw new Error("Applying a managed worktree requires a clean main checkout");
118
+ const patch = await readFile(manifest.patchPath);
119
+ if (!patch.length) throw new Error("Captured patch is empty despite changed files");
120
+ await git(manifest.repoRoot, ["apply", "--check", manifest.patchPath], signal);
121
+ await git(manifest.repoRoot, ["apply", manifest.patchPath], signal);
122
+ } catch (error) {
123
+ manifest.status = "conflict";
124
+ manifest.error = String(error);
125
+ await this.store.save(manifest);
126
+ throw new Error(`Worktree patch was not applied; worktree and patch preserved. ${String(error)}`);
127
+ }
128
+ manifest.status = "applied";
129
+ manifest.error = undefined;
130
+ try {
131
+ await this.store.save(manifest);
132
+ await this.cleanup(manifest, signal);
133
+ await this.store.save(manifest);
134
+ return manifest;
135
+ } catch (error) {
136
+ manifest.error = `Patch applied, but finalization failed: ${String(error)}`;
137
+ await this.store.save(manifest).catch(() => {});
138
+ throw new Error(manifest.error);
139
+ }
140
+ }
141
+
142
+ async remove(id: string, force = false, signal?: AbortSignal): Promise<WorktreeManifest> {
143
+ const manifest = await this.store.load(id);
144
+ if (existsSync(manifest.worktreePath) && !force) {
145
+ const { stdout } = await git(manifest.worktreePath, ["status", "--porcelain", "--untracked-files=all"], signal);
146
+ if (stdout.trim()) throw new Error("Worktree has changes. Capture/apply them first or set force: true.");
147
+ }
148
+ await this.cleanup(manifest, signal, force);
149
+ if (manifest.cleanupError) throw new Error(`Worktree cleanup incomplete: ${manifest.cleanupError}`);
150
+ manifest.status = "removed";
151
+ manifest.error = undefined;
152
+ await this.store.save(manifest);
153
+ return manifest;
154
+ }
155
+
156
+ private requireLive(manifest: WorktreeManifest): void {
157
+ if (!existsSync(manifest.worktreePath)) throw new Error(`Managed worktree no longer exists: ${manifest.worktreePath}`);
158
+ if (manifest.status === "applied" || manifest.status === "removed") throw new Error(`Worktree is already ${manifest.status}`);
159
+ }
160
+
161
+ private async cleanup(manifest: WorktreeManifest, signal?: AbortSignal, force = true): Promise<void> {
162
+ const errors: string[] = [];
163
+ if (existsSync(manifest.worktreePath)) {
164
+ const args = ["worktree", "remove", ...(force ? ["--force"] : []), manifest.worktreePath];
165
+ try { await git(manifest.repoRoot, args, signal); }
166
+ catch (error) { errors.push(`worktree remove: ${String(error)}`); }
167
+ }
168
+ try { await git(manifest.repoRoot, ["branch", "-D", manifest.branch], signal); }
169
+ catch (error) {
170
+ const branches = await git(manifest.repoRoot, ["branch", "--list", manifest.branch], signal).catch(() => ({ stdout: manifest.branch, stderr: "" }));
171
+ if (branches.stdout.trim()) errors.push(`branch delete: ${String(error)}`);
172
+ }
173
+ try { await rm(dirname(manifest.worktreePath), { recursive: false }); } catch { /* Shared parent remains while non-empty. */ }
174
+ manifest.cleanupError = errors.length ? errors.join("\n") : undefined;
175
+ }
176
+ }
package/src/store.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import type { WorktreeManifest } from "./types.js";
4
+
5
+ const ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
6
+
7
+ export class WorktreeStore {
8
+ constructor(readonly root: string) {}
9
+
10
+ runDir(id: string): string {
11
+ if (!ID_PATTERN.test(id)) throw new Error("Invalid worktree id");
12
+ return join(this.root, id);
13
+ }
14
+
15
+ manifestPath(id: string): string {
16
+ return join(this.runDir(id), "manifest.json");
17
+ }
18
+
19
+ async save(manifest: WorktreeManifest): Promise<void> {
20
+ manifest.updatedAt = new Date().toISOString();
21
+ const dir = this.runDir(manifest.id);
22
+ await mkdir(dir, { recursive: true, mode: 0o700 });
23
+ await writeFile(this.manifestPath(manifest.id), JSON.stringify(manifest, null, 2), { mode: 0o600 });
24
+ }
25
+
26
+ async load(id: string): Promise<WorktreeManifest> {
27
+ const value = JSON.parse(await readFile(this.manifestPath(id), "utf8")) as WorktreeManifest;
28
+ if (value.version !== 1 || value.id !== id) throw new Error(`Invalid manifest for worktree ${id}`);
29
+ return value;
30
+ }
31
+
32
+ async list(): Promise<WorktreeManifest[]> {
33
+ let entries;
34
+ try { entries = await readdir(this.root, { withFileTypes: true }); }
35
+ catch (error) {
36
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
37
+ throw error;
38
+ }
39
+ const manifests: WorktreeManifest[] = [];
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) continue;
42
+ try { manifests.push(await this.load(entry.name)); } catch { /* Ignore corrupt entries in list; direct status reports them. */ }
43
+ }
44
+ return manifests.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
45
+ }
46
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type WorktreeStatus = "created" | "captured" | "applied" | "no_changes" | "conflict" | "removed";
2
+
3
+ export interface WorktreeManifest {
4
+ version: 1;
5
+ id: string;
6
+ name: string;
7
+ repoRoot: string;
8
+ sourcePrefix: string;
9
+ worktreePath: string;
10
+ executionCwd: string;
11
+ branch: string;
12
+ baseCommit: string;
13
+ patchPath: string;
14
+ changedFiles: string[];
15
+ status: WorktreeStatus;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ cleanupError?: string;
19
+ error?: string;
20
+ }
21
+
22
+ export interface CreateOptions {
23
+ cwd: string;
24
+ name: string;
25
+ }
26
+
27
+ export interface WorktreeStoreOptions {
28
+ stateRoot: string;
29
+ worktreeParentName?: string;
30
+ }