Integrated repository, worktree, git stacker, and project manager
0

Configure Feed

Select the types of activity you want to include in your feed.

Add `pm cd <project> [<wt>]` for shell-side cwd shortcuts

Prints the project dir (or the forward symlink to a wt) so the caller
can wrap it: `cd "$(pm cd myproj wt1)"`. Errors clearly when the wt is
detached so a shell function doesn't silently land in the wrong place.

Wires zsh completion for both positionals via `_pm_wt_for_cd`, which
reads the project from `$words[3]` and falls back to `--project`.

Jordan Isaacs (Apr 28, 2026, 8:11 PM UTC) d4a06b60 46061410

+344 -3
+1
README.md
··· 47 47 pm stacker continue | abort # resume/cancel paused op 48 48 pm stacker config [--list | --unset] [<key> [<value>]] # pr.mode, pr.trunk, pr.target-repo 49 49 50 + pm cd <project> [<wt>] # print project (or worktree) path; wrap with `cd "$(pm cd …)"` 50 51 pm check [--fix] # invariant scan across pool + projects 51 52 ``` 52 53
+206 -3
integrations/_pm
··· 14 14 cmds) 15 15 local -a commands 16 16 commands=( 17 + 'agent:list recent coding-agent sessions' 17 18 'pool:manage worktree pool' 18 19 'project:manage projects' 19 20 'repo:manage canonical repos under ~/.repos' 20 21 'stacker:branch-stack tracking against pm'\''s pool' 22 + 'cd:Print the path of a project, or one of its worktrees.' 21 23 'check:Verify invariants across pool and projects.' 22 24 ) 23 25 _describe -t commands 'command' commands 24 26 ;; 25 27 args) 26 28 case $words[1] in 29 + agent) 30 + _arguments -C \ 31 + '--help[Display this message and exit.]' \ 32 + '-h[Display this message and exit.]' \ 33 + '--version[Display application version.]' \ 34 + '1: :->cmds' \ 35 + '*::arg:->args' 36 + 37 + case $state in 38 + cmds) 39 + local -a commands 40 + commands=( 41 + 'ls:List recent chat sessions across Claude / Codex / Cursor, per project.' 42 + 'claude:Launch Claude Code in the pm project dir, forwarding extra args.' 43 + 'codex:Launch Codex CLI in the pm project dir, forwarding extra args.' 44 + 'cursor:Launch Cursor agent in the pm project dir, forwarding extra args.' 45 + ) 46 + _describe -t commands 'command' commands 47 + ;; 48 + args) 49 + case $words[1] in 50 + ls) 51 + _arguments \ 52 + '1:limit to one project' \ 53 + '2:every project' \ 54 + '-p[limit to one project]:p' \ 55 + '--project[limit to one project]:project' \ 56 + '--all[every project]' \ 57 + '-n[-n]:n' \ 58 + '--limit[-n]:limit' \ 59 + '--agent[comma-separated subset\: claude,codex,cursor]:agent' \ 60 + '--json[--json]' \ 61 + '--resume[pick a session interactively and resume it]' \ 62 + '--help[Display this message and exit.]' \ 63 + '-h[Display this message and exit.]' \ 64 + '--version[Display application version.]' 65 + 66 + ;; 67 + claude) 68 + _arguments \ 69 + '*:ARGS' \ 70 + '-p[-p]:p' \ 71 + '--project[-p]:project' \ 72 + '--help[Display this message and exit.]' \ 73 + '-h[Display this message and exit.]' \ 74 + '--version[Display application version.]' 75 + 76 + ;; 77 + codex) 78 + _arguments \ 79 + '*:ARGS' \ 80 + '-p[-p]:p' \ 81 + '--project[-p]:project' \ 82 + '--help[Display this message and exit.]' \ 83 + '-h[Display this message and exit.]' \ 84 + '--version[Display application version.]' 85 + 86 + ;; 87 + cursor) 88 + _arguments \ 89 + '*:ARGS' \ 90 + '-p[-p]:p' \ 91 + '--project[-p]:project' \ 92 + '--help[Display this message and exit.]' \ 93 + '-h[Display this message and exit.]' \ 94 + '--version[Display application version.]' 95 + 96 + ;; 97 + esac 98 + ;; 99 + esac 100 + ;; 27 101 pool) 28 102 _arguments -C \ 29 103 '--help[Display this message and exit.]' \ ··· 37 111 local -a commands 38 112 commands=( 39 113 'add:Mint a new pool slot.' 40 - 'ls:List pool slots with claim status.' 114 + 'ls:List pool slots with claim status, grouped by repo.' 41 115 ) 42 116 _describe -t commands 'command' commands 43 117 ;; ··· 47 121 _arguments \ 48 122 '1:repo:_pm_repos' \ 49 123 '--repo[--repo]:repo:_pm_repos' \ 124 + '--json[--json]' \ 50 125 '--help[Display this message and exit.]' \ 51 126 '-h[Display this message and exit.]' \ 52 127 '--version[Display application version.]' ··· 56 131 _arguments \ 57 132 '1:repo:_pm_repos' \ 58 133 '--repo[--repo]:repo:_pm_repos' \ 134 + '--json[--json]' \ 59 135 '--help[Display this message and exit.]' \ 60 136 '-h[Display this message and exit.]' \ 61 137 '--version[Display application version.]' ··· 79 155 commands=( 80 156 'create:Create a project (optionally with initial worktrees).' 81 157 'delete:Delete a whole project.' 82 - 'ls:List projects.' 83 - 'status:Show health + db rows for a single project (defaults to current).' 158 + 'ls:List projects, grouped by project name.' 159 + 'status:Show worktree health, PR state, stacker arms, and recent agent sessions.' 84 160 'wt:manage worktrees within a project' 85 161 ) 86 162 _describe -t commands 'command' commands ··· 92 168 '1:--project' \ 93 169 '--project[--project]:project' \ 94 170 '--wt[--wt]:wt' \ 171 + '--json[--json]' \ 95 172 '--help[Display this message and exit.]' \ 96 173 '-h[Display this message and exit.]' \ 97 174 '--version[Display application version.]' ··· 103 180 '--project[--project]:project:_pm_projects' \ 104 181 '--dry-run[--dry-run]' \ 105 182 '--no-dry-run[--dry-run]' \ 183 + '--json[--json]' \ 106 184 '--help[Display this message and exit.]' \ 107 185 '-h[Display this message and exit.]' \ 108 186 '--version[Display application version.]' ··· 110 188 ;; 111 189 ls) 112 190 _arguments \ 191 + '--json[--json]' \ 113 192 '--help[Display this message and exit.]' \ 114 193 '-h[Display this message and exit.]' \ 115 194 '--version[Display application version.]' ··· 119 198 _arguments \ 120 199 '1:project:_pm_projects' \ 121 200 '--project[--project]:project:_pm_projects' \ 201 + '--json[--json]' \ 202 + '-s[comma-separated subset\: worktrees,prs,stacker,sessions]:s' \ 203 + '--section[comma-separated subset\: worktrees,prs,stacker,sessions]:section' \ 122 204 '--help[Display this message and exit.]' \ 123 205 '-h[Display this message and exit.]' \ 124 206 '--version[Display application version.]' ··· 155 237 '--wt[comma-separated worktree names]:wt:_pm_wt_attachable' \ 156 238 '--all[every worktree in the project]' \ 157 239 '--no-branch[--no-branch]' \ 240 + '--json[--json]' \ 158 241 '--help[Display this message and exit.]' \ 159 242 '-h[Display this message and exit.]' \ 160 243 '--version[Display application version.]' ··· 167 250 '--spec[--spec]:spec' \ 168 251 '-p[project name (defaults to current project)]:p:_pm_projects' \ 169 252 '--project[project name (defaults to current project)]:project:_pm_projects' \ 253 + '--json[--json]' \ 170 254 '--help[Display this message and exit.]' \ 171 255 '-h[Display this message and exit.]' \ 172 256 '--version[Display application version.]' ··· 183 267 '--all[every worktree in the project]' \ 184 268 '--dry-run[--dry-run]' \ 185 269 '--no-dry-run[--dry-run]' \ 270 + '--json[--json]' \ 186 271 '--help[Display this message and exit.]' \ 187 272 '-h[Display this message and exit.]' \ 188 273 '--version[Display application version.]' ··· 199 284 '--all[every worktree in the project]' \ 200 285 '--dry-run[--dry-run]' \ 201 286 '--no-dry-run[--dry-run]' \ 287 + '--json[--json]' \ 202 288 '--help[Display this message and exit.]' \ 203 289 '-h[Display this message and exit.]' \ 204 290 '--version[Display application version.]' ··· 225 311 local -a commands 226 312 commands=( 227 313 'ls:List canonical repos with branch + upstream status.' 314 + 'maintenance:Warm git object store + index + fsmonitor across repos and pool slots.' 228 315 'pull:Fetch + ff-only pull each canonical repo.' 229 316 ) 230 317 _describe -t commands 'command' commands ··· 233 320 case $words[1] in 234 321 ls) 235 322 _arguments \ 323 + '--offline[--offline]' \ 324 + '--json[--json]' \ 325 + '--help[Display this message and exit.]' \ 326 + '-h[Display this message and exit.]' \ 327 + '--version[Display application version.]' 328 + 329 + ;; 330 + maintenance) 331 + _arguments \ 332 + '--repo[--repo]:repo:_pm_repos' \ 333 + '--json[--json]' \ 236 334 '--help[Display this message and exit.]' \ 237 335 '-h[Display this message and exit.]' \ 238 336 '--version[Display application version.]' ··· 241 339 pull) 242 340 _arguments \ 243 341 '--repo[--repo]:repo:_pm_repos' \ 342 + '--json[--json]' \ 244 343 '--help[Display this message and exit.]' \ 245 344 '-h[Display this message and exit.]' \ 246 345 '--version[Display application version.]' ··· 263 362 local -a commands 264 363 commands=( 265 364 'abort:Abort a paused stacker operation and reset state.' 365 + 'absorb:Cherry-pick the current branch'\''s new commits onto its parent.' 266 366 'config:Get/set per-repo stacker config.' 267 367 'continue:Resume a paused stacker operation.' 268 368 'create:Create or adopt a tracked branch (optionally in a pool slot).' 269 369 'guard:internal guardrails for Git hooks' 270 370 'log:Show commits since the branch'\''s managed base.' 271 371 'ls:Render the stack tree.' 372 + 'pr:manage the cached PR association for tracked branches' 272 373 'push:Force-push + create/update PRs for a scope.' 273 374 'remove:Untrack a branch (optionally delete it); reparent children.' 274 375 'rename:Rename the current branch.' ··· 290 391 '--version[Display application version.]' 291 392 292 393 ;; 394 + absorb) 395 + _arguments \ 396 + '1:--branch' \ 397 + '2:defaults to the cwd'\''s pm slot' \ 398 + '--branch[--branch]:branch' \ 399 + '--repo[defaults to the cwd'\''s pm slot]:repo:_pm_repos' \ 400 + '--continue[resume a paused absorb]' \ 401 + '--abort[abort the paused op]' \ 402 + '--help[Display this message and exit.]' \ 403 + '-h[Display this message and exit.]' \ 404 + '--version[Display application version.]' 405 + 406 + ;; 293 407 config) 294 408 _arguments \ 295 409 '1:--key' \ ··· 299 413 '--repo[defaults to the cwd'\''s pm slot]:repo:_pm_repos' \ 300 414 '--list[list all set keys]' \ 301 415 '--unset[remove a key]' \ 416 + '--json[--json]' \ 302 417 '--help[Display this message and exit.]' \ 303 418 '-h[Display this message and exit.]' \ 304 419 '--version[Display application version.]' ··· 382 497 '--skip-ancestors[exclude ancestors from the walk]' \ 383 498 '--skip-descendants[exclude descendants from the walk]' \ 384 499 '--from[start the walk from this branch]:from' \ 500 + '-c[narrow to the current branch'\''s lineage (default\: all)]' \ 501 + '--current[narrow to the current branch'\''s lineage (default\: all)]' \ 385 502 '--details[--details]:details:(none status status-counts all)' \ 386 503 '--json[--json]' \ 504 + '--icons[--icons]' \ 505 + '--no-icons[--icons]' \ 506 + '--merged[--merged]' \ 507 + '--no-merged[--merged]' \ 508 + '--merged-style[--merged-style]:merged-style:(dimmed strikethrough normal)' \ 509 + '--color-mode[--color-mode]:color-mode:(off icon title full)' \ 510 + '--legend[--legend]' \ 387 511 '--help[Display this message and exit.]' \ 388 512 '-h[Display this message and exit.]' \ 389 513 '--version[Display application version.]' 390 514 391 515 ;; 516 + pr) 517 + _arguments -C \ 518 + '--help[Display this message and exit.]' \ 519 + '-h[Display this message and exit.]' \ 520 + '--version[Display application version.]' \ 521 + '1: :->cmds' \ 522 + '*::arg:->args' 523 + 524 + case $state in 525 + cmds) 526 + local -a commands 527 + commands=( 528 + 'refresh:Look up the current GitHub PR for a tracked branch and cache it.' 529 + 'unlink:Clear the cached PR association for a branch (or --all branches).' 530 + ) 531 + _describe -t commands 'command' commands 532 + ;; 533 + args) 534 + case $words[1] in 535 + refresh) 536 + _arguments \ 537 + '1:--branch' \ 538 + '2:defaults to the cwd'\''s pm slot' \ 539 + '--branch[--branch]:branch' \ 540 + '--repo[defaults to the cwd'\''s pm slot]:repo:_pm_repos' \ 541 + '--help[Display this message and exit.]' \ 542 + '-h[Display this message and exit.]' \ 543 + '--version[Display application version.]' 544 + 545 + ;; 546 + unlink) 547 + _arguments \ 548 + '1:--branch' \ 549 + '2:defaults to the cwd'\''s pm slot' \ 550 + '--branch[--branch]:branch' \ 551 + '--repo[defaults to the cwd'\''s pm slot]:repo:_pm_repos' \ 552 + '--all[clear PR links for every branch]' \ 553 + '--help[Display this message and exit.]' \ 554 + '-h[Display this message and exit.]' \ 555 + '--version[Display application version.]' 556 + 557 + ;; 558 + esac 559 + ;; 560 + esac 561 + ;; 392 562 push) 393 563 _arguments \ 394 564 '1:--branch' \ ··· 503 673 ;; 504 674 esac 505 675 ;; 676 + cd) 677 + _arguments \ 678 + '1:project:_pm_projects' \ 679 + '2:wt:_pm_wt_for_cd' \ 680 + '--project[--project]:project:_pm_projects' \ 681 + '--wt[--wt]:wt:_pm_wt_for_cd' \ 682 + '--help[Display this message and exit.]' \ 683 + '-h[Display this message and exit.]' \ 684 + '--version[Display application version.]' 685 + 686 + ;; 506 687 check) 507 688 _arguments \ 508 689 '--fix[--fix]' \ 509 690 '--no-fix[--fix]' \ 691 + '--json[--json]' \ 510 692 '--help[Display this message and exit.]' \ 511 693 '-h[Display this message and exit.]' \ 512 694 '--version[Display application version.]' ··· 571 753 _pm_wt_attachable() { _pm_wt_list unattached } 572 754 _pm_wt_detachable() { _pm_wt_list attached } 573 755 _pm_wt_all() { _pm_wt_list all } 756 + 757 + _pm_wt_for_cd() { 758 + # `pm cd <project> <wt>` — read the project from the first positional 759 + # ($words[1]=pm, $words[2]=cd, $words[3]=<project>). Falls back to the 760 + # --project flag if someone wrote it that way. 761 + local project=${words[3]:-} 762 + if [[ -z "$project" || "$project" == --* || "$project" == -* ]]; then 763 + project=$(_pm_extract_flag --project -p) 764 + fi 765 + if [[ -z "$project" ]]; then 766 + _message -e worktrees "specify a project first" 767 + return 1 768 + fi 769 + local -a items 770 + items=(${(f)"$(command pm __complete worktrees --state all --project "$project" 2>/dev/null)"}) 771 + if (( ${#items} == 0 )); then 772 + _message -e worktrees "no worktrees in project '$project'" 773 + return 1 774 + fi 775 + _describe -t worktrees 'worktree' items 776 + } 574 777 575 778 _pm "$@" 576 779
+1
src/project_manager/cli/__init__.py
··· 18 18 from project_manager.stacker.commands import stacker_app as _stacker_app # noqa: F401 19 19 20 20 from . import _complete as _complete # registers `__complete` 21 + from . import cd as _cd # noqa: F401 — registers the `cd` command 21 22 from . import check as _check # noqa: F401 — registers the `check` command 22 23 from ._shared import fail, root 23 24
+27
src/project_manager/cli/_complete.py
··· 142 142 _pm_wt_detachable() { _pm_wt_list attached } 143 143 _pm_wt_all() { _pm_wt_list all } 144 144 145 + _pm_wt_for_cd() { 146 + # `pm cd <project> <wt>` — read the project from the first positional 147 + # ($words[1]=pm, $words[2]=cd, $words[3]=<project>). Falls back to the 148 + # --project flag if someone wrote it that way. 149 + local project=${words[3]:-} 150 + if [[ -z "$project" || "$project" == --* || "$project" == -* ]]; then 151 + project=$(_pm_extract_flag --project -p) 152 + fi 153 + if [[ -z "$project" ]]; then 154 + _message -e worktrees "specify a project first" 155 + return 1 156 + fi 157 + local -a items 158 + items=(${(f)"$(command pm __complete worktrees --state all --project "$project" 2>/dev/null)"}) 159 + if (( ${#items} == 0 )); then 160 + _message -e worktrees "no worktrees in project '$project'" 161 + return 1 162 + fi 163 + _describe -t worktrees 'worktree' items 164 + } 165 + 145 166 _pm "$@" 146 167 """ 147 168 ··· 171 192 # `pm project wt create` positional spec defaults to `<repo>` (or 172 193 # `<wt>:<repo>`). Completing repo names covers the common case. 173 194 ("'1:--spec'", "'1:repo:_pm_repos'"), 195 + # `pm cd <project> <wt>` — `'2:--wt'` only appears here, and the 196 + # `'--wt[--wt]:wt'` in `pm project create` (a comma-separated *spec*, 197 + # not an existing wt name) is shielded by the create-block mask 198 + # below, so a global rewrite is safe. 199 + ("'2:--wt'", "'2:wt:_pm_wt_for_cd'"), 200 + ("'--wt[--wt]:wt'", "'--wt[--wt]:wt:_pm_wt_for_cd'"), 174 201 ) 175 202 176 203 # --- Context-sensitive substitutions ----------------------------------------
+37
src/project_manager/cli/cd.py
··· 1 + """`pm cd` — print the path to a project (or one of its worktrees). 2 + 3 + `cd` is a shell builtin, so this command can only print the resolved 4 + path; the caller wraps it: `cd "$(pm cd myproj wt1)"`. 5 + """ 6 + from project_manager import config 7 + from project_manager.errors import ProjectError 8 + from project_manager.project import discovery 9 + 10 + from ._shared import root 11 + 12 + 13 + @root.command 14 + def cd(project: str, wt: str | None = None) -> int: 15 + """Print the path of a project, or one of its worktrees. 16 + 17 + Usage: 18 + 19 + cd "$(pm cd myproj)" 20 + cd "$(pm cd myproj wt1)" 21 + """ 22 + paths = config.load() 23 + discovery.require_project_db(paths, project) 24 + if wt is None: 25 + print(paths.project(project)) 26 + return 0 27 + rows = discovery.read_wts(paths, project) 28 + if wt not in {w for w, _, _ in rows}: 29 + raise ProjectError(f"project '{project}' has no worktree '{wt}'") 30 + forward = paths.forward(project, wt) 31 + if not forward.is_symlink(): 32 + raise ProjectError( 33 + f"worktree '{wt}' is detached; " 34 + f"run `pm project wt attach -p {project} --wt {wt}`", 35 + ) 36 + print(forward) 37 + return 0
+72
tests/cli/test_cd.py
··· 1 + """Tests for `pm cd` (see cli/cd.py).""" 2 + import pytest 3 + 4 + # Import for side-effect: registers every sub-app including `cd`. 5 + import project_manager.cli # noqa: F401 6 + from project_manager.cli import main 7 + from project_manager.paths import Paths 8 + from project_manager.project import create as create_mod 9 + from project_manager.project import detach as detach_mod 10 + from tests.helpers import git_pool 11 + 12 + 13 + def _just_repos(names: list[str]) -> list[tuple[str, str]]: 14 + return [(n, n) for n in names] 15 + 16 + 17 + def test_cd_project_only_prints_project_dir( 18 + pm_env: Paths, capsys: pytest.CaptureFixture[str], 19 + ) -> None: 20 + git_pool(pm_env, "foo", n=1) 21 + create_mod.create(pm_env, "demo", _just_repos(["foo"])) 22 + assert main(["cd", "demo"]) == 0 23 + assert capsys.readouterr().out.strip() == str(pm_env.project("demo")) 24 + 25 + 26 + def test_cd_project_and_wt_prints_forward_symlink( 27 + pm_env: Paths, capsys: pytest.CaptureFixture[str], 28 + ) -> None: 29 + git_pool(pm_env, "foo", n=1) 30 + create_mod.create(pm_env, "demo", [("foo1", "foo")]) 31 + assert main(["cd", "demo", "foo1"]) == 0 32 + assert capsys.readouterr().out.strip() == str(pm_env.forward("demo", "foo1")) 33 + 34 + 35 + def test_cd_unknown_project_errors( 36 + pm_env: Paths, # noqa: ARG001 (sets PM_CONFIG) 37 + capsys: pytest.CaptureFixture[str], 38 + ) -> None: 39 + assert main(["cd", "nope"]) == 2 40 + assert "project 'nope' does not exist" in capsys.readouterr().err 41 + 42 + 43 + def test_cd_unknown_wt_errors( 44 + pm_env: Paths, capsys: pytest.CaptureFixture[str], 45 + ) -> None: 46 + git_pool(pm_env, "foo", n=1) 47 + create_mod.create(pm_env, "demo", [("foo1", "foo")]) 48 + assert main(["cd", "demo", "missing"]) == 2 49 + assert "no worktree 'missing'" in capsys.readouterr().err 50 + 51 + 52 + def test_cd_detached_wt_errors( 53 + pm_env: Paths, capsys: pytest.CaptureFixture[str], 54 + ) -> None: 55 + git_pool(pm_env, "foo", n=1) 56 + create_mod.create(pm_env, "demo", [("foo1", "foo")]) 57 + detach_mod.detach(pm_env, "demo", ["foo1"]) 58 + assert main(["cd", "demo", "foo1"]) == 2 59 + err = capsys.readouterr().err 60 + assert "detached" in err 61 + assert "pm project wt attach" in err 62 + 63 + 64 + def test_cd_does_not_leak_path_on_error( 65 + pm_env: Paths, # noqa: ARG001 (sets PM_CONFIG) 66 + capsys: pytest.CaptureFixture[str], 67 + ) -> None: 68 + # On error nothing must hit stdout — the shell wrapper would `cd ""` 69 + # otherwise, silently leaving the user in their current dir with no 70 + # explanation. 71 + main(["cd", "nope"]) 72 + assert capsys.readouterr().out == ""