@zenobius/pi-worktrees 0.0.1
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 +21 -0
- package/README.md +37 -0
- package/dist/index.js +615 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 zenobi.us
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# pi-worktrees
|
|
2
|
+
|
|
3
|
+
Worktrees extension for Pi Coding Agent
|
|
4
|
+
|
|
5
|
+
> A Bun module created from the [bun-module](https://github.com/zenobi-us/bun-module) template
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Git worktree management commands for Pi
|
|
10
|
+
- Interactive setup for worktree settings
|
|
11
|
+
- Automatic worktree listing, creation, removal, and pruning
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Run in Pi:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/worktree init
|
|
19
|
+
/worktree create <feature-name>
|
|
20
|
+
/worktree list
|
|
21
|
+
/worktree status
|
|
22
|
+
/worktree cd <name>
|
|
23
|
+
/worktree remove <name>
|
|
24
|
+
/worktree prune
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Development
|
|
28
|
+
|
|
29
|
+
- `mise run build` - Build the module
|
|
30
|
+
- `mise run test` - Run tests
|
|
31
|
+
- `mise run lint` - Lint code
|
|
32
|
+
- `mise run lint:fix` - Fix linting issues
|
|
33
|
+
- `mise run format` - Format code with Prettier
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT License. See the [LICENSE](LICENSE) file for details.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import { execSync, spawn } from "child_process";
|
|
4
|
+
import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync, statSync } from "fs";
|
|
5
|
+
import { dirname, basename, join, resolve, relative } from "path";
|
|
6
|
+
import { homedir } from "os";
|
|
7
|
+
var HELP_TEXT = `
|
|
8
|
+
/worktree - Git worktree management
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
/worktree init Configure worktree settings interactively
|
|
12
|
+
/worktree settings [key] [val] Get/set individual settings
|
|
13
|
+
/worktree create <feature-name> Create new worktree with branch
|
|
14
|
+
/worktree list List all worktrees
|
|
15
|
+
/worktree remove <name> Remove a worktree
|
|
16
|
+
/worktree status Show current worktree info
|
|
17
|
+
/worktree cd <name> Print path to worktree
|
|
18
|
+
/worktree prune Clean up stale references
|
|
19
|
+
|
|
20
|
+
Settings:
|
|
21
|
+
/worktree settings Show all settings
|
|
22
|
+
/worktree settings parentDir Get parentDir value
|
|
23
|
+
/worktree settings parentDir ~ Set parentDir value
|
|
24
|
+
/worktree settings onCreate Get onCreate value
|
|
25
|
+
/worktree settings onCreate "" Clear onCreate value
|
|
26
|
+
|
|
27
|
+
Configuration (~/.pi/settings.json):
|
|
28
|
+
{
|
|
29
|
+
"worktree": {
|
|
30
|
+
"parentDir": "...", // Override default parent directory
|
|
31
|
+
"onCreate": "mise setup" // Command to run after creation
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Template vars: {{path}}, {{name}}, {{branch}}, {{project}}
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
/worktree init
|
|
39
|
+
/worktree settings parentDir "~/.worktrees/{{project}}"
|
|
40
|
+
/worktree create auth-feature
|
|
41
|
+
/worktree list
|
|
42
|
+
/worktree cd auth-feature
|
|
43
|
+
/worktree remove auth-feature
|
|
44
|
+
`.trim();
|
|
45
|
+
function git(args, cwd) {
|
|
46
|
+
try {
|
|
47
|
+
return execSync(`git ${args.join(" ")}`, {
|
|
48
|
+
cwd,
|
|
49
|
+
encoding: "utf-8",
|
|
50
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
51
|
+
}).trim();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`git ${args[0]} failed: ${error.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isGitRepo(cwd) {
|
|
57
|
+
try {
|
|
58
|
+
git(["rev-parse", "--git-dir"], cwd);
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function getMainWorktreePath(cwd) {
|
|
65
|
+
const gitCommonDir = git(["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd);
|
|
66
|
+
return dirname(gitCommonDir);
|
|
67
|
+
}
|
|
68
|
+
function getProjectName(cwd) {
|
|
69
|
+
return basename(getMainWorktreePath(cwd));
|
|
70
|
+
}
|
|
71
|
+
function isWorktree(cwd) {
|
|
72
|
+
try {
|
|
73
|
+
const gitDir = git(["rev-parse", "--git-dir"], cwd);
|
|
74
|
+
const gitPath = join(cwd, ".git");
|
|
75
|
+
if (existsSync(gitPath)) {
|
|
76
|
+
const stat = statSync(gitPath);
|
|
77
|
+
return stat.isFile();
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function getCurrentBranch(cwd) {
|
|
85
|
+
try {
|
|
86
|
+
return git(["branch", "--show-current"], cwd) || "HEAD (detached)";
|
|
87
|
+
} catch {
|
|
88
|
+
return "unknown";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function listWorktrees(cwd) {
|
|
92
|
+
const output = git(["worktree", "list", "--porcelain"], cwd);
|
|
93
|
+
const worktrees = [];
|
|
94
|
+
const currentPath = resolve(cwd);
|
|
95
|
+
const mainPath = getMainWorktreePath(cwd);
|
|
96
|
+
let current = {};
|
|
97
|
+
for (const line of output.split(`
|
|
98
|
+
`)) {
|
|
99
|
+
if (line.startsWith("worktree ")) {
|
|
100
|
+
current.path = line.slice(9);
|
|
101
|
+
} else if (line.startsWith("HEAD ")) {
|
|
102
|
+
current.head = line.slice(5);
|
|
103
|
+
} else if (line.startsWith("branch ")) {
|
|
104
|
+
current.branch = line.slice(7).replace("refs/heads/", "");
|
|
105
|
+
} else if (line === "detached") {
|
|
106
|
+
current.branch = "HEAD (detached)";
|
|
107
|
+
} else if (line === "") {
|
|
108
|
+
if (current.path) {
|
|
109
|
+
worktrees.push({
|
|
110
|
+
path: current.path,
|
|
111
|
+
branch: current.branch || "unknown",
|
|
112
|
+
head: current.head || "unknown",
|
|
113
|
+
isMain: current.path === mainPath,
|
|
114
|
+
isCurrent: current.path === currentPath
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
current = {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (current.path) {
|
|
121
|
+
worktrees.push({
|
|
122
|
+
path: current.path,
|
|
123
|
+
branch: current.branch || "unknown",
|
|
124
|
+
head: current.head || "unknown",
|
|
125
|
+
isMain: current.path === mainPath,
|
|
126
|
+
isCurrent: current.path === currentPath
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return worktrees;
|
|
130
|
+
}
|
|
131
|
+
function getSettingsPath() {
|
|
132
|
+
return join(homedir(), ".pi", "settings.json");
|
|
133
|
+
}
|
|
134
|
+
function loadFullSettings() {
|
|
135
|
+
const settingsPath = getSettingsPath();
|
|
136
|
+
try {
|
|
137
|
+
if (existsSync(settingsPath)) {
|
|
138
|
+
const content = readFileSync(settingsPath, "utf-8");
|
|
139
|
+
return JSON.parse(content);
|
|
140
|
+
}
|
|
141
|
+
} catch {}
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
function loadSettings() {
|
|
145
|
+
const settings = loadFullSettings();
|
|
146
|
+
return settings.worktree || {};
|
|
147
|
+
}
|
|
148
|
+
function saveSettings(worktreeSettings) {
|
|
149
|
+
const settingsPath = getSettingsPath();
|
|
150
|
+
const settingsDir = dirname(settingsPath);
|
|
151
|
+
if (!existsSync(settingsDir)) {
|
|
152
|
+
mkdirSync(settingsDir, { recursive: true });
|
|
153
|
+
}
|
|
154
|
+
const fullSettings = loadFullSettings();
|
|
155
|
+
fullSettings.worktree = worktreeSettings;
|
|
156
|
+
writeFileSync(settingsPath, JSON.stringify(fullSettings, null, 2) + `
|
|
157
|
+
`, "utf-8");
|
|
158
|
+
}
|
|
159
|
+
function expandTemplate(template, ctx) {
|
|
160
|
+
return template.replace(/\{\{path\}\}/g, ctx.path).replace(/\{\{name\}\}/g, ctx.name).replace(/\{\{branch\}\}/g, ctx.branch).replace(/\{\{project\}\}/g, ctx.project).replace(/^~/, homedir());
|
|
161
|
+
}
|
|
162
|
+
function getWorktreeParentDir(cwd, settings) {
|
|
163
|
+
const project = getProjectName(cwd);
|
|
164
|
+
const mainWorktree = getMainWorktreePath(cwd);
|
|
165
|
+
if (settings.parentDir) {
|
|
166
|
+
return expandTemplate(settings.parentDir, {
|
|
167
|
+
path: "",
|
|
168
|
+
name: "",
|
|
169
|
+
branch: "",
|
|
170
|
+
project,
|
|
171
|
+
mainWorktree
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return join(dirname(mainWorktree), `${project}.worktrees`);
|
|
175
|
+
}
|
|
176
|
+
function isPathInsideRepo(repoPath, targetPath) {
|
|
177
|
+
const relPath = relative(repoPath, targetPath);
|
|
178
|
+
return !relPath.startsWith("..") && !relPath.startsWith("/");
|
|
179
|
+
}
|
|
180
|
+
function ensureExcluded(cwd, worktreeParentDir) {
|
|
181
|
+
const mainWorktree = getMainWorktreePath(cwd);
|
|
182
|
+
if (!isPathInsideRepo(mainWorktree, worktreeParentDir)) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const excludePath = join(mainWorktree, ".git", "info", "exclude");
|
|
186
|
+
const relPath = relative(mainWorktree, worktreeParentDir);
|
|
187
|
+
const excludePattern = `/${relPath}/`;
|
|
188
|
+
try {
|
|
189
|
+
let content = "";
|
|
190
|
+
if (existsSync(excludePath)) {
|
|
191
|
+
content = readFileSync(excludePath, "utf-8");
|
|
192
|
+
}
|
|
193
|
+
if (content.includes(excludePattern) || content.includes(relPath)) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const newEntry = `
|
|
197
|
+
# Worktree directory (added by worktree extension)
|
|
198
|
+
${excludePattern}
|
|
199
|
+
`;
|
|
200
|
+
appendFileSync(excludePath, newEntry);
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
async function runOnCreateHook(ctx, settings, notify) {
|
|
204
|
+
const { onCreate } = settings;
|
|
205
|
+
if (!onCreate) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (typeof onCreate === "string") {
|
|
209
|
+
const command = expandTemplate(onCreate, ctx);
|
|
210
|
+
notify(`Running: ${command}`, "info");
|
|
211
|
+
return new Promise((resolve2, reject) => {
|
|
212
|
+
const child = spawn(command, {
|
|
213
|
+
cwd: ctx.path,
|
|
214
|
+
shell: true,
|
|
215
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
216
|
+
});
|
|
217
|
+
let stdout = "";
|
|
218
|
+
let stderr = "";
|
|
219
|
+
child.stdout?.on("data", (data) => {
|
|
220
|
+
stdout += data.toString();
|
|
221
|
+
});
|
|
222
|
+
child.stderr?.on("data", (data) => {
|
|
223
|
+
stderr += data.toString();
|
|
224
|
+
});
|
|
225
|
+
child.on("close", (code) => {
|
|
226
|
+
if (code === 0) {
|
|
227
|
+
if (stdout.trim()) {
|
|
228
|
+
notify(stdout.trim().slice(0, 200), "info");
|
|
229
|
+
}
|
|
230
|
+
resolve2();
|
|
231
|
+
} else {
|
|
232
|
+
notify(`onCreate failed (exit ${code}): ${stderr.slice(0, 200)}`, "error");
|
|
233
|
+
resolve2();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
child.on("error", (err) => {
|
|
237
|
+
notify(`onCreate error: ${err.message}`, "error");
|
|
238
|
+
resolve2();
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
} else if (typeof onCreate === "function") {
|
|
242
|
+
try {
|
|
243
|
+
await onCreate(ctx);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
notify(`onCreate error: ${err.message}`, "error");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function handleInit(_args, ctx) {
|
|
250
|
+
if (!ctx.hasUI) {
|
|
251
|
+
ctx.ui.notify("init requires interactive mode", "error");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const currentSettings = loadSettings();
|
|
255
|
+
const settingsPath = getSettingsPath();
|
|
256
|
+
ctx.ui.notify(`Worktree Extension Setup
|
|
257
|
+
\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501`, "info");
|
|
258
|
+
if (currentSettings.parentDir || currentSettings.onCreate) {
|
|
259
|
+
const current = [
|
|
260
|
+
"Current settings:",
|
|
261
|
+
currentSettings.parentDir ? ` parentDir: ${currentSettings.parentDir}` : null,
|
|
262
|
+
currentSettings.onCreate ? ` onCreate: ${currentSettings.onCreate}` : null
|
|
263
|
+
].filter(Boolean).join(`
|
|
264
|
+
`);
|
|
265
|
+
ctx.ui.notify(current, "info");
|
|
266
|
+
}
|
|
267
|
+
const PARENT_DIR_DEFAULT = "Default (../{{project}}.worktrees/)";
|
|
268
|
+
const PARENT_DIR_GLOBAL = "Global (~/.local/share/worktrees/{{project}})";
|
|
269
|
+
const PARENT_DIR_CUSTOM = "Custom path...";
|
|
270
|
+
const PARENT_DIR_KEEP = "Keep current";
|
|
271
|
+
const parentDirOptions = [
|
|
272
|
+
PARENT_DIR_DEFAULT,
|
|
273
|
+
PARENT_DIR_GLOBAL,
|
|
274
|
+
PARENT_DIR_CUSTOM,
|
|
275
|
+
currentSettings.parentDir ? PARENT_DIR_KEEP : null
|
|
276
|
+
].filter(Boolean);
|
|
277
|
+
const parentDirChoice = await ctx.ui.select("Where should worktrees be created?", parentDirOptions);
|
|
278
|
+
if (parentDirChoice === undefined) {
|
|
279
|
+
ctx.ui.notify("Setup cancelled", "info");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
let parentDir;
|
|
283
|
+
if (parentDirChoice === PARENT_DIR_DEFAULT) {
|
|
284
|
+
parentDir = undefined;
|
|
285
|
+
} else if (parentDirChoice === PARENT_DIR_GLOBAL) {
|
|
286
|
+
parentDir = "~/.local/share/worktrees/{{project}}";
|
|
287
|
+
} else if (parentDirChoice === PARENT_DIR_CUSTOM) {
|
|
288
|
+
const customPath = await ctx.ui.input("Enter custom path (supports {{project}}, {{name}}):", currentSettings.parentDir || "../{{project}}.worktrees");
|
|
289
|
+
if (customPath === undefined) {
|
|
290
|
+
ctx.ui.notify("Setup cancelled", "info");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
parentDir = customPath || undefined;
|
|
294
|
+
} else if (parentDirChoice === PARENT_DIR_KEEP) {
|
|
295
|
+
parentDir = currentSettings.parentDir;
|
|
296
|
+
}
|
|
297
|
+
const onCreate = await ctx.ui.input(`Enter command to run after creating worktree (or leave empty):
|
|
298
|
+
Supports: {{path}}, {{name}}, {{branch}}, {{project}}`, currentSettings.onCreate && typeof currentSettings.onCreate === "string" ? currentSettings.onCreate : "mise setup");
|
|
299
|
+
if (onCreate === undefined) {
|
|
300
|
+
ctx.ui.notify("Setup cancelled", "info");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const newSettings = {};
|
|
304
|
+
if (parentDir) {
|
|
305
|
+
newSettings.parentDir = parentDir;
|
|
306
|
+
}
|
|
307
|
+
if (onCreate && onCreate.trim()) {
|
|
308
|
+
newSettings.onCreate = onCreate.trim();
|
|
309
|
+
}
|
|
310
|
+
const preview = [
|
|
311
|
+
"Settings to save:",
|
|
312
|
+
"",
|
|
313
|
+
newSettings.parentDir ? ` parentDir: "${newSettings.parentDir}"` : " parentDir: (default)",
|
|
314
|
+
newSettings.onCreate ? ` onCreate: "${newSettings.onCreate}"` : " onCreate: (none)",
|
|
315
|
+
"",
|
|
316
|
+
`File: ${settingsPath}`
|
|
317
|
+
].join(`
|
|
318
|
+
`);
|
|
319
|
+
const confirmed = await ctx.ui.confirm("Save settings?", preview);
|
|
320
|
+
if (!confirmed) {
|
|
321
|
+
ctx.ui.notify("Setup cancelled", "info");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
try {
|
|
325
|
+
saveSettings(newSettings);
|
|
326
|
+
ctx.ui.notify(`\u2713 Settings saved to ${settingsPath}`, "info");
|
|
327
|
+
const finalConfig = JSON.stringify({ worktree: newSettings }, null, 2);
|
|
328
|
+
ctx.ui.notify(`Configuration:
|
|
329
|
+
${finalConfig}`, "info");
|
|
330
|
+
} catch (err) {
|
|
331
|
+
ctx.ui.notify(`Failed to save settings: ${err.message}`, "error");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
var VALID_SETTING_KEYS = ["parentDir", "onCreate"];
|
|
335
|
+
async function handleSettings(args, ctx) {
|
|
336
|
+
const currentSettings = loadSettings();
|
|
337
|
+
const settingsPath = getSettingsPath();
|
|
338
|
+
const parts = args.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
339
|
+
const key = parts[0]?.trim();
|
|
340
|
+
const value = parts.slice(1).join(" ").replace(/^"(.*)"$/, "$1");
|
|
341
|
+
if (!key) {
|
|
342
|
+
const lines = [
|
|
343
|
+
"Worktree Settings:",
|
|
344
|
+
"\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501",
|
|
345
|
+
"",
|
|
346
|
+
`parentDir: ${currentSettings.parentDir || "(default: ../<project>.worktrees/)"}`,
|
|
347
|
+
`onCreate: ${currentSettings.onCreate || "(none)"}`,
|
|
348
|
+
"",
|
|
349
|
+
`File: ${settingsPath}`
|
|
350
|
+
];
|
|
351
|
+
ctx.ui.notify(lines.join(`
|
|
352
|
+
`), "info");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (!VALID_SETTING_KEYS.includes(key)) {
|
|
356
|
+
ctx.ui.notify(`Invalid setting key: "${key}"
|
|
357
|
+
Valid keys: ${VALID_SETTING_KEYS.join(", ")}`, "error");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!value && parts.length === 1) {
|
|
361
|
+
const currentValue = currentSettings[key];
|
|
362
|
+
if (currentValue) {
|
|
363
|
+
ctx.ui.notify(`${key}: ${currentValue}`, "info");
|
|
364
|
+
} else {
|
|
365
|
+
const defaults = {
|
|
366
|
+
parentDir: "(default: ../<project>.worktrees/)",
|
|
367
|
+
onCreate: "(none)"
|
|
368
|
+
};
|
|
369
|
+
ctx.ui.notify(`${key}: ${defaults[key]}`, "info");
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const newSettings = { ...currentSettings };
|
|
374
|
+
if (value === "" || value === '""' || value === "null" || value === "clear") {
|
|
375
|
+
delete newSettings[key];
|
|
376
|
+
ctx.ui.notify(`\u2713 Cleared ${key}`, "info");
|
|
377
|
+
} else {
|
|
378
|
+
newSettings[key] = value;
|
|
379
|
+
ctx.ui.notify(`\u2713 Set ${key} = "${value}"`, "info");
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
saveSettings(newSettings);
|
|
383
|
+
} catch (err) {
|
|
384
|
+
ctx.ui.notify(`Failed to save settings: ${err.message}`, "error");
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
async function handleCreate(args, ctx) {
|
|
388
|
+
const featureName = args.trim();
|
|
389
|
+
if (!featureName) {
|
|
390
|
+
ctx.ui.notify("Usage: /worktree create <feature-name>", "error");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
394
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const settings = loadSettings();
|
|
398
|
+
const project = getProjectName(ctx.cwd);
|
|
399
|
+
const mainWorktree = getMainWorktreePath(ctx.cwd);
|
|
400
|
+
const parentDir = getWorktreeParentDir(ctx.cwd, settings);
|
|
401
|
+
const worktreePath = join(parentDir, featureName);
|
|
402
|
+
const branchName = `feature/${featureName}`;
|
|
403
|
+
const existing = listWorktrees(ctx.cwd);
|
|
404
|
+
if (existing.some((w) => w.path === worktreePath)) {
|
|
405
|
+
ctx.ui.notify(`Worktree already exists at: ${worktreePath}`, "error");
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
git(["rev-parse", "--verify", branchName], ctx.cwd);
|
|
410
|
+
ctx.ui.notify(`Branch '${branchName}' already exists. Use a different name.`, "error");
|
|
411
|
+
return;
|
|
412
|
+
} catch {}
|
|
413
|
+
ensureExcluded(ctx.cwd, parentDir);
|
|
414
|
+
ctx.ui.notify(`Creating worktree: ${featureName}`, "info");
|
|
415
|
+
try {
|
|
416
|
+
git(["worktree", "add", "-b", branchName, worktreePath], mainWorktree);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
ctx.ui.notify(`Failed to create worktree: ${err.message}`, "error");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const createdCtx = {
|
|
422
|
+
path: worktreePath,
|
|
423
|
+
name: featureName,
|
|
424
|
+
branch: branchName,
|
|
425
|
+
project,
|
|
426
|
+
mainWorktree
|
|
427
|
+
};
|
|
428
|
+
await runOnCreateHook(createdCtx, settings, ctx.ui.notify.bind(ctx.ui));
|
|
429
|
+
ctx.ui.notify(`\u2713 Worktree created!
|
|
430
|
+
Path: ${worktreePath}
|
|
431
|
+
Branch: ${branchName}`, "info");
|
|
432
|
+
}
|
|
433
|
+
async function handleList(_args, ctx) {
|
|
434
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
435
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
const worktrees = listWorktrees(ctx.cwd);
|
|
439
|
+
if (worktrees.length === 0) {
|
|
440
|
+
ctx.ui.notify("No worktrees found", "info");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const lines = worktrees.map((w) => {
|
|
444
|
+
const markers = [
|
|
445
|
+
w.isMain ? "[main]" : "",
|
|
446
|
+
w.isCurrent ? "[current]" : ""
|
|
447
|
+
].filter(Boolean).join(" ");
|
|
448
|
+
return `${w.branch}${markers ? " " + markers : ""}
|
|
449
|
+
${w.path}`;
|
|
450
|
+
});
|
|
451
|
+
ctx.ui.notify(`Worktrees:
|
|
452
|
+
|
|
453
|
+
${lines.join(`
|
|
454
|
+
|
|
455
|
+
`)}`, "info");
|
|
456
|
+
}
|
|
457
|
+
async function handleRemove(args, ctx) {
|
|
458
|
+
const worktreeName = args.trim();
|
|
459
|
+
if (!worktreeName) {
|
|
460
|
+
ctx.ui.notify("Usage: /worktree remove <name>", "error");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
464
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const worktrees = listWorktrees(ctx.cwd);
|
|
468
|
+
const settings = loadSettings();
|
|
469
|
+
const parentDir = getWorktreeParentDir(ctx.cwd, settings);
|
|
470
|
+
const target = worktrees.find((w) => basename(w.path) === worktreeName || w.path === worktreeName || w.path === join(parentDir, worktreeName));
|
|
471
|
+
if (!target) {
|
|
472
|
+
ctx.ui.notify(`Worktree not found: ${worktreeName}`, "error");
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (target.isMain) {
|
|
476
|
+
ctx.ui.notify("Cannot remove the main worktree", "error");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (target.isCurrent) {
|
|
480
|
+
ctx.ui.notify("Cannot remove the current worktree. Switch to another first.", "error");
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const confirmed = await ctx.ui.confirm(`Remove worktree?`, `This will remove:
|
|
484
|
+
Path: ${target.path}
|
|
485
|
+
Branch: ${target.branch}
|
|
486
|
+
|
|
487
|
+
The branch will NOT be deleted.`);
|
|
488
|
+
if (!confirmed) {
|
|
489
|
+
ctx.ui.notify("Cancelled", "info");
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
try {
|
|
493
|
+
git(["worktree", "remove", target.path], ctx.cwd);
|
|
494
|
+
ctx.ui.notify(`\u2713 Worktree removed: ${target.path}`, "info");
|
|
495
|
+
} catch (err) {
|
|
496
|
+
const forceConfirmed = await ctx.ui.confirm("Force remove?", `Worktree has uncommitted changes. Force remove anyway?`);
|
|
497
|
+
if (forceConfirmed) {
|
|
498
|
+
try {
|
|
499
|
+
git(["worktree", "remove", "--force", target.path], ctx.cwd);
|
|
500
|
+
ctx.ui.notify(`\u2713 Worktree force removed: ${target.path}`, "info");
|
|
501
|
+
} catch (forceErr) {
|
|
502
|
+
ctx.ui.notify(`Failed to remove: ${forceErr.message}`, "error");
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
ctx.ui.notify("Cancelled", "info");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async function handleStatus(_args, ctx) {
|
|
510
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
511
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const isWt = isWorktree(ctx.cwd);
|
|
515
|
+
const mainPath = getMainWorktreePath(ctx.cwd);
|
|
516
|
+
const project = getProjectName(ctx.cwd);
|
|
517
|
+
const branch = getCurrentBranch(ctx.cwd);
|
|
518
|
+
const worktrees = listWorktrees(ctx.cwd);
|
|
519
|
+
const current = worktrees.find((w) => w.isCurrent);
|
|
520
|
+
const status = [
|
|
521
|
+
`Project: ${project}`,
|
|
522
|
+
`Current path: ${ctx.cwd}`,
|
|
523
|
+
`Branch: ${branch}`,
|
|
524
|
+
`Is worktree: ${isWt ? "Yes" : "No (main repository)"}`,
|
|
525
|
+
`Main worktree: ${mainPath}`,
|
|
526
|
+
`Total worktrees: ${worktrees.length}`
|
|
527
|
+
];
|
|
528
|
+
ctx.ui.notify(status.join(`
|
|
529
|
+
`), "info");
|
|
530
|
+
}
|
|
531
|
+
async function handleCd(args, ctx) {
|
|
532
|
+
const worktreeName = args.trim();
|
|
533
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
534
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
const worktrees = listWorktrees(ctx.cwd);
|
|
538
|
+
const settings = loadSettings();
|
|
539
|
+
const parentDir = getWorktreeParentDir(ctx.cwd, settings);
|
|
540
|
+
if (!worktreeName) {
|
|
541
|
+
const main = worktrees.find((w) => w.isMain);
|
|
542
|
+
if (main) {
|
|
543
|
+
console.log(main.path);
|
|
544
|
+
ctx.ui.notify(`Main worktree: ${main.path}`, "info");
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const target = worktrees.find((w) => basename(w.path) === worktreeName || w.path === worktreeName || w.path === join(parentDir, worktreeName));
|
|
549
|
+
if (!target) {
|
|
550
|
+
ctx.ui.notify(`Worktree not found: ${worktreeName}`, "error");
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
console.log(target.path);
|
|
554
|
+
ctx.ui.notify(`Worktree path: ${target.path}`, "info");
|
|
555
|
+
}
|
|
556
|
+
async function handlePrune(_args, ctx) {
|
|
557
|
+
if (!isGitRepo(ctx.cwd)) {
|
|
558
|
+
ctx.ui.notify("Not in a git repository", "error");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
let dryRun;
|
|
562
|
+
try {
|
|
563
|
+
dryRun = git(["worktree", "prune", "--dry-run"], ctx.cwd);
|
|
564
|
+
} catch (err) {
|
|
565
|
+
ctx.ui.notify(`Failed to check stale worktrees: ${err.message}`, "error");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!dryRun.trim()) {
|
|
569
|
+
ctx.ui.notify("No stale worktree references to prune", "info");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const confirmed = await ctx.ui.confirm("Prune stale worktrees?", `The following stale references will be removed:
|
|
573
|
+
|
|
574
|
+
${dryRun}`);
|
|
575
|
+
if (!confirmed) {
|
|
576
|
+
ctx.ui.notify("Cancelled", "info");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
git(["worktree", "prune"], ctx.cwd);
|
|
581
|
+
ctx.ui.notify("\u2713 Stale worktree references pruned", "info");
|
|
582
|
+
} catch (err) {
|
|
583
|
+
ctx.ui.notify(`Failed to prune: ${err.message}`, "error");
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
var commands = {
|
|
587
|
+
init: handleInit,
|
|
588
|
+
settings: handleSettings,
|
|
589
|
+
config: handleSettings,
|
|
590
|
+
create: handleCreate,
|
|
591
|
+
list: handleList,
|
|
592
|
+
ls: handleList,
|
|
593
|
+
remove: handleRemove,
|
|
594
|
+
rm: handleRemove,
|
|
595
|
+
status: handleStatus,
|
|
596
|
+
cd: handleCd,
|
|
597
|
+
prune: handlePrune
|
|
598
|
+
};
|
|
599
|
+
function src_default(pi) {
|
|
600
|
+
pi.registerCommand("worktree", {
|
|
601
|
+
description: "Git worktree management for isolated workspaces",
|
|
602
|
+
handler: async (args, ctx) => {
|
|
603
|
+
const [cmd, ...rest] = args.trim().split(/\s+/);
|
|
604
|
+
const handler = commands[cmd];
|
|
605
|
+
if (handler) {
|
|
606
|
+
await handler(rest.join(" "), ctx);
|
|
607
|
+
} else {
|
|
608
|
+
ctx.ui.notify(HELP_TEXT, "info");
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
export {
|
|
614
|
+
src_default as default
|
|
615
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zenobius/pi-worktrees",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Worktrees extension for Pi Coding Agent",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Zenobius",
|
|
7
|
+
"email": "airtonix@users.noreploy.github.com"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git@github.com:zenobi-us/pi-worktrees.git"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"pi-package"
|
|
25
|
+
],
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src/version.ts"
|
|
29
|
+
],
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@eslint/js": "^9.39.1",
|
|
32
|
+
"@types/node": "^20.11.5",
|
|
33
|
+
"@typescript-eslint/eslint-plugin": "8.47.0",
|
|
34
|
+
"@typescript-eslint/parser": "8.47.0",
|
|
35
|
+
"bun-types": "latest",
|
|
36
|
+
"eslint": "^9.39.1",
|
|
37
|
+
"eslint-config-prettier": "10.1.8",
|
|
38
|
+
"eslint-plugin-prettier": "^5.1.3",
|
|
39
|
+
"prettier": "^3.2.4",
|
|
40
|
+
"typescript-eslint": "^8.47.0",
|
|
41
|
+
"vitest": "^3.2.4"
|
|
42
|
+
}
|
|
43
|
+
}
|