@signalridge/pi-worktree 0.49.3

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.
@@ -0,0 +1,285 @@
1
+ import { lstat, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, posix, win32 } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export const SETTINGS_FILE = "pi-worktree.json";
7
+
8
+ export type WorktreeSettingsSource = "default" | "user";
9
+
10
+ export interface LoadedWorktreeSettings {
11
+ kind: "missing" | "loaded" | "invalid";
12
+ path: string;
13
+ effectiveRoot: string;
14
+ source: WorktreeSettingsSource;
15
+ configuredRoot?: string;
16
+ document?: Record<string, unknown>;
17
+ warning?: string;
18
+ }
19
+
20
+ export interface WorktreeSettingsState {
21
+ effectiveRoot: string;
22
+ source: WorktreeSettingsSource;
23
+ configuredRoot?: string;
24
+ warning?: string;
25
+ canSave: boolean;
26
+ }
27
+
28
+ export interface SettingsFileOperations {
29
+ write(path: string, data: string): Promise<void>;
30
+ rename(source: string, destination: string): Promise<void>;
31
+ }
32
+
33
+ export interface WorktreeSettingsRuntime {
34
+ get(): Readonly<WorktreeSettingsState>;
35
+ getPath(): string;
36
+ reload(): Promise<Readonly<WorktreeSettingsState>>;
37
+ save(configuredRoot: string | undefined): Promise<Readonly<WorktreeSettingsState>>;
38
+ flush?(): Promise<void>;
39
+ }
40
+
41
+ interface RuntimeOptions {
42
+ path?: string | (() => string);
43
+ home?: string;
44
+ platform?: NodeJS.Platform;
45
+ operations?: Partial<SettingsFileOperations>;
46
+ }
47
+
48
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
49
+ write: (path, data) => writeFile(path, data, { encoding: "utf8", flag: "wx", mode: 0o600 }).then(() => undefined),
50
+ rename,
51
+ };
52
+
53
+ export function settingsFilePath(): string {
54
+ return join(getAgentDir(), SETTINGS_FILE);
55
+ }
56
+
57
+ export function defaultWorktreeRoot(home = homedir(), platform: NodeJS.Platform = process.platform): string {
58
+ return platformPath(platform).join(home, ".worktrees");
59
+ }
60
+
61
+ export function resolveWorktreeRoot(
62
+ value: string,
63
+ home = homedir(),
64
+ platform: NodeJS.Platform = process.platform,
65
+ ): string {
66
+ if (!value || value.includes("\0")) {
67
+ throw new Error("worktreeRoot must be a non-empty path without NUL characters.");
68
+ }
69
+ if (hasShellVariableSyntax(value)) {
70
+ throw new Error("worktreeRoot must not contain shell variable syntax.");
71
+ }
72
+
73
+ const path = platformPath(platform);
74
+ let candidate = value;
75
+ if (value === "~") {
76
+ candidate = home;
77
+ } else if (value.startsWith("~/") || (platform === "win32" && value.startsWith("~\\"))) {
78
+ candidate = path.resolve(home, value.slice(2));
79
+ } else if (value.startsWith("~")) {
80
+ throw new Error("worktreeRoot supports only ~ itself or a path beginning with ~/.");
81
+ }
82
+ if (!path.isAbsolute(candidate)) {
83
+ throw new Error("worktreeRoot must be an absolute path or begin with ~/.");
84
+ }
85
+
86
+ try {
87
+ const normalized = path.normalize(candidate);
88
+ if (!normalized || !path.isAbsolute(normalized)) {
89
+ throw new Error("normalization did not produce an absolute path");
90
+ }
91
+ return normalized;
92
+ } catch (error) {
93
+ throw new Error(`worktreeRoot could not be normalized: ${formatError(error)}`);
94
+ }
95
+ }
96
+
97
+ export async function loadWorktreeSettings(
98
+ path = settingsFilePath(),
99
+ home = homedir(),
100
+ platform: NodeJS.Platform = process.platform,
101
+ ): Promise<LoadedWorktreeSettings> {
102
+ const fallback = defaultWorktreeRoot(home, platform);
103
+ let text: string;
104
+ try {
105
+ const stats = await lstat(path);
106
+ if (stats.isSymbolicLink()) return invalid(path, fallback, "symbolic links are not accepted");
107
+ if (!stats.isFile()) return invalid(path, fallback, "settings path is not a regular file");
108
+ text = await readFile(path, "utf8");
109
+ } catch (error) {
110
+ if (isNodeError(error) && error.code === "ENOENT") {
111
+ return {
112
+ kind: "missing",
113
+ path,
114
+ effectiveRoot: fallback,
115
+ source: "default",
116
+ document: {},
117
+ };
118
+ }
119
+ return invalid(path, fallback, formatError(error));
120
+ }
121
+
122
+ try {
123
+ const document = JSON.parse(text) as unknown;
124
+ if (!isRecord(document)) return invalid(path, fallback, "the top level must be a JSON object");
125
+ if (!Object.hasOwn(document, "worktreeRoot")) {
126
+ return {
127
+ kind: "loaded",
128
+ path,
129
+ effectiveRoot: fallback,
130
+ source: "default",
131
+ document,
132
+ };
133
+ }
134
+ if (typeof document.worktreeRoot !== "string") {
135
+ return invalid(path, fallback, "worktreeRoot must be a string");
136
+ }
137
+ const effectiveRoot = resolveWorktreeRoot(document.worktreeRoot, home, platform);
138
+ return {
139
+ kind: "loaded",
140
+ path,
141
+ effectiveRoot,
142
+ source: "user",
143
+ configuredRoot: document.worktreeRoot,
144
+ document,
145
+ };
146
+ } catch (error) {
147
+ return invalid(path, fallback, formatError(error));
148
+ }
149
+ }
150
+
151
+ export async function saveWorktreeSettings(
152
+ document: Record<string, unknown>,
153
+ configuredRoot: string | undefined,
154
+ path = settingsFilePath(),
155
+ operations: Partial<SettingsFileOperations> = {},
156
+ ): Promise<Record<string, unknown>> {
157
+ const nextDocument = { ...document };
158
+ if (configuredRoot === undefined) delete nextDocument.worktreeRoot;
159
+ else nextDocument.worktreeRoot = configuredRoot;
160
+
161
+ await mkdir(dirname(path), { recursive: true });
162
+ const temporaryPath = temporaryFilePath(path);
163
+ try {
164
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
165
+ temporaryPath,
166
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
167
+ );
168
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(temporaryPath, path);
169
+ return nextDocument;
170
+ } catch (error) {
171
+ await unlink(temporaryPath).catch(() => undefined);
172
+ throw error;
173
+ }
174
+ }
175
+
176
+ export function createWorktreeSettingsRuntime(options: RuntimeOptions = {}): WorktreeSettingsRuntime {
177
+ const home = options.home ?? homedir();
178
+ const platform = options.platform ?? process.platform;
179
+ let resolvedPath: string | undefined;
180
+ const getPath = () => {
181
+ resolvedPath ??= typeof options.path === "function" ? options.path() : (options.path ?? settingsFilePath());
182
+ return resolvedPath;
183
+ };
184
+ let operationQueue = Promise.resolve();
185
+ const enqueue = <T>(operation: () => Promise<T>): Promise<T> => {
186
+ const result = operationQueue.then(operation, operation);
187
+ operationQueue = result.then(
188
+ () => undefined,
189
+ () => undefined,
190
+ );
191
+ return result;
192
+ };
193
+ let state: WorktreeSettingsState = {
194
+ effectiveRoot: defaultWorktreeRoot(home, platform),
195
+ source: "default",
196
+ canSave: true,
197
+ };
198
+
199
+ return {
200
+ get: () => Object.freeze({ ...state }),
201
+ getPath,
202
+ async flush() {
203
+ await operationQueue;
204
+ },
205
+ reload() {
206
+ return enqueue(async () => {
207
+ const loaded = await loadWorktreeSettings(getPath(), home, platform);
208
+ if (loaded.kind === "invalid") {
209
+ state = { ...state, warning: loaded.warning, canSave: false };
210
+ return Object.freeze({ ...state });
211
+ }
212
+ state = stateFromLoaded(loaded);
213
+ return Object.freeze({ ...state });
214
+ });
215
+ },
216
+ save(configuredRoot) {
217
+ return enqueue(async () => {
218
+ if (!state.canSave) {
219
+ throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
220
+ }
221
+ const effectiveRoot =
222
+ configuredRoot === undefined
223
+ ? defaultWorktreeRoot(home, platform)
224
+ : resolveWorktreeRoot(configuredRoot, home, platform);
225
+ const latest = await loadWorktreeSettings(getPath(), home, platform);
226
+ if (latest.kind === "invalid") {
227
+ state = { ...state, warning: latest.warning, canSave: false };
228
+ throw new Error(`Fix the pi-worktree settings file at ${getPath()} before changing it.`);
229
+ }
230
+ await saveWorktreeSettings(latest.document ?? {}, configuredRoot, getPath(), options.operations);
231
+ state = {
232
+ effectiveRoot,
233
+ source: configuredRoot === undefined ? "default" : "user",
234
+ ...(configuredRoot === undefined ? {} : { configuredRoot }),
235
+ canSave: true,
236
+ };
237
+ return Object.freeze({ ...state });
238
+ });
239
+ },
240
+ };
241
+ }
242
+
243
+ function stateFromLoaded(loaded: LoadedWorktreeSettings): WorktreeSettingsState {
244
+ return {
245
+ effectiveRoot: loaded.effectiveRoot,
246
+ source: loaded.source,
247
+ ...(loaded.configuredRoot === undefined ? {} : { configuredRoot: loaded.configuredRoot }),
248
+ ...(loaded.warning === undefined ? {} : { warning: loaded.warning }),
249
+ canSave: true,
250
+ };
251
+ }
252
+
253
+ function invalid(path: string, fallback: string, reason: string): LoadedWorktreeSettings {
254
+ return {
255
+ kind: "invalid",
256
+ path,
257
+ effectiveRoot: fallback,
258
+ source: "default",
259
+ warning: `${SETTINGS_FILE} ignored (${path}: ${reason}); using the safe default or last valid root without overwriting the file.`,
260
+ };
261
+ }
262
+
263
+ function platformPath(platform: NodeJS.Platform): typeof posix | typeof win32 {
264
+ return platform === "win32" ? win32 : posix;
265
+ }
266
+
267
+ function hasShellVariableSyntax(value: string): boolean {
268
+ return /\$|%[^%]+%/u.test(value);
269
+ }
270
+
271
+ function temporaryFilePath(path: string): string {
272
+ return `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
273
+ }
274
+
275
+ function isRecord(value: unknown): value is Record<string, unknown> {
276
+ return typeof value === "object" && value !== null && !Array.isArray(value);
277
+ }
278
+
279
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
280
+ return error instanceof Error && "code" in error;
281
+ }
282
+
283
+ function formatError(error: unknown): string {
284
+ return error instanceof Error ? error.message : String(error);
285
+ }
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerWorktreeCommand } from "./command.js";
3
+ import { createWorktreeSettingsRuntime, settingsFilePath, type WorktreeSettingsRuntime } from "./settings.js";
4
+
5
+ interface WorktreeExtensionOptions {
6
+ settings?: WorktreeSettingsRuntime;
7
+ }
8
+
9
+ export default function worktreeExtension(pi: ExtensionAPI, options: WorktreeExtensionOptions = {}): void {
10
+ const settings = options.settings ?? createWorktreeSettingsRuntime({ path: settingsFilePath });
11
+ let sessionGeneration = 0;
12
+ let menuController = new AbortController();
13
+ registerWorktreeCommand(pi, settings, () => {
14
+ const generation = sessionGeneration;
15
+ return {
16
+ signal: menuController.signal,
17
+ isCurrent: () => generation === sessionGeneration && !menuController.signal.aborted,
18
+ };
19
+ });
20
+
21
+ pi.on("session_start", async (_event, ctx) => {
22
+ const generation = ++sessionGeneration;
23
+ menuController.abort(new DOMException("Worktree session replaced", "AbortError"));
24
+ menuController = new AbortController();
25
+ const loaded = await settings.reload();
26
+ if (generation !== sessionGeneration || !loaded.warning || !ctx.hasUI) return;
27
+ ctx.ui.notify(loaded.warning, "warning");
28
+ });
29
+ pi.on("session_shutdown", async () => {
30
+ sessionGeneration += 1;
31
+ menuController.abort(new DOMException("Worktree session shut down", "AbortError"));
32
+ await settings.flush?.();
33
+ });
34
+ }