@maxiaochao/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/CHANGELOG.md +5 -0
- package/README.md +60 -0
- package/TESTING.md +21 -0
- package/bin/pi-worktree +298 -0
- package/bin/pi-worktree-completion.bash +41 -0
- package/docs/demo.gif +0 -0
- package/docs/screenshot.png +0 -0
- package/package.json +20 -0
- package/scripts/install.mjs +26 -0
- package/skills/pi-worktree/SKILL.md +36 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @maxiaochao/pi-worktree
|
|
2
|
+
|
|
3
|
+
`pi-worktree` CLI 与配套 Pi skill。CLI 创建、检查和清理 Git worktree,并在隔离目录中启动 Pi。
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## 为什么有这个包
|
|
8
|
+
|
|
9
|
+
这个包把原来分散在主包中的 shell CLI、bash completion、安装逻辑和 agent skill 合成一个发布单元。它让人和 agent 使用同一套隔离流程,避免在主 checkout 中混入并行任务改动。
|
|
10
|
+
|
|
11
|
+
## 演示
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi-worktree start feature -p "implement the feature"
|
|
17
|
+
pi-worktree info feature
|
|
18
|
+
pi-worktree out
|
|
19
|
+
pi-worktree remove feature
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`start` 从当前分支创建或复用 `.worktrees/feature`,记录 base,并在该目录启动 Pi。
|
|
23
|
+
|
|
24
|
+
## 安装
|
|
25
|
+
|
|
26
|
+
完整工具包已经包含 CLI 和 skill:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pi install npm:@maxiaochao/pi-toolkit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
只需要 worktree 能力时单独安装:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:@maxiaochao/pi-worktree
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
postinstall 会把 CLI 复制到 `~/.local/bin/pi-worktree`,把 bash completion 安装到 `~/.local/share/bash-completion/completions/pi-worktree`。两种安装方式二选一。
|
|
39
|
+
|
|
40
|
+
## 更新
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi update npm:@maxiaochao/pi-toolkit # 更新主包内置的 CLI 与 skill
|
|
44
|
+
pi update npm:@maxiaochao/pi-worktree # 更新独立子包
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
更新会重新运行安装逻辑并覆盖 `~/.local/bin/pi-worktree` 和 completion。主包与子包版本独立,不要同时安装。
|
|
48
|
+
|
|
49
|
+
## 命令
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pi-worktree start <name> [pi args...]
|
|
53
|
+
pi-worktree list
|
|
54
|
+
pi-worktree info <name>
|
|
55
|
+
pi-worktree out
|
|
56
|
+
pi-worktree remove <name>
|
|
57
|
+
pi-worktree prune
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Agent 用法见 `skills/pi-worktree/SKILL.md`。测试流程、夹具和结果见 [TESTING.md](TESTING.md)。
|
package/TESTING.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Worktree 测试方案
|
|
2
|
+
|
|
3
|
+
## 流程
|
|
4
|
+
|
|
5
|
+
1. `npm --prefix packages/worktree test`:在临时 Git 仓库中使用假的 `pi` 命令运行 CLI。
|
|
6
|
+
2. `npm pack ./packages/worktree --dry-run`:确认 CLI、completion、skill 和安装脚本进入 tarball。
|
|
7
|
+
3. 手工验收发布包的 `pi-worktree start` 交互流程。
|
|
8
|
+
4. 确认 README 示例、真实临时 Git 仓库输出 `docs/screenshot.png` 和 `docs/demo.gif` 进入 tarball。
|
|
9
|
+
|
|
10
|
+
## 用例数据
|
|
11
|
+
|
|
12
|
+
- `--help` 输出命令说明。
|
|
13
|
+
- `start` 从当前分支创建 `.worktrees/<name>` 并记录 base。
|
|
14
|
+
- 假 Pi 收到正确工作目录和参数。
|
|
15
|
+
- `list` 标记主 checkout 和子 worktree。
|
|
16
|
+
- `info` 报告 clean、branch、base 和 ahead。
|
|
17
|
+
- postinstall 把可执行 CLI 与 completion 复制到隔离 HOME,并保持执行权限。
|
|
18
|
+
|
|
19
|
+
## 最近验证结果
|
|
20
|
+
|
|
21
|
+
迁移基线要求全部 shell 断言通过,且 `npm pack --dry-run` 包含 `bin/`、`skills/` 和 `scripts/install.mjs`。
|
package/bin/pi-worktree
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pi-worktree: create/reuse a git worktree, then launch pi inside it.
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# pi-worktree start <name> [pi args...] # enter worktree <name> and start pi
|
|
6
|
+
# pi-worktree start --base <ref> <name> [pi args...] # branch from <ref> instead of current branch
|
|
7
|
+
# pi-worktree -l | --list # list worktrees (main checkout marked "main checkout")
|
|
8
|
+
# pi-worktree -i <name> # show base commit, ahead/behind, unique commits
|
|
9
|
+
# pi-worktree -o | --out # cd back to the main checkout
|
|
10
|
+
# pi-worktree -r <name> # remove worktree <name> (must be clean & merged)
|
|
11
|
+
# pi-worktree -p | --prune # drop stale worktree registrations
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
# Colors: only when stdout is a TTY and NO_COLOR is unset (no-color.org)
|
|
15
|
+
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
|
|
16
|
+
C_BOLD=$'\e[1m' C_DIM=$'\e[2m' C_RED=$'\e[31m' C_GREEN=$'\e[32m' C_YELLOW=$'\e[33m' C_CYAN=$'\e[36m' C_RESET=$'\e[0m'
|
|
17
|
+
else
|
|
18
|
+
C_BOLD='' C_DIM='' C_RED='' C_GREEN='' C_YELLOW='' C_CYAN='' C_RESET=''
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
ok() { printf '%spi-worktree:%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
|
|
22
|
+
warn() { printf '%s⚠️ %s%s\n' "$C_YELLOW" "$C_RESET" "$*"; }
|
|
23
|
+
die() { printf '%spi-worktree: %s%s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; }
|
|
24
|
+
|
|
25
|
+
require_repo() {
|
|
26
|
+
git rev-parse --is-inside-work-tree >/dev/null 2>&1 || die "not inside a git repository"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
main_checkout() {
|
|
30
|
+
git rev-parse --path-format=absolute --git-common-dir | sed 's|/.git$||'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
in_linked_worktree() {
|
|
34
|
+
local git_dir common_dir
|
|
35
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null) || return 1
|
|
36
|
+
common_dir=$(git rev-parse --git-common-dir 2>/dev/null) || return 1
|
|
37
|
+
[[ "$(cd "$git_dir" && pwd -P)" != "$(cd "$common_dir" && pwd -P)" ]] \
|
|
38
|
+
&& [[ -z "$(git rev-parse --show-superproject-working-tree 2>/dev/null)" ]]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
wt_dir() { echo ".worktrees/$1"; }
|
|
42
|
+
|
|
43
|
+
# Best available base ref: current branch > HEAD (worktrees should branch
|
|
44
|
+
# from where you actually are; a stale origin/main must not win)
|
|
45
|
+
pick_base() {
|
|
46
|
+
local cur; cur=$(git branch --show-current 2>/dev/null || true)
|
|
47
|
+
if [[ -n "$cur" ]]; then
|
|
48
|
+
echo "$cur"
|
|
49
|
+
else
|
|
50
|
+
echo HEAD
|
|
51
|
+
fi
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Resolve base ref for a new worktree: explicit --base wins, else current branch.
|
|
55
|
+
resolve_base() {
|
|
56
|
+
if [[ -n "${BASE_OVERRIDE:-}" ]]; then
|
|
57
|
+
git rev-parse --verify --quiet "${BASE_OVERRIDE}^{commit}" >/dev/null \
|
|
58
|
+
|| die "base ref not found: $BASE_OVERRIDE"
|
|
59
|
+
echo "$BASE_OVERRIDE"
|
|
60
|
+
else
|
|
61
|
+
pick_base
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ensure_gitignore() {
|
|
66
|
+
# The trailing slash is load-bearing: `.worktrees/` is a directory-only
|
|
67
|
+
# pattern, and git check-ignore cannot match it against a path that does not
|
|
68
|
+
# exist yet — so `.worktrees` would look unignored on every first run and the
|
|
69
|
+
# rule would be appended again.
|
|
70
|
+
if ! git check-ignore -q .worktrees/ 2>/dev/null; then
|
|
71
|
+
printf '.worktrees/\n' >> .gitignore
|
|
72
|
+
ok "added .worktrees/ to .gitignore"
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
cmd_list() {
|
|
77
|
+
require_repo
|
|
78
|
+
local main_dir; main_dir=$(main_checkout)
|
|
79
|
+
# Annotate the primary checkout and stale (prunable) entries.
|
|
80
|
+
git worktree list --porcelain | awk -v main="$main_dir" \
|
|
81
|
+
-v D="${C_DIM}" -v Y="${C_YELLOW}" -v C="${C_CYAN}" -v R="${C_RESET}" '
|
|
82
|
+
/^worktree / { path=substr($0,10); next }
|
|
83
|
+
/^HEAD / { sha=substr($0,6); next }
|
|
84
|
+
/^branch / { b=substr($0,8); sub("refs/heads/","",b); next }
|
|
85
|
+
/^detached/ { b="(detached)"; next }
|
|
86
|
+
/^bare/ { b="(bare)"; next }
|
|
87
|
+
/^prunable/ { stale=1; next }
|
|
88
|
+
/^$/ {
|
|
89
|
+
if (b=="") b="?"
|
|
90
|
+
mark=(path==main) ? " " D "(main checkout)" R : ""
|
|
91
|
+
branch=(path==main) ? b : C b R
|
|
92
|
+
hint=(stale) ? " " Y "← stale: dir gone, run: pi-worktree prune" R : ""
|
|
93
|
+
printf "%s %s%s%s %s%s%s\n", path, D, substr(sha,1,7), R, branch, mark, hint
|
|
94
|
+
path=""; sha=""; b=""; stale=0
|
|
95
|
+
}
|
|
96
|
+
'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
subcmd_prune() {
|
|
100
|
+
require_repo
|
|
101
|
+
git worktree prune -v
|
|
102
|
+
ok "pruned stale worktree registrations"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
cmd_out() {
|
|
106
|
+
cd "$(main_checkout)" && exec "${SHELL:-/bin/bash}"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
cmd_info() {
|
|
110
|
+
require_repo
|
|
111
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree -i <name>"
|
|
112
|
+
local dir; dir="$(wt_dir "$1")"
|
|
113
|
+
[[ -d "$dir" ]] || die "worktree not found: $dir"
|
|
114
|
+
local branch; branch=$(git -C "$dir" branch --show-current)
|
|
115
|
+
echo "worktree: $dir"
|
|
116
|
+
if git -C "$dir" diff --quiet 2>/dev/null && git -C "$dir" diff --cached --quiet 2>/dev/null \
|
|
117
|
+
&& [[ -z "$(git -C "$dir" ls-files --others --exclude-standard)" ]]; then
|
|
118
|
+
echo "status: clean"
|
|
119
|
+
else
|
|
120
|
+
echo "status: DIRTY"
|
|
121
|
+
git -C "$dir" status --short | head -10
|
|
122
|
+
fi
|
|
123
|
+
echo "branch: $branch @ $(git -C "$dir" rev-parse --short HEAD)"
|
|
124
|
+
local base baseRef ahead behind
|
|
125
|
+
base=$(git config --get "branch.$branch.base" 2>/dev/null || true)
|
|
126
|
+
baseRef=$(git config --get "branch.$branch.baseRef" 2>/dev/null || true)
|
|
127
|
+
if [[ -n "$base" ]]; then
|
|
128
|
+
echo "based on: $baseRef @ $base"
|
|
129
|
+
ahead=$(git rev-list --count "$base..$branch" 2>/dev/null || echo "?")
|
|
130
|
+
echo "ahead: $ahead commits since base"
|
|
131
|
+
else
|
|
132
|
+
echo "based on: (not recorded; run: git merge-base main $branch)"
|
|
133
|
+
fi
|
|
134
|
+
if git show-ref --verify --quiet refs/heads/main && [[ "$branch" != main ]]; then
|
|
135
|
+
behind=$(git rev-list --count "$branch..main" 2>/dev/null || echo "?")
|
|
136
|
+
echo "behind: $behind commits vs main"$([[ "${behind:-0}" -gt 0 ]] && echo " ← consider rebase")
|
|
137
|
+
fi
|
|
138
|
+
if [[ -n "$base" ]]; then
|
|
139
|
+
echo "unique commits:"
|
|
140
|
+
git log --oneline "$base..$branch" | head -5 || true
|
|
141
|
+
fi
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cmd_remove() {
|
|
145
|
+
require_repo
|
|
146
|
+
[[ $# -eq 1 ]] || die "usage: pi-worktree -r <name>"
|
|
147
|
+
local dir; dir="$(wt_dir "$1")"
|
|
148
|
+
local branch registered rm_anyway=0 force=0
|
|
149
|
+
if [[ ! -d "$dir" ]]; then
|
|
150
|
+
# Directory already gone but possibly still registered (prunable), or a
|
|
151
|
+
# stale branch left behind by an earlier prune: clean the registration,
|
|
152
|
+
# then fall through to the branch cleanup.
|
|
153
|
+
local registered
|
|
154
|
+
registered=$(git worktree list --porcelain | awk -v p="$(pwd -P)/$dir" '
|
|
155
|
+
$0=="worktree "p {f=1; next}
|
|
156
|
+
f && /^branch / {print substr($0,8); exit}
|
|
157
|
+
f && /^detached/ {print "-"; exit}
|
|
158
|
+
f && /^$/ {exit}')
|
|
159
|
+
if [[ "$registered" == "-" ]]; then
|
|
160
|
+
git worktree prune
|
|
161
|
+
ok "pruned stale registration: $dir (directory already gone)"
|
|
162
|
+
return 0 # detached HEAD, nothing else to clean up
|
|
163
|
+
fi
|
|
164
|
+
if [[ -n "$registered" ]]; then
|
|
165
|
+
branch=${registered#refs/heads/}
|
|
166
|
+
elif git show-ref --verify --quiet "refs/heads/$1"; then
|
|
167
|
+
branch=$1 # registration already pruned; only the stale branch remains
|
|
168
|
+
else
|
|
169
|
+
die "worktree not found: $dir"
|
|
170
|
+
fi
|
|
171
|
+
if [[ -n "$registered" ]]; then
|
|
172
|
+
git worktree prune
|
|
173
|
+
ok "pruned stale registration: $dir (directory already gone)"
|
|
174
|
+
fi
|
|
175
|
+
else
|
|
176
|
+
# dirty worktree: removing discards modified/untracked files, which git
|
|
177
|
+
# refuses to do without --force — so confirm the loss explicitly
|
|
178
|
+
if ! git -C "$dir" diff --quiet 2>/dev/null \
|
|
179
|
+
|| ! git -C "$dir" diff --cached --quiet 2>/dev/null \
|
|
180
|
+
|| [[ -n "$(git -C "$dir" ls-files --others --exclude-standard)" ]]; then
|
|
181
|
+
warn "worktree contains modified/untracked files that will be discarded:"
|
|
182
|
+
git -C "$dir" status --short | head -10
|
|
183
|
+
read -rp "Discard and remove anyway? [y/N] " ans
|
|
184
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
185
|
+
force=1
|
|
186
|
+
fi
|
|
187
|
+
branch=$(git -C "$dir" branch --show-current)
|
|
188
|
+
# refuse to remove if the branch is not merged into main
|
|
189
|
+
if [[ -n "$branch" ]] && ! git merge-base --is-ancestor "$branch" main 2>/dev/null; then
|
|
190
|
+
warn "branch '$branch' does not appear merged into 'main' (or no main branch exists)."
|
|
191
|
+
read -rp "Remove anyway? [y/N] " ans
|
|
192
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
193
|
+
rm_anyway=1
|
|
194
|
+
fi
|
|
195
|
+
local out
|
|
196
|
+
if [[ "$force" == 1 ]]; then
|
|
197
|
+
git worktree remove --force "$dir" && ok "removed: $dir (discarded changes as confirmed)"
|
|
198
|
+
elif out=$(git worktree remove "$dir" 2>&1); then
|
|
199
|
+
ok "removed: $dir"
|
|
200
|
+
elif [[ "$out" == *"submodules cannot be moved or removed"* ]]; then
|
|
201
|
+
# git refuses to remove worktrees with initialized submodules unless
|
|
202
|
+
# --force is used (--force only overrides the submodule check here; the
|
|
203
|
+
# branch deletion below still asks before dropping unmerged commits)
|
|
204
|
+
git worktree remove --force "$dir" && ok "removed: $dir (contained submodules)"
|
|
205
|
+
else
|
|
206
|
+
printf '%s\n' "$out" >&2
|
|
207
|
+
die "failed to remove worktree: $dir"
|
|
208
|
+
fi
|
|
209
|
+
fi
|
|
210
|
+
# The branch survives `git worktree remove`; recreating the same name would
|
|
211
|
+
# silently reuse this stale branch. Delete merged branches automatically.
|
|
212
|
+
if [[ -n "$branch" ]] && git show-ref --verify --quiet "refs/heads/$branch"; then
|
|
213
|
+
if git branch -d "$branch" 2>/dev/null; then
|
|
214
|
+
ok "deleted merged branch: $branch"
|
|
215
|
+
elif [[ "$rm_anyway" == 1 ]]; then
|
|
216
|
+
git branch -D "$branch" && ok "deleted unmerged branch: $branch (confirmed above)"
|
|
217
|
+
else
|
|
218
|
+
warn "branch '$branch' has unmerged commits."
|
|
219
|
+
read -rp "Delete anyway? [y/N] " ans
|
|
220
|
+
if [[ "$ans" =~ ^[Yy]$ ]]; then
|
|
221
|
+
git branch -D "$branch" && ok "deleted branch: $branch"
|
|
222
|
+
else
|
|
223
|
+
warn "branch '$branch' kept; 'pi-worktree start $branch' will reuse it as-is"
|
|
224
|
+
fi
|
|
225
|
+
fi
|
|
226
|
+
fi
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
cd_wt() {
|
|
230
|
+
local name="$1"; shift
|
|
231
|
+
require_repo
|
|
232
|
+
|
|
233
|
+
# Already isolated? Just launch pi here (superpowers Step 0).
|
|
234
|
+
if in_linked_worktree; then
|
|
235
|
+
ok "already in an isolated worktree ($(pwd)), launching pi here"
|
|
236
|
+
exec pi "$@"
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
local dir; dir="$(wt_dir "$name")"
|
|
240
|
+
|
|
241
|
+
if [[ -d "$dir" ]]; then
|
|
242
|
+
# Reuse: must be a registered worktree for this repo.
|
|
243
|
+
# Skip the dirty-workspace prompt — the worktree already exists, nothing new to create.
|
|
244
|
+
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || die "$dir exists but is not a git worktree"
|
|
245
|
+
ensure_gitignore
|
|
246
|
+
ok "reusing existing worktree $dir"
|
|
247
|
+
else
|
|
248
|
+
# Dirty workspace: uncommitted changes do NOT follow into the new worktree.
|
|
249
|
+
if ! git diff --quiet || ! git diff --cached --quiet || [[ -n "$(git ls-files --others --exclude-standard)" ]]; then
|
|
250
|
+
warn "Uncommitted changes stay in THIS directory; they will NOT appear in the worktree:"
|
|
251
|
+
git status --short | head -20
|
|
252
|
+
read -rp "Continue? [y/N] " ans
|
|
253
|
+
[[ "$ans" =~ ^[Yy]$ ]] || die "aborted"
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
# After the check above, so the tool's own .gitignore edit is not reported
|
|
257
|
+
# as (and does not prompt about) the user's uncommitted work.
|
|
258
|
+
ensure_gitignore
|
|
259
|
+
mkdir -p .worktrees
|
|
260
|
+
# Reuse branch if it exists, else create it
|
|
261
|
+
if git show-ref --verify --quiet "refs/heads/$name"; then
|
|
262
|
+
git worktree add "$dir" "$name"
|
|
263
|
+
ok "checked out existing branch '$name'"
|
|
264
|
+
# record a best-effort base for -i display
|
|
265
|
+
local mb; mb=$(git merge-base main "$name" 2>/dev/null || true)
|
|
266
|
+
[[ -n "$mb" ]] && git config "branch.$name.base" "$(git rev-parse --short "$mb")" \
|
|
267
|
+
&& git config "branch.$name.baseRef" main
|
|
268
|
+
else
|
|
269
|
+
local base; base=$(resolve_base)
|
|
270
|
+
git worktree add "$dir" -b "$name" "$base"
|
|
271
|
+
git config "branch.$name.base" "$(git rev-parse --short "$base")"
|
|
272
|
+
git config "branch.$name.baseRef" "$base"
|
|
273
|
+
ok "created branch '$name' from $base @ $(git rev-parse --short "$base")"
|
|
274
|
+
fi
|
|
275
|
+
fi
|
|
276
|
+
|
|
277
|
+
cd "$dir"
|
|
278
|
+
exec pi "$@"
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
case "${1:-}" in
|
|
282
|
+
-l|--list|list|ls) shift; cmd_list "$@" ;;
|
|
283
|
+
-i|--info|info) shift; cmd_info "$@" ;;
|
|
284
|
+
-o|--out|out) shift; cmd_out "$@" ;;
|
|
285
|
+
-r|--remove|rm|remove) shift; cmd_remove "$@" ;;
|
|
286
|
+
-p|--prune|prune) shift; subcmd_prune ;;
|
|
287
|
+
-h|--help|help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' ;;
|
|
288
|
+
start) shift
|
|
289
|
+
BASE_OVERRIDE=""
|
|
290
|
+
if [[ "${1:-}" == "--base" ]]; then
|
|
291
|
+
shift; [[ $# -ge 2 ]] || die "usage: pi-worktree start --base <ref> <name> [pi args...]"
|
|
292
|
+
BASE_OVERRIDE="$1"; shift
|
|
293
|
+
fi
|
|
294
|
+
[[ $# -ge 1 ]] || die "usage: pi-worktree start <name> [pi args...]"
|
|
295
|
+
name="$1"; shift; cd_wt "$name" "$@" ;;
|
|
296
|
+
"") die "usage: pi-worktree start <name> [pi args...] (or list / info / out / remove / prune)" ;;
|
|
297
|
+
*) die "unknown command: $1 (usage: pi-worktree start <name> [pi args...])" ;;
|
|
298
|
+
esac
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# bash completion for pi-worktree
|
|
2
|
+
_pi_worktree() {
|
|
3
|
+
local cur first
|
|
4
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
5
|
+
first="${COMP_WORDS[1]}"
|
|
6
|
+
|
|
7
|
+
local SUBS="start list ls info out remove rm prune -l --list -i --info -o --out -r --remove -p --prune -h --help"
|
|
8
|
+
|
|
9
|
+
# existing worktree names (only linked worktrees, i.e. under .worktrees/)
|
|
10
|
+
local wts=""
|
|
11
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
12
|
+
local main_dir
|
|
13
|
+
main_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null | sed 's|/.git$||')
|
|
14
|
+
wts=$(git worktree list --porcelain 2>/dev/null \
|
|
15
|
+
| sed -n 's|^worktree ||p' \
|
|
16
|
+
| while read -r p; do [[ "$p" != "$main_dir" ]] && basename "$p"; done || true)
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if (( COMP_CWORD == 1 )); then
|
|
20
|
+
# first word: subcommands, flags, and existing worktree names (to enter)
|
|
21
|
+
COMPREPLY=($(compgen -W "$SUBS $wts" -- "$cur"))
|
|
22
|
+
return
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
case "$first" in
|
|
26
|
+
-i|--info|info|-r|--remove|remove|rm|start)
|
|
27
|
+
# only the second word of info/remove completes worktree names;
|
|
28
|
+
# later words offer nothing (info/remove take no further args)
|
|
29
|
+
if (( COMP_CWORD == 2 )); then
|
|
30
|
+
COMPREPLY=($(compgen -W "$wts" -- "$cur"))
|
|
31
|
+
else
|
|
32
|
+
COMPREPLY=()
|
|
33
|
+
fi
|
|
34
|
+
;;
|
|
35
|
+
*)
|
|
36
|
+
# after a name: default (file) completion for pi args
|
|
37
|
+
COMPREPLY=()
|
|
38
|
+
;;
|
|
39
|
+
esac
|
|
40
|
+
}
|
|
41
|
+
complete -F _pi_worktree pi-worktree
|
package/docs/demo.gif
ADDED
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maxiaochao/pi-worktree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Git worktree CLI and Pi skill for isolated coding tasks.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "pi-skill", "git", "worktree"],
|
|
7
|
+
"files": ["bin", "skills", "scripts", "docs", "README.md", "TESTING.md", "CHANGELOG.md"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/chaochaoxiaochao/pi-toolkit.git",
|
|
11
|
+
"directory": "packages/worktree"
|
|
12
|
+
},
|
|
13
|
+
"bin": { "pi-worktree": "bin/pi-worktree" },
|
|
14
|
+
"pi": { "skills": ["./skills"] },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test tests/install.test.mjs && bash tests/run-tests.sh",
|
|
17
|
+
"postinstall": "node scripts/install.mjs"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": { "access": "public" }
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir, platform } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
|
|
8
|
+
export function installWorktree({ root = packageRoot, home = homedir() } = {}) {
|
|
9
|
+
if (platform() === "win32") return [];
|
|
10
|
+
const installed = [];
|
|
11
|
+
const binTarget = join(home, ".local", "bin", "pi-worktree");
|
|
12
|
+
mkdirSync(dirname(binTarget), { recursive: true });
|
|
13
|
+
copyFileSync(join(root, "bin", "pi-worktree"), binTarget);
|
|
14
|
+
chmodSync(binTarget, 0o755);
|
|
15
|
+
installed.push(binTarget);
|
|
16
|
+
|
|
17
|
+
const completionTarget = join(home, ".local", "share", "bash-completion", "completions", "pi-worktree");
|
|
18
|
+
mkdirSync(dirname(completionTarget), { recursive: true });
|
|
19
|
+
copyFileSync(join(root, "bin", "pi-worktree-completion.bash"), completionTarget);
|
|
20
|
+
installed.push(completionTarget);
|
|
21
|
+
return installed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
25
|
+
for (const path of installWorktree()) console.log(`[pi-worktree] installed -> ${path}`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-worktree
|
|
3
|
+
description: Create, inspect, and clean up git worktrees with the pi-worktree CLI (start/list/info/remove/prune), which also launches Pi inside the worktree. Use when a task warrants isolation in its own worktree, or when the user asks to handle something the worktree way.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-worktree
|
|
7
|
+
|
|
8
|
+
`pi-worktree` wraps git worktree creation and Pi launch into one command. Worktrees live in `.worktrees/<name>` (added to `.gitignore`), each with a branch of the same name, branched from the branch you are on now (or `HEAD` when detached). The base commit is recorded in `branch.<name>.base` / `branch.<name>.baseRef` so `info` can always report where the branch came from.
|
|
9
|
+
|
|
10
|
+
Installed by the package postinstall hook to `~/.local/bin/pi-worktree`, with bash completion for subcommands and existing worktree names.
|
|
11
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
| Command | Effect |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `pi-worktree start <name> [pi args...]` | Create or reuse `.worktrees/<name>`, then launch Pi inside it. Already inside a linked worktree, it just launches Pi there. |
|
|
17
|
+
| `pi-worktree start --base <ref> <name> [pi args...]` | Same, but branch from `<ref>` instead of the current branch. |
|
|
18
|
+
| `pi-worktree list` | All worktrees; the main checkout is marked `(main checkout)`, stale registrations are flagged. |
|
|
19
|
+
| `pi-worktree info <name>` | Clean/dirty status, branch and HEAD, base commit, ahead/behind `main`, unique commits. |
|
|
20
|
+
| `pi-worktree out` | Open a shell in the main checkout (for merging). |
|
|
21
|
+
| `pi-worktree remove <name>` | Remove the worktree and its branch; asks before discarding dirty files or unmerged commits. |
|
|
22
|
+
| `pi-worktree prune` | Drop registrations whose directory was deleted by hand. |
|
|
23
|
+
|
|
24
|
+
## Isolating a task
|
|
25
|
+
|
|
26
|
+
1. **Survey** — `pi-worktree list`, plus `pi-worktree info <name>` when the name you want is already taken. Reuse is free; a removed worktree is gone for good, because `remove` deletes its branch.
|
|
27
|
+
2. **Start** — `pi-worktree start <name>` (with `--base <ref>` when the work must not start from the current branch). Done when `list` shows the new worktree on its own branch.
|
|
28
|
+
3. **Work** — inside the worktree: commit there, and check `pi-worktree info <name>` before claiming progress. `ahead: N commits since base` is the evidence.
|
|
29
|
+
4. **Land** — `pi-worktree out`, then `git merge <name>` in the main checkout. Done when the worktree's unique commits are in the target branch.
|
|
30
|
+
5. **Clean up** — `pi-worktree remove <name>`. A merged branch goes without prompting; anything unmerged asks once.
|
|
31
|
+
|
|
32
|
+
## Driving it from an agent
|
|
33
|
+
|
|
34
|
+
- `start` **execs `pi`** in the worktree, and `out` execs a shell. With no trailing Pi args both want an interactive terminal, so either run `start <name> -p "<task>"` for a headless nested run, or hand the bare `start <name>` to the user to run themselves.
|
|
35
|
+
- `remove` asks before discarding dirty files or unmerged commits, and a non-interactive stdin (EOF) aborts safely. Answer explicitly when the removal is intended: `printf 'y\ny\n' | pi-worktree remove <name>`.
|
|
36
|
+
- Your own working directory cannot change. "Isolate this in a worktree" therefore means launching a nested headless Pi in the worktree, or telling the user to run `pi-worktree start <name>`.
|