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

Configure Feed

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

style: ruff format pass over src/ and tests/

Mechanical run of `uv run ruff format src tests`. No behavior change —
just line wrapping and trailing-comma normalization that the project
hadn't been applying. Listed in `.git-blame-ignore-revs` (added next
commit) so `git blame` skips this revision.

Jordan Isaacs (May 15, 2026, 5:10 PM UTC) ffa76aaa 4aba54b2

+1611 -1117
+1
src/project_manager/agent/cli/__init__.py
··· 1 1 """`pm agent` sub-app.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.cli._shared import root
+13 -6
src/project_manager/agent/cli/ls.py
··· 1 1 """`pm agent ls`.""" 2 + 2 3 import asyncio 3 4 import sys 4 5 from typing import Annotated ··· 56 57 paths = config.load() 57 58 projects = resolve_project_scope(paths, scope) 58 59 agents = parse_agents(agent) 59 - effective_limit = limit if limit is not None else ( 60 - _DEFAULT_LIMIT_SINGLE if len(projects) == 1 else _DEFAULT_LIMIT_MULTI 60 + effective_limit = ( 61 + limit 62 + if limit is not None 63 + else (_DEFAULT_LIMIT_SINGLE if len(projects) == 1 else _DEFAULT_LIMIT_MULTI) 61 64 ) 62 65 rows = asyncio.run(ls_mod.ls(paths, projects, effective_limit, agents)) 63 66 if resume: 64 67 return _resume(rows) 65 68 render.emit_sections( 66 - ls_mod.sections(rows), ls_mod.COLUMNS, 69 + ls_mod.sections(rows), 70 + ls_mod.COLUMNS, 67 71 group=render.GroupColumn("Project"), 68 - as_json=json, shape=render.JsonShape("project", "sessions"), 72 + as_json=json, 73 + shape=render.JsonShape("project", "sessions"), 69 74 ) 70 75 return 0 71 76 ··· 84 89 render.console(stderr=True).print("no sessions to resume") 85 90 return 0 86 91 chosen = tui.pick( 87 - sections, ls_mod.COLUMNS, 92 + sections, 93 + ls_mod.COLUMNS, 88 94 group=render.GroupColumn("Project"), 89 95 header="Select a session to resume", 90 96 is_selectable=lambda r: bool(r.session_id), ··· 96 102 # process — but the ruff `return`-completeness rule doesn't see 97 103 # that, so we return an exit code for form's sake after the call. 98 104 run_mod.run( 99 - agent_name, chosen.project, 105 + agent_name, 106 + chosen.project, 100 107 run_mod.resume_args(agent_name, chosen.session_id), 101 108 ) 102 109 return 0
+8 -13
src/project_manager/agent/ls.py
··· 51 51 "agent": self.agent, 52 52 "session_id": self.session_id, 53 53 "title": self.title, 54 - "last_active": ( 55 - self.last_active.isoformat() 56 - if self.last_active is not None else None 57 - ), 54 + "last_active": (self.last_active.isoformat() if self.last_active is not None else None), 58 55 } 59 56 60 57 ··· 65 62 replaces with the standard `-` placeholder in the table. 66 63 """ 67 64 return AgentRow( 68 - project=project, agent="", session_id="", title="", last_active=None, 65 + project=project, 66 + agent="", 67 + session_id="", 68 + title="", 69 + last_active=None, 69 70 ) 70 71 71 72 ··· 87 88 # Goes through `render.format_datetime` so every listing 88 89 # command that shows a timestamp applies the same display-tz 89 90 # rule (configured via `[display].timezone`). 90 - lambda r: ( 91 - render.format_datetime(r.last_active) 92 - if r.last_active is not None else "" 93 - ), 91 + lambda r: render.format_datetime(r.last_active) if r.last_active is not None else "", 94 92 ), 95 93 ] 96 94 ··· 125 123 if not projects: 126 124 return [] 127 125 async with asyncio.TaskGroup() as tg: 128 - tasks = [ 129 - tg.create_task(_fetch_project(paths, p, limit, agents)) 130 - for p in projects 131 - ] 126 + tasks = [tg.create_task(_fetch_project(paths, p, limit, agents)) for p in projects] 132 127 return [row for t in tasks for row in t.result()] 133 128 134 129
+4 -3
src/project_manager/agent/run.py
··· 68 68 prefix = commands.get(agent.value) 69 69 if prefix is None: 70 70 raise ProjectError( 71 - f"no [agents.commands].{agent.value} configured — " 72 - f"add it to ~/.config/pm/config.toml", 71 + f"no [agents.commands].{agent.value} configured — add it to ~/.config/pm/config.toml", 73 72 ) 74 73 argv = [*shlex.split(prefix), *forwarded] 75 74 if not argv: ··· 78 77 79 78 80 79 def run( 81 - agent: AgentName, project: str | None, forwarded: tuple[str, ...], 80 + agent: AgentName, 81 + project: str | None, 82 + forwarded: tuple[str, ...], 82 83 ) -> NoReturn: 83 84 """Resolve the project, chdir, and exec into the configured agent CLI. 84 85
+3 -2
src/project_manager/agent/sources/claude.py
··· 77 77 78 78 79 79 def _collect_candidates( 80 - root: Path, owned_paths: set[Path], 80 + root: Path, 81 + owned_paths: set[Path], 81 82 ) -> list[tuple[Path, Path, float]]: 82 83 out: list[tuple[Path, Path, float]] = [] 83 84 for cwd in owned_paths: ··· 156 157 if synthetic: 157 158 stripped = synthetic.lstrip() 158 159 if stripped.startswith(_SUMMARY_PREFIX): 159 - stripped = stripped[len(_SUMMARY_PREFIX):].lstrip() 160 + stripped = stripped[len(_SUMMARY_PREFIX) :].lstrip() 160 161 collapsed = " ".join(stripped.split()) 161 162 if collapsed: 162 163 return collapsed
+3 -5
src/project_manager/agent/sources/cursor.py
··· 52 52 # newer valid session just because a cluster of empties came first. 53 53 entries: list[SessionEntry] = [] 54 54 for cwd, session_id, transcript, mtime in candidates: 55 - title = ( 56 - _read_store_name(store_index.get(session_id)) 57 - or _first_user_query(transcript) 58 - ) 55 + title = _read_store_name(store_index.get(session_id)) or _first_user_query(transcript) 59 56 if title is None: 60 57 # No human-assigned name and no user prompt in the transcript 61 58 # — the session is empty (bash-only or metadata-only). Drop ··· 76 73 77 74 78 75 def _collect_candidates( 79 - projects_root: Path, owned_paths: set[Path], 76 + projects_root: Path, 77 + owned_paths: set[Path], 80 78 ) -> list[tuple[Path, str, Path, float]]: 81 79 out: list[tuple[Path, str, Path, float]] = [] 82 80 for cwd in owned_paths:
+5 -2
src/project_manager/async_util.py
··· 20 20 21 21 22 22 async def bounded_gather( 23 - awaitables: Iterable[Awaitable[T]], *, limit: int, 23 + awaitables: Iterable[Awaitable[T]], 24 + *, 25 + limit: int, 24 26 ) -> list[T]: 25 27 """Run awaitables concurrently with at most `limit` in flight. 26 28 ··· 63 65 """ 64 66 item_list = list(items) 65 67 results = await bounded_gather( 66 - (fn(item) for item in item_list), limit=limit, 68 + (fn(item) for item in item_list), 69 + limit=limit, 67 70 ) 68 71 return dict(zip(item_list, results, strict=True))
+21 -17
src/project_manager/check.py
··· 14 14 15 15 16 16 class Kind(StrEnum): 17 - ACTIVE = "active" # db row + forward + pool row aligned 18 - DETACHED = "detached" # db row, no forward, no pool row 19 - DRIFT = "drift" # forward points at slot != db's remembered uuid 20 - STALE = "stale" # forward exists; pool db row missing or wrong owner 21 - BROKEN = "broken" # forward points at a missing slot dir 17 + ACTIVE = "active" # db row + forward + pool row aligned 18 + DETACHED = "detached" # db row, no forward, no pool row 19 + DRIFT = "drift" # forward points at slot != db's remembered uuid 20 + STALE = "stale" # forward exists; pool db row missing or wrong owner 21 + BROKEN = "broken" # forward points at a missing slot dir 22 22 ORPHAN_FORWARD = "orphan-forward" # forward exists, no db row 23 - ORPHAN_OWNER = "orphan-owner" # project-owned pool row with no backing forward 24 - OPS_OWNED = "ops-owned" # stacker-owned slot mid-op (resumable state present) 25 - STALE_OPS = "stale-ops" # stacker-owned slot with a clean worktree — leaked 23 + ORPHAN_OWNER = "orphan-owner" # project-owned pool row with no backing forward 24 + OPS_OWNED = "ops-owned" # stacker-owned slot mid-op (resumable state present) 25 + STALE_OPS = "stale-ops" # stacker-owned slot with a clean worktree — leaked 26 26 27 27 28 28 @dataclass(frozen=True) 29 29 class Finding: 30 30 kind: Kind 31 - wt: str | None # worktree name (symlink name under projects/<project>/) 31 + wt: str | None # worktree name (symlink name under projects/<project>/) 32 32 repo: str | None # pool repo key (may be None when unknowable, e.g. bare orphan forward) 33 33 slot_path: Path | None 34 34 forward_path: Path | None ··· 134 134 135 135 136 136 def _orphan_forwards( 137 - paths: Paths, project: str, db_wts: set[str], 137 + paths: Paths, 138 + project: str, 139 + db_wts: set[str], 138 140 ) -> Iterator[Finding]: 139 141 project_dir = paths.project(project) 140 142 for entry in sorted(project_dir.iterdir()): ··· 208 210 slot_path=slot_path, 209 211 forward_path=None, 210 212 detail=( 211 - f"pool row owned by {owner.kind.value}:{owner.id} " 212 - "but not backed by a live forward" 213 + f"pool row owned by {owner.kind.value}:{owner.id} but not backed by a live forward" 213 214 ), 214 215 ) 215 216 216 217 217 218 def _project_findings( 218 - paths: Paths, pooldb: PoolDB, project: str, 219 + paths: Paths, 220 + pooldb: PoolDB, 221 + project: str, 219 222 ) -> tuple[list[Finding], set[tuple[str, str, str]]]: 220 223 """Row findings + orphan_forwards for one project, plus legit (project, repo, uuid) triples.""" 221 224 db_path = discovery.require_project_db(paths, project) ··· 239 242 ), 240 243 ) 241 244 continue 242 - row_findings = list( 243 - _row_findings(paths, pooldb, project, (wt, repo, slot_uuid)) 244 - ) 245 + row_findings = list(_row_findings(paths, pooldb, project, (wt, repo, slot_uuid))) 245 246 findings.extend(row_findings) 246 247 for rf in row_findings: 247 248 # ACTIVE and DRIFT both indicate the pool row is legitimately backing ··· 257 258 258 259 259 260 def check_project( 260 - paths: Paths, project: str, *, include_orphan_owners: bool = True, 261 + paths: Paths, 262 + project: str, 263 + *, 264 + include_orphan_owners: bool = True, 261 265 ) -> list[Finding]: 262 266 """All findings for a single project. 263 267
+1
src/project_manager/cli/__init__.py
··· 5 5 `ProjectError` / `CommandError` (the two error types commands raise) into 6 6 the standard `pm: <msg>` stderr line and exit code 2. 7 7 """ 8 + 8 9 import sys 9 10 10 11 from project_manager.agent.cli import agent_app as _agent_app # noqa: F401
+2 -2
src/project_manager/cli/_complete.py
··· 13 13 bodies below collapse into plain `() -> list[str]` functions attached via 14 14 `Parameter(completion=...)` and this sub-app can be deleted. 15 15 """ 16 + 16 17 import re 17 18 from contextlib import suppress 18 19 from typing import Literal ··· 265 266 m = _PROJECT_CREATE_BLOCK.search(script) 266 267 if m is None: 267 268 warn.append( 268 - "project-level `create)` block not found; --project " 269 - "autocompletion may leak into it", 269 + "project-level `create)` block not found; --project autocompletion may leak into it", 270 270 ) 271 271 else: 272 272 stash = m.group(0)
+2 -3
src/project_manager/cli/_params.py
··· 22 22 they're pure CLI-layer helpers — they only translate `flag → name` and 23 23 have no other consumers. 24 24 """ 25 + 25 26 from dataclasses import dataclass 26 27 from typing import Annotated 27 28 ··· 75 76 return repo 76 77 here = locate.slot_for_cwd(paths) 77 78 if here is None: 78 - raise git.GitError( 79 - "pass --repo <name> (or run from inside a pm worktree slot)." 80 - ) 79 + raise git.GitError("pass --repo <name> (or run from inside a pm worktree slot).") 81 80 return here.repo_name 82 81 83 82
+5 -2
src/project_manager/cli/_shared.py
··· 7 7 these compose against live in `project_manager.cli._params`. 8 8 - `fail` renders the consistent `pm: <msg>` error line used everywhere. 9 9 """ 10 + 10 11 import sys 11 12 from dataclasses import dataclass 12 13 from typing import Annotated ··· 43 44 """`--wt <a,b>` or `--all`; exactly one must be set (enforced in body).""" 44 45 45 46 wt: Annotated[ 46 - str | None, Parameter(help="comma-separated worktree names"), 47 + str | None, 48 + Parameter(help="comma-separated worktree names"), 47 49 ] = None 48 50 all: Annotated[ 49 - bool, Parameter(name="--all", negative="", help="every worktree in the project"), 51 + bool, 52 + Parameter(name="--all", negative="", help="every worktree in the project"), 50 53 ] = False 51 54 52 55
+2 -2
src/project_manager/cli/cd.py
··· 5 5 `integrations/pm-cd.zsh` shell wrapper, which captures stdout and runs 6 6 `builtin cd`. `--print` opts out of that wrapper for scripting. 7 7 """ 8 + 8 9 from typing import Annotated 9 10 10 11 from cyclopts import Parameter ··· 48 49 forward = paths.forward(project, wt) 49 50 if not forward.is_symlink(): 50 51 raise ProjectError( 51 - f"worktree '{wt}' is detached; " 52 - f"run `pm project wt attach -p {project} --wt {wt}`", 52 + f"worktree '{wt}' is detached; run `pm project wt attach -p {project} --wt {wt}`", 53 53 ) 54 54 print(forward) 55 55 return 0
+1
src/project_manager/cli/check.py
··· 1 1 """`pm check` — top-level invariant checker.""" 2 + 2 3 import sys 3 4 from typing import Annotated 4 5
+1 -2
src/project_manager/config.py
··· 169 169 for key, value in commands_raw.items(): 170 170 if not isinstance(value, str): 171 171 raise ValueError( # noqa: TRY004 172 - f"[agents.commands].{key} must be a string, " 173 - f"got {type(value).__name__}", 172 + f"[agents.commands].{key} must be a string, got {type(value).__name__}", 174 173 ) 175 174 commands[str(key)] = value 176 175 return Agents(commands=commands)
+1
src/project_manager/pool/cli/__init__.py
··· 1 1 """`pm pool` sub-app.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.cli._shared import root
+1
src/project_manager/pool/cli/add.py
··· 1 1 """`pm pool add`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+5 -2
src/project_manager/pool/cli/ls.py
··· 1 1 """`pm pool ls`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 20 21 paths = config.load() 21 22 rows = ls_mod.ls(paths, repo) 22 23 render.emit_sections( 23 - ls_mod.sections(rows), ls_mod.COLUMNS, 24 + ls_mod.sections(rows), 25 + ls_mod.COLUMNS, 24 26 group=render.GroupColumn("Repo"), 25 - as_json=json, shape=render.JsonShape("repo", "slots"), 27 + as_json=json, 28 + shape=render.JsonShape("repo", "slots"), 26 29 ) 27 30 return 0
+2 -5
src/project_manager/pool/db.py
··· 72 72 def get_owner(self, repo: str, uuid: str) -> Owner | None: 73 73 with self.connect() as conn: 74 74 row = conn.execute( 75 - "SELECT owner_kind, owner_id FROM slot_ownership " 76 - "WHERE repo = ? AND uuid = ?", 75 + "SELECT owner_kind, owner_id FROM slot_ownership WHERE repo = ? AND uuid = ?", 77 76 (repo, uuid), 78 77 ).fetchone() 79 78 if row is None: ··· 83 82 def is_free(self, repo: str, uuid: str) -> bool: 84 83 return self.get_owner(repo, uuid) is None 85 84 86 - def list_owned( 87 - self, repo: str | None = None 88 - ) -> list[tuple[str, str, Owner]]: 85 + def list_owned(self, repo: str | None = None) -> list[tuple[str, str, Owner]]: 89 86 query = "SELECT repo, uuid, owner_kind, owner_id FROM slot_ownership" 90 87 params: tuple[str, ...] = () 91 88 if repo is not None:
+1 -3
src/project_manager/pool/ls.py
··· 47 47 else: 48 48 if not paths.worktrees.is_dir(): 49 49 return [] 50 - repos = sorted( 51 - entry.name for entry in paths.worktrees.iterdir() if entry.is_dir() 52 - ) 50 + repos = sorted(entry.name for entry in paths.worktrees.iterdir() if entry.is_dir()) 53 51 54 52 pooldb = PoolDB(paths.pool_db()) 55 53 rows: list[PoolRow] = []
+19 -15
src/project_manager/project/attach.py
··· 53 53 known = {name: (repo, uuid) for name, repo, uuid in all_rows} 54 54 missing = [w for w in wts if w not in known] 55 55 if missing: 56 - raise ProjectError( 57 - f"project '{project}' has no such worktree(s): {', '.join(missing)}" 58 - ) 56 + raise ProjectError(f"project '{project}' has no such worktree(s): {', '.join(missing)}") 59 57 return [(w, *known[w]) for w in wts] 60 58 61 59 62 60 def _try_reclaim( 63 - paths: Paths, pooldb: PoolDB, owner: Owner, repo: str, slot_uuid: str, 61 + paths: Paths, 62 + pooldb: PoolDB, 63 + owner: Owner, 64 + repo: str, 65 + slot_uuid: str, 64 66 ) -> Slot | None: 65 67 """Attempt to reclaim the remembered slot. Returns the claimed Slot, or None if unavailable.""" 66 68 slot_path = paths.slot(repo, slot_uuid) ··· 76 78 77 79 78 80 def _claim_fallback( 79 - paths: Paths, pooldb: PoolDB, owner: Owner, repo: str, retries: int = 3, 81 + paths: Paths, 82 + pooldb: PoolDB, 83 + owner: Owner, 84 + repo: str, 85 + retries: int = 3, 80 86 ) -> Slot: 81 87 for _ in range(retries): 82 88 free = slot_mod.free_slots(paths, pooldb, repo) ··· 111 117 existing = ctx.pooldb.get_owner(repo, target.name) 112 118 if existing == owner: 113 119 return None # already active (idempotent) 114 - raise ProjectError( 115 - f"{forward} exists but is not owned by this project — run `pm check`" 116 - ) 120 + raise ProjectError(f"{forward} exists but is not owned by this project — run `pm check`") 117 121 118 122 s = _try_reclaim(ctx.paths, ctx.pooldb, owner, repo, remembered_uuid) 119 123 if s is None: ··· 154 158 f"{outcome.conflict_path}; slot left on default" 155 159 ) 156 160 elif outcome.result == RestoreResult.SKIPPED_MISSING_BRANCH: 157 - warning = ( 158 - f"{wt}: saved branch '{saved}' no longer exists; " 159 - f"slot left on default" 160 - ) 161 + warning = f"{wt}: saved branch '{saved}' no longer exists; slot left on default" 161 162 db.set_branch(ctx.conn, wt, None) 162 163 return warning 163 164 ··· 196 197 s = _attach_one(ctx, project, wt, repo, remembered) 197 198 if s is not None: 198 199 warning = _maybe_restore_branch( 199 - ctx, wt, repo, s.path, no_branch=no_branch, 200 + ctx, 201 + wt, 202 + repo, 203 + s.path, 204 + no_branch=no_branch, 200 205 ) 201 206 if warning: 202 207 warnings.append(warning) ··· 210 215 raise 211 216 return AttachResult( 212 217 newly_claimed=[ 213 - AttachedWt(wt=wt, repo=repo, uuid=s.uuid, path=s.path) 214 - for wt, repo, s in attached 218 + AttachedWt(wt=wt, repo=repo, uuid=s.uuid, path=s.path) for wt, repo, s in attached 215 219 ], 216 220 warnings=warnings, 217 221 )
+4 -4
src/project_manager/project/branch.py
··· 48 48 if git.has_tracked_changes(slot_path): 49 49 return "has uncommitted changes; commit or discard them before detaching" 50 50 if git.has_untracked_files(slot_path): 51 - return ( 52 - "has untracked files; commit, remove, or gitignore them before detaching" 53 - ) 51 + return "has untracked files; commit, remove, or gitignore them before detaching" 54 52 return None 55 53 56 54 ··· 65 63 66 64 67 65 def _branch_in_use_elsewhere( 68 - main_repo: Path, branch: str, except_path: Path, 66 + main_repo: Path, 67 + branch: str, 68 + except_path: Path, 69 69 ) -> Path | None: 70 70 except_resolved = except_path.resolve() 71 71 for info in git.worktree_list(main_repo):
+1
src/project_manager/project/cli/__init__.py
··· 1 1 """`pm project` sub-app.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.cli._shared import root
+6 -3
src/project_manager/project/cli/_plan_printers.py
··· 1 1 """Shared dry-run plan printers for project/wt delete and detach.""" 2 + 2 3 import sys 3 4 4 5 from project_manager import render ··· 20 21 21 22 22 23 def emit_delete_plan( 23 - plan: delete_mod.DeletePlan, paths: Paths, *, as_json: bool, 24 + plan: delete_mod.DeletePlan, 25 + paths: Paths, 26 + *, 27 + as_json: bool, 24 28 ) -> None: 25 29 if as_json: 26 30 render.emit_json(plan.__pm_json__()) ··· 32 36 if plan.drop_rows: 33 37 print() 34 38 render.console().print( 35 - f"Would drop {len(plan.drop_rows)} db row(s): " 36 - + ", ".join(plan.drop_rows), 39 + f"Would drop {len(plan.drop_rows)} db row(s): " + ", ".join(plan.drop_rows), 37 40 ) 38 41 extras: list[str] = [] 39 42 if plan.remove_readme:
+1
src/project_manager/project/cli/create.py
··· 1 1 """`pm project create`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/project/cli/delete.py
··· 1 1 """`pm project delete`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+5 -2
src/project_manager/project/cli/ls.py
··· 1 1 """`pm project ls`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 20 21 rows = ls_mod.ls(paths) 21 22 projects = [name for name, _ in discovery.list_project_dbs(paths)] 22 23 render.emit_sections( 23 - ls_mod.sections(rows, projects), ls_mod.COLUMNS, 24 + ls_mod.sections(rows, projects), 25 + ls_mod.COLUMNS, 24 26 group=render.GroupColumn("Project"), 25 - as_json=json, shape=render.JsonShape("project", "worktrees"), 27 + as_json=json, 28 + shape=render.JsonShape("project", "worktrees"), 26 29 ) 27 30 return 0
+5 -6
src/project_manager/project/cli/status.py
··· 1 1 """`pm project status`.""" 2 + 2 3 import asyncio 3 4 from typing import Annotated 4 5 ··· 35 36 for p in pieces: 36 37 if p not in valid: 37 38 raise ValueError( 38 - f"unknown section {p!r} — pick from " 39 - f"{', '.join(sorted(valid))}", 39 + f"unknown section {p!r} — pick from {', '.join(sorted(valid))}", 40 40 ) 41 41 out.add(StatusSection(p)) 42 42 return frozenset(out) ··· 129 129 asyncio.run( 130 130 agent_ls.ls(paths, [resolved], _SESSIONS_LIMIT, frozenset(REGISTRY)), 131 131 ) 132 - if StatusSection.SESSIONS in selected else [] 132 + if StatusSection.SESSIONS in selected 133 + else [] 133 134 ) 134 135 if json: 135 136 _emit_json(ps, sessions_rows, selected) ··· 139 140 # for — a sessions-only call shouldn't fail the shell pipeline just 140 141 # because some unrelated worktree is in DRIFT. 141 142 if StatusSection.WORKTREES in selected: 142 - non_healthy = [ 143 - r for r in ps.worktrees if r.finding.kind != check_mod.Kind.ACTIVE 144 - ] 143 + non_healthy = [r for r in ps.worktrees if r.finding.kind != check_mod.Kind.ACTIVE] 145 144 return 1 if non_healthy else 0 146 145 return 0
+1
src/project_manager/project/cli/wt/__init__.py
··· 1 1 """`pm project wt` sub-sub-app.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.project.cli import project_app
+5 -1
src/project_manager/project/cli/wt/attach.py
··· 1 1 """`pm project wt attach`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 26 27 paths = config.load() 27 28 project = current.resolve_project(paths, flag.project) 28 29 result = attach_mod.attach( 29 - paths, project, selected_wts(sel), no_branch=no_branch, 30 + paths, 31 + project, 32 + selected_wts(sel), 33 + no_branch=no_branch, 30 34 ) 31 35 err = render.console(stderr=True) 32 36 for warning in result.warnings:
+1
src/project_manager/project/cli/wt/create.py
··· 1 1 """`pm project wt create`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/project/cli/wt/delete.py
··· 1 1 """`pm project wt delete`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/project/cli/wt/detach.py
··· 1 1 """`pm project wt detach`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+9 -5
src/project_manager/project/create.py
··· 54 54 55 55 56 56 def _claim_any_free( 57 - paths: Paths, pooldb: PoolDB, owner: Owner, repo: str, retries: int = 3, 57 + paths: Paths, 58 + pooldb: PoolDB, 59 + owner: Owner, 60 + repo: str, 61 + retries: int = 3, 58 62 ) -> Slot: 59 63 for _ in range(retries): 60 64 free = slot_mod.free_slots(paths, pooldb, repo) ··· 96 100 97 101 98 102 def create( 99 - paths: Paths, project: str, wts: list[tuple[str, str]], 103 + paths: Paths, 104 + project: str, 105 + wts: list[tuple[str, str]], 100 106 ) -> list[CreatedWt]: 101 107 """Create a project (or add worktrees to an existing one). 102 108 ··· 123 129 ctx = _CreateCtx(paths=paths, pooldb=pooldb, conn=conn) 124 130 for wt, repo in wts: 125 131 s = _claim_one(ctx, project, wt, repo) 126 - claimed.append( 127 - CreatedWt(wt=wt, repo=s.repo, uuid=s.uuid, path=s.path) 128 - ) 132 + claimed.append(CreatedWt(wt=wt, repo=s.repo, uuid=s.uuid, path=s.path)) 129 133 except BaseException: 130 134 for c in reversed(claimed): 131 135 forward = paths.forward(project, c.wt)
+2 -6
src/project_manager/project/db.py
··· 36 36 """ 37 37 tables = { 38 38 row[0] 39 - for row in conn.execute( 40 - "SELECT name FROM sqlite_master WHERE type='table'" 41 - ).fetchall() 39 + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() 42 40 } 43 41 if "worktrees" in tables: 44 42 return ··· 84 82 85 83 86 84 def get_slot(conn: sqlite3.Connection, wt: str) -> str | None: 87 - row = conn.execute( 88 - "SELECT slot_uuid FROM worktrees WHERE name = ?", (wt,) 89 - ).fetchone() 85 + row = conn.execute("SELECT slot_uuid FROM worktrees WHERE name = ?", (wt,)).fetchone() 90 86 return row[0] if row is not None else None 91 87 92 88
+10 -9
src/project_manager/project/delete.py
··· 45 45 46 46 47 47 def _delete_wts( 48 - paths: Paths, project: str, wts: list[str], db_path: Path, 48 + paths: Paths, 49 + project: str, 50 + wts: list[str], 51 + db_path: Path, 49 52 ) -> None: 50 53 """Per-wt delete helper. Detach each wt (cleanliness-checked), drop rows.""" 51 54 with db.transaction(db_path) as conn: 52 55 known = {name for name, _, _ in db.list_wts(conn)} 53 56 missing = [w for w in wts if w not in known] 54 57 if missing: 55 - raise ProjectError( 56 - f"project '{project}' has no such worktree(s): {', '.join(missing)}" 57 - ) 58 + raise ProjectError(f"project '{project}' has no such worktree(s): {', '.join(missing)}") 58 59 detach_mod.detach(paths, project, wts=wts) 59 60 for w in wts: 60 61 db.remove_wt(conn, w) ··· 64 65 extras = [ 65 66 str(entry) 66 67 for entry in project_dir.iterdir() 67 - if entry.name not in _ALLOWED_EXTRAS 68 - and not _is_pm_symlink(entry, paths.worktrees) 68 + if entry.name not in _ALLOWED_EXTRAS and not _is_pm_symlink(entry, paths.worktrees) 69 69 ] 70 70 if extras: 71 71 raise ProjectError( ··· 118 118 119 119 120 120 def plan_delete( 121 - paths: Paths, project: str, wts: list[str] | None, 121 + paths: Paths, 122 + project: str, 123 + wts: list[str] | None, 122 124 ) -> DeletePlan: 123 125 """Describe what `delete` would do without mutating state.""" 124 126 project_dir = paths.project(project) ··· 132 134 extras = [ 133 135 entry 134 136 for entry in project_dir.iterdir() 135 - if entry.name not in _ALLOWED_EXTRAS 136 - and not _is_pm_symlink(entry, paths.worktrees) 137 + if entry.name not in _ALLOWED_EXTRAS and not _is_pm_symlink(entry, paths.worktrees) 137 138 ] 138 139 with db.readonly(db_path) as conn: 139 140 planned_wts = [name for name, _, _ in db.list_wts(conn)]
+15 -9
src/project_manager/project/detach.py
··· 74 74 75 75 if pooldb.get_owner(repo, uuid) == owner: 76 76 pooldb.release(repo, uuid) 77 - released.append( 78 - DetachedWt(wt=wt, repo=repo, uuid=uuid, path=target) 79 - ) 77 + released.append(DetachedWt(wt=wt, repo=repo, uuid=uuid, path=target)) 80 78 with contextlib.suppress(FileNotFoundError): 81 79 forward.unlink() 82 80 return released ··· 147 145 148 146 149 147 def plan_detach( 150 - paths: Paths, project: str, wts: list[str] | None, 148 + paths: Paths, 149 + project: str, 150 + wts: list[str] | None, 151 151 ) -> DetachPlan: 152 152 """Describe what `detach` would do without mutating state. 153 153 ··· 167 167 known = {w: r for w, r, _ in all_rows} 168 168 missing = [w for w in wts if w not in known] 169 169 if missing: 170 - raise ProjectError( 171 - f"project '{project}' has no such worktree(s): {', '.join(missing)}" 172 - ) 170 + raise ProjectError(f"project '{project}' has no such worktree(s): {', '.join(missing)}") 173 171 to_plan = [(w, known[w]) for w in wts] 174 172 175 173 actions: list[DetachAction] = [] ··· 178 176 if not forward.is_symlink(): 179 177 actions.append( 180 178 DetachAction( 181 - wt=wt, repo=repo, kind="noop", slot_uuid=None, blocker=None, 179 + wt=wt, 180 + repo=repo, 181 + kind="noop", 182 + slot_uuid=None, 183 + blocker=None, 182 184 ) 183 185 ) 184 186 continue ··· 199 201 blocker = branch_mod.cleanliness_blocker(slot_path) 200 202 actions.append( 201 203 DetachAction( 202 - wt=wt, repo=repo, kind="detach", slot_uuid=uuid, blocker=blocker, 204 + wt=wt, 205 + repo=repo, 206 + kind="detach", 207 + slot_uuid=uuid, 208 + blocker=blocker, 203 209 ) 204 210 ) 205 211 return DetachPlan(project=project, actions=actions)
+1 -6
src/project_manager/project/ls.py
··· 103 103 findings = check_mod.check_project(paths, project, include_orphan_owners=False) 104 104 for f in findings: 105 105 label = _KIND_TO_LABEL.get(f.kind) 106 - if ( 107 - label is None 108 - or f.wt is None 109 - or f.repo is None 110 - or f.slot_path is None 111 - ): 106 + if label is None or f.wt is None or f.repo is None or f.slot_path is None: 112 107 continue 113 108 rows.append( 114 109 ProjectRow(
+1
src/project_manager/project/spec.py
··· 1 1 """Pure parsers for `pm project` worktree specs (CLI-independent).""" 2 + 2 3 from project_manager.errors import ProjectError 3 4 4 5
+9 -10
src/project_manager/project/status.py
··· 285 285 if finding.slot_path is None: 286 286 return None 287 287 live = repo_branches.setdefault( 288 - finding.repo, _live_branches(paths, finding.repo), 288 + finding.repo, 289 + _live_branches(paths, finding.repo), 289 290 ) 290 291 try: 291 292 resolved = finding.slot_path.resolve() ··· 421 422 repo = repo or row_repo 422 423 branch = _resolve_branch(f, repo_branches, paths, project) 423 424 pr = pr_map.get((repo, branch)) if repo and branch else None 424 - tracked_entry = ( 425 - tracked_by_key.get((repo, branch)) if repo and branch else None 426 - ) 425 + tracked_entry = tracked_by_key.get((repo, branch)) if repo and branch else None 427 426 if want_worktrees: 428 427 worktrees.append( 429 428 WorktreeRow( ··· 436 435 ), 437 436 ) 438 437 if ( 439 - want_prs and pr is not None and f.wt is not None 440 - and repo is not None and branch is not None 438 + want_prs 439 + and pr is not None 440 + and f.wt is not None 441 + and repo is not None 442 + and branch is not None 441 443 ): 442 444 prs.append(PRRow(wt=f.wt, repo=repo, branch=branch, pr=pr)) 443 445 if want_stacker and tracked_entry is not None and repo is not None: ··· 448 450 if want_stacker and f.wt is not None and repo is not None and branch is not None: 449 451 wt_labels[(repo, branch)] = f.wt 450 452 prs.sort(key=lambda r: (r.repo, r.wt)) 451 - stacker = ( 452 - _gather_stacker(ctx, paths, tracked_per_repo, wt_labels) 453 - if want_stacker else [] 454 - ) 453 + stacker = _gather_stacker(ctx, paths, tracked_per_repo, wt_labels) if want_stacker else [] 455 454 return ProjectStatus(worktrees=worktrees, prs=prs, stacker=stacker)
+38 -26
src/project_manager/render.py
··· 60 60 markup: bool = False 61 61 62 62 def render_cell(self, row: T) -> str: 63 - raw = ( 64 - getattr(row, self.value) if isinstance(self.value, str) 65 - else self.value(row) 66 - ) 63 + raw = getattr(row, self.value) if isinstance(self.value, str) else self.value(row) 67 64 if raw is None or raw == "": 68 65 return self.empty 69 66 return str(raw) ··· 136 133 default; callers opt into markup via `print(..., markup=True)`. 137 134 """ 138 135 return Console( 139 - stderr=stderr, highlight=False, markup=False, soft_wrap=True, 136 + stderr=stderr, 137 + highlight=False, 138 + markup=False, 139 + soft_wrap=True, 140 140 ) 141 141 142 142 ··· 170 170 171 171 JSON shape: `[{shape.group_key: title, shape.items_key: [raw_row...]}]`. 172 172 """ 173 - materialized: list[tuple[str, list[T]]] = [ 174 - (s.title, list(s.rows)) for s in sections 175 - ] 173 + materialized: list[tuple[str, list[T]]] = [(s.title, list(s.rows)) for s in sections] 176 174 if as_json: 177 - console().print_json(data=[ 178 - { 179 - shape.group_key: title, 180 - shape.items_key: [_default_to_dict(r) for r in rows], 181 - } 182 - for title, rows in materialized 183 - ]) 175 + console().print_json( 176 + data=[ 177 + { 178 + shape.group_key: title, 179 + shape.items_key: [_default_to_dict(r) for r in rows], 180 + } 181 + for title, rows in materialized 182 + ] 183 + ) 184 184 return 185 185 if not materialized: 186 186 return ··· 217 217 """ 218 218 flat_rows = [r for _, rs in materialized for r in rs] 219 219 other_widths = _compute_widths(flat_rows, columns) 220 - group_width = max( 221 - len(group.title), 222 - *(len(title) for title, _ in materialized), 223 - ) if materialized else len(group.title) 220 + group_width = ( 221 + max( 222 + len(group.title), 223 + *(len(title) for title, _ in materialized), 224 + ) 225 + if materialized 226 + else len(group.title) 227 + ) 224 228 t = Table( 225 - box=box.HORIZONTALS, show_edge=False, pad_edge=False, padding=(0, 2), 229 + box=box.HORIZONTALS, 230 + show_edge=False, 231 + pad_edge=False, 232 + padding=(0, 2), 226 233 collapse_padding=selected_flat_idx is not None, 227 - show_header=True, header_style="dim", 234 + show_header=True, 235 + header_style="dim", 228 236 ) 229 237 t.add_column(group.title, min_width=group_width) 230 238 for i, col in enumerate(columns): ··· 254 262 t.add_row( 255 263 Text( 256 264 title if i == 0 else "", 257 - style=_selected_style(group.style) if is_selected 258 - else group.style, 265 + style=_selected_style(group.style) if is_selected else group.style, 259 266 ), 260 267 *( 261 268 Text( 262 269 col.render_cell(r), 263 270 style=_selected_style(col.render_style(r)) 264 - if is_selected else (col.render_style(r) or ""), 271 + if is_selected 272 + else (col.render_style(r) or ""), 265 273 ) 266 274 for col in columns 267 275 ), ··· 417 425 columns: list[Column[T]], 418 426 ) -> None: 419 427 t = Table( 420 - box=box.HORIZONTALS, show_edge=False, pad_edge=False, padding=(0, 2), 421 - show_header=True, header_style="dim", 428 + box=box.HORIZONTALS, 429 + show_edge=False, 430 + pad_edge=False, 431 + padding=(0, 2), 432 + show_header=True, 433 + header_style="dim", 422 434 ) 423 435 for col in columns: 424 436 t.add_column(col.title)
+1
src/project_manager/repo/cli/__init__.py
··· 1 1 """`pm repo` sub-app.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.cli._shared import root
+1
src/project_manager/repo/cli/ls.py
··· 1 1 """`pm repo ls`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/repo/cli/maintenance.py
··· 1 1 """`pm repo maintenance`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/repo/cli/pull.py
··· 1 1 """`pm repo pull`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+7 -13
src/project_manager/repo/git.py
··· 35 35 subsequent pull of the parent. 36 36 """ 37 37 result = run( 38 - ["git", "-C", str(repo), "status", "--porcelain", 39 - "--ignore-submodules=all"], 38 + ["git", "-C", str(repo), "status", "--porcelain", "--ignore-submodules=all"], 40 39 check=False, 41 40 ) 42 41 return bool(result.stdout.strip()) ··· 45 44 def upstream_ref(repo: Path) -> str | None: 46 45 """Return the upstream tracking ref (e.g. 'origin/main'), or None.""" 47 46 result = run( 48 - ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", 49 - "--symbolic-full-name", "@{u}"], 47 + ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], 50 48 check=False, 51 49 ) 52 50 if result.returncode != 0: ··· 94 92 def ahead_behind(repo: Path) -> tuple[int, int] | None: 95 93 """Return (ahead, behind) vs. @{u}, or None if no upstream.""" 96 94 result = run( 97 - ["git", "-C", str(repo), "rev-list", "--left-right", "--count", 98 - "@{u}...HEAD"], 95 + ["git", "-C", str(repo), "rev-list", "--left-right", "--count", "@{u}...HEAD"], 99 96 check=False, 100 97 ) 101 98 if result.returncode != 0: ··· 121 118 def branch_exists(repo: Path, branch: str) -> bool: 122 119 """True if a local branch with this name exists.""" 123 120 result = run( 124 - ["git", "-C", str(repo), "rev-parse", "--verify", "-q", 125 - f"refs/heads/{branch}"], 121 + ["git", "-C", str(repo), "rev-parse", "--verify", "-q", f"refs/heads/{branch}"], 126 122 check=False, 127 123 ) 128 124 return result.returncode == 0 ··· 137 133 "don't know" and fall back to other heuristics. 138 134 """ 139 135 result = run( 140 - ["git", "-C", str(repo), "symbolic-ref", "--short", "-q", 141 - "refs/remotes/origin/HEAD"], 136 + ["git", "-C", str(repo), "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"], 142 137 check=False, 143 138 ) 144 139 if result.returncode != 0: ··· 146 141 ref = result.stdout.strip() 147 142 prefix = "origin/" 148 143 if ref.startswith(prefix): 149 - return ref[len(prefix):] or None 144 + return ref[len(prefix) :] or None 150 145 return ref or None 151 146 152 147 ··· 158 153 branch) — callers treat that as "unknown" rather than "up-to-date". 159 154 """ 160 155 result = run( 161 - ["git", "-C", str(repo), "ls-remote", "--heads", "origin", 162 - f"refs/heads/{branch}"], 156 + ["git", "-C", str(repo), "ls-remote", "--heads", "origin", f"refs/heads/{branch}"], 163 157 check=False, 164 158 ) 165 159 if result.returncode != 0:
+7 -13
src/project_manager/repo/git_async.py
··· 23 23 24 24 async def is_dirty(repo: Path) -> bool: 25 25 result = await run_async( 26 - ["git", "-C", str(repo), "status", "--porcelain", 27 - "--ignore-submodules=all"], 26 + ["git", "-C", str(repo), "status", "--porcelain", "--ignore-submodules=all"], 28 27 check=False, 29 28 ) 30 29 return bool(result.stdout.strip()) ··· 32 31 33 32 async def upstream_ref(repo: Path) -> str | None: 34 33 result = await run_async( 35 - ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", 36 - "--symbolic-full-name", "@{u}"], 34 + ["git", "-C", str(repo), "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], 37 35 check=False, 38 36 ) 39 37 if result.returncode != 0: ··· 62 60 63 61 async def ahead_behind(repo: Path) -> tuple[int, int] | None: 64 62 result = await run_async( 65 - ["git", "-C", str(repo), "rev-list", "--left-right", "--count", 66 - "@{u}...HEAD"], 63 + ["git", "-C", str(repo), "rev-list", "--left-right", "--count", "@{u}...HEAD"], 67 64 check=False, 68 65 ) 69 66 if result.returncode != 0: ··· 87 84 88 85 async def branch_exists(repo: Path, branch: str) -> bool: 89 86 result = await run_async( 90 - ["git", "-C", str(repo), "rev-parse", "--verify", "-q", 91 - f"refs/heads/{branch}"], 87 + ["git", "-C", str(repo), "rev-parse", "--verify", "-q", f"refs/heads/{branch}"], 92 88 check=False, 93 89 ) 94 90 return result.returncode == 0 ··· 96 92 97 93 async def origin_head_branch(repo: Path) -> str | None: 98 94 result = await run_async( 99 - ["git", "-C", str(repo), "symbolic-ref", "--short", "-q", 100 - "refs/remotes/origin/HEAD"], 95 + ["git", "-C", str(repo), "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD"], 101 96 check=False, 102 97 ) 103 98 if result.returncode != 0: ··· 105 100 ref = result.stdout.strip() 106 101 prefix = "origin/" 107 102 if ref.startswith(prefix): 108 - return ref[len(prefix):] or None 103 + return ref[len(prefix) :] or None 109 104 return ref or None 110 105 111 106 ··· 123 118 async def remote_head_sha(repo: Path, branch: str) -> str | None: 124 119 """Ask the remote for the current tip of `branch` without fetching.""" 125 120 result = await run_async( 126 - ["git", "-C", str(repo), "ls-remote", "--heads", "origin", 127 - f"refs/heads/{branch}"], 121 + ["git", "-C", str(repo), "ls-remote", "--heads", "origin", f"refs/heads/{branch}"], 128 122 check=False, 129 123 ) 130 124 if result.returncode != 0:
+5 -10
src/project_manager/repo/ls.py
··· 14 14 @dataclass(frozen=True) 15 15 class RepoRow: 16 16 repo: str 17 - branch: str | None # None == detached 17 + branch: str | None # None == detached 18 18 dirty: bool 19 - submodules: str # "none" | "synced" | "stale" 20 - ahead: int | None # None if no upstream or detached 19 + submodules: str # "none" | "synced" | "stale" 20 + ahead: int | None # None if no upstream or detached 21 21 behind: int | None 22 22 has_upstream: bool 23 23 # Tri-state: True = local @{u} matches the remote tip (nothing to fetch); ··· 120 120 t_trunk = tg.create_task(_resolve_trunk(repo_dir, configured_trunk)) 121 121 t_upstream = tg.create_task(git_async.upstream_ref(repo_dir)) 122 122 t_ab = tg.create_task(git_async.ahead_behind(repo_dir)) 123 - t_fn = ( 124 - tg.create_task(_check_fetch_needed(repo_dir, t_upstream)) 125 - if check_remote else None 126 - ) 123 + t_fn = tg.create_task(_check_fetch_needed(repo_dir, t_upstream)) if check_remote else None 127 124 branch = t_branch.result() 128 125 upstream = t_upstream.result() 129 126 has_upstream = branch is not None and upstream is not None ··· 268 265 async with asyncio.TaskGroup() as tg: 269 266 tasks = [ 270 267 tg.create_task( 271 - _row(paths, r, 272 - check_remote=check_remote, 273 - configured_trunk=trunks.get(r)), 268 + _row(paths, r, check_remote=check_remote, configured_trunk=trunks.get(r)), 274 269 ) 275 270 for r in repos 276 271 ]
+42 -22
src/project_manager/repo/maintenance.py
··· 91 91 # EOF before returning anything — adding a line-pump mode there 92 92 # would let us drop `asyncio.to_thread` and the per-job thread. 93 93 result = await asyncio.to_thread( 94 - run, job.cmd, check=False, stream=True, 94 + run, 95 + job.cmd, 96 + check=False, 97 + stream=True, 95 98 ) 96 99 ok = job.ignore_returncode or result.returncode == 0 97 100 # Many maintenance subcommands are silent on success, so fall back to 98 101 # "ok"/"exit N" when git said nothing useful. 99 102 tail = [line for line in result.stderr.splitlines() if line.strip()] 100 - message = ( 101 - tail[-1].strip() if tail 102 - else ("ok" if ok else f"exit {result.returncode}") 103 - ) 103 + message = tail[-1].strip() if tail else ("ok" if ok else f"exit {result.returncode}") 104 104 return MaintenanceResult( 105 - repo=job.repo, target=job.target, op=job.op, ok=ok, message=message, 105 + repo=job.repo, 106 + target=job.target, 107 + op=job.op, 108 + ok=ok, 109 + message=message, 106 110 ) 107 111 108 112 109 113 def _update_index_targets( 110 - paths: Paths, repo: str, 114 + paths: Paths, 115 + repo: str, 111 116 ) -> list[tuple[str, Path]]: 112 117 """`(target_label, path)` for the canonical repo + every pool slot.""" 113 118 out: list[tuple[str, Path]] = [] ··· 123 128 repo_dir = paths.repo(repo) 124 129 if repo_dir.is_dir(): 125 130 task_args = [f"--task={t}" for t in _MAINTENANCE_TASKS] 126 - jobs.append(_Job( 127 - repo=repo, target="(repo)", op="maintenance", 128 - cmd=["git", "-C", str(repo_dir), "maintenance", "run", *task_args], 129 - )) 130 - jobs.append(_Job( 131 - repo=repo, target="(repo)", op="worktree-prune", 132 - cmd=["git", "-C", str(repo_dir), "worktree", "prune"], 133 - )) 131 + jobs.append( 132 + _Job( 133 + repo=repo, 134 + target="(repo)", 135 + op="maintenance", 136 + cmd=["git", "-C", str(repo_dir), "maintenance", "run", *task_args], 137 + ) 138 + ) 139 + jobs.append( 140 + _Job( 141 + repo=repo, 142 + target="(repo)", 143 + op="worktree-prune", 144 + cmd=["git", "-C", str(repo_dir), "worktree", "prune"], 145 + ) 146 + ) 134 147 for target, path in _update_index_targets(paths, repo): 135 - jobs.append(_Job( 136 - repo=repo, target=target, op="update-index", 137 - cmd=["git", "-C", str(path), "update-index", "-q", "--refresh"], 138 - ignore_returncode=True, 139 - )) 148 + jobs.append( 149 + _Job( 150 + repo=repo, 151 + target=target, 152 + op="update-index", 153 + cmd=["git", "-C", str(path), "update-index", "-q", "--refresh"], 154 + ignore_returncode=True, 155 + ) 156 + ) 140 157 return jobs 141 158 142 159 143 160 async def _maintain_async( 144 - paths: Paths, repos: list[str], limit: int, 161 + paths: Paths, 162 + repos: list[str], 163 + limit: int, 145 164 ) -> list[MaintenanceResult]: 146 165 jobs = [j for r in repos for j in _jobs_for_repo(paths, r)] 147 166 return await bounded_gather( 148 - (_run_job(j) for j in jobs), limit=limit, 167 + (_run_job(j) for j in jobs), 168 + limit=limit, 149 169 ) 150 170 151 171
+7 -5
src/project_manager/repo/pull.py
··· 64 64 65 65 head_before = git.head_sha(repo_dir) 66 66 cmd = [ 67 - "git", "-C", str(repo_dir), "pull", "--ff-only", "--prune", 67 + "git", 68 + "-C", 69 + str(repo_dir), 70 + "pull", 71 + "--ff-only", 72 + "--prune", 68 73 "--recurse-submodules", 69 74 ] 70 75 emit_command_start(cmd) ··· 73 78 except CommandError as e: 74 79 return PullResult(repo=repo, ok=False, message=f"pull failed: {e}") 75 80 76 - base_message = ( 77 - "up-to-date" if git.head_sha(repo_dir) == head_before 78 - else "fast-forwarded" 79 - ) 81 + base_message = "up-to-date" if git.head_sha(repo_dir) == head_before else "fast-forwarded" 80 82 # `pull --recurse-submodules` updates already-populated submodules but 81 83 # won't --init new ones; run `submodule update --init --recursive` so 82 84 # newly-added submodules are checked out.
-1
src/project_manager/stacker/__init__.py
··· 1 1 """stacker package.""" 2 -
+24 -34
src/project_manager/stacker/cherry_pick/driver.py
··· 33 33 34 34 35 35 def sync_plan( 36 - ctx: StackerCtx, tracked: TrackedBranch, slot_path: Path, 36 + ctx: StackerCtx, 37 + tracked: TrackedBranch, 38 + slot_path: Path, 37 39 ) -> tuple[str, str, list[str]]: 38 40 """Plan an exact-range cherry-pick of the branch's working commits. 39 41 ··· 47 49 repo_path = ctx.paths.repo(tracked.parent_repo_name) 48 50 parent_head = git.rev_parse(repo_path, tracked.parent_branch) 49 51 start_head = git.rev_parse(slot_path, "HEAD") 50 - commit_list = git.rev_list( 51 - slot_path, f"{tracked.managed_base_commit}..{start_head}" 52 - ) 52 + commit_list = git.rev_list(slot_path, f"{tracked.managed_base_commit}..{start_head}") 53 53 return parent_head, start_head, commit_list 54 54 55 55 ··· 64 64 parent_head, start_head, commit_list = sync_plan(ctx, tracked, slot_path) 65 65 op = ctx.db.get_operation(tracked.repo_name) 66 66 if op is None: 67 - op = OperationState( 68 - repo_name=tracked.repo_name, op_type=op_type, status="running" 69 - ) 67 + op = OperationState(repo_name=tracked.repo_name, op_type=op_type, status="running") 70 68 op.status = "running" 71 69 op.branch = tracked.branch 72 70 op.parent_branch = tracked.parent_branch ··· 108 106 if op.branch: 109 107 with _owned_slot(ctx, repo_name, op, handle) as h: 110 108 result = drive_local( 111 - ctx, op, slot_path=h.path, 112 - continuing=continuing, logs=logs, 109 + ctx, 110 + op, 111 + slot_path=h.path, 112 + continuing=continuing, 113 + logs=logs, 113 114 ) 114 115 continuing = False 115 116 handle = None ··· 211 212 ctx.db.clear_operation(repo_name) 212 213 raise 213 214 collapse_msg = sync_gates.collapse_if_merged( 214 - ctx, tracked, acquired.path, 215 + ctx, 216 + tracked, 217 + acquired.path, 215 218 allow_drop_merge=op.allow_drop_merge, 216 219 ) 217 220 if collapse_msg is not None: ··· 223 226 parent_head, _, _ = sync_plan(ctx, tracked, acquired.path) 224 227 if parent_head == tracked.managed_base_commit: 225 228 child_label = selectors.selector_for(tracked.repo_name, tracked.branch) 226 - parent_label = selectors.selector_for( 227 - tracked.parent_repo_name, tracked.parent_branch 228 - ) 229 + parent_label = selectors.selector_for(tracked.parent_repo_name, tracked.parent_branch) 229 230 fmt.record( 230 231 ctx, 231 232 logs, ··· 237 238 worktree.release_if_clean(ctx, acquired) 238 239 return None 239 240 prepare_local_operation( 240 - ctx, tracked, op_type="downstream_sync", slot_path=acquired.path, 241 + ctx, 242 + tracked, 243 + op_type="downstream_sync", 244 + slot_path=acquired.path, 241 245 logs=logs, 242 246 ) 243 247 except BaseException: ··· 265 269 return finalize_local_op(ctx, op, slot_path) 266 270 267 271 268 - def finalize_local_op( 269 - ctx: StackerCtx, op: OperationState, slot_path: Path 270 - ) -> str | None: 272 + def finalize_local_op(ctx: StackerCtx, op: OperationState, slot_path: Path) -> str | None: 271 273 assert op.branch 272 274 if op.op_type == "local_absorb": 273 275 return _finalize_absorb(ctx, op, slot_path) 274 - tracked = require_tracked( 275 - ctx, SelectorTarget(repo_name=op.repo_name, branch=op.branch) 276 - ) 276 + tracked = require_tracked(ctx, SelectorTarget(repo_name=op.repo_name, branch=op.branch)) 277 277 ctx.db.upsert_branch( 278 278 TrackedBranch( 279 279 repo_name=tracked.repo_name, ··· 281 281 parent_repo_name=tracked.parent_repo_name, 282 282 parent_branch=tracked.parent_branch, 283 283 managed_base_commit=op.target_parent_head or tracked.managed_base_commit, 284 - last_synced_parent_commit=( 285 - op.target_parent_head or tracked.last_synced_parent_commit 286 - ), 284 + last_synced_parent_commit=(op.target_parent_head or tracked.last_synced_parent_commit), 287 285 last_clean_head=git.rev_parse(slot_path, "HEAD"), 288 286 ) 289 287 ) ··· 303 301 return None 304 302 305 303 306 - def _finalize_absorb( 307 - ctx: StackerCtx, op: OperationState, slot_path: Path 308 - ) -> str: 304 + def _finalize_absorb(ctx: StackerCtx, op: OperationState, slot_path: Path) -> str: 309 305 """Absorb variant: parent advanced by child's commits; child untouched. 310 306 311 307 Intentionally does NOT call `require_tracked` or `upsert_branch` with the ··· 356 352 # parent being updated, op.parent_branch is the source child. Headline 357 353 # wording follows that. 358 354 if op.op_type == "local_absorb": 359 - headline = ( 360 - f"Absorb paused on {inspect_selector} while absorbing from " 361 - f"{other_selector}." 362 - ) 355 + headline = f"Absorb paused on {inspect_selector} while absorbing from {other_selector}." 363 356 else: 364 - headline = ( 365 - f"Sync paused on {inspect_selector} while syncing onto " 366 - f"{other_selector}." 367 - ) 357 + headline = f"Sync paused on {inspect_selector} while syncing onto {other_selector}." 368 358 cherry = git.cherry_pick_in_progress(slot_path) 369 359 parts = [f"{headline}\nCherry-pick in progress: {'yes' if cherry else 'no'}"] 370 360 if op.error_message:
+3 -11
src/project_manager/stacker/cherry_pick/resume.py
··· 27 27 fmt.record(ctx, logs, f"Continuing cherry-pick in {label}") 28 28 proc = git.cherry_pick_continue(slot_path) 29 29 if proc.returncode != 0: 30 - message = ( 31 - proc.stderr.strip() 32 - or proc.stdout.strip() 33 - or "cherry-pick --continue failed" 34 - ) 30 + message = proc.stderr.strip() or proc.stdout.strip() or "cherry-pick --continue failed" 35 31 if empty.is_empty_cherry_pick_message(message): 36 32 return resume_skip_empty(ctx, op, slot_path, logs, label) 37 33 op.status = "paused" ··· 66 62 skip = git.cherry_pick_skip(slot_path) 67 63 if skip.returncode != 0: 68 64 op.status = "paused" 69 - op.error_message = ( 70 - skip.stderr.strip() or skip.stdout.strip() or "cherry-pick --skip failed" 71 - ) 65 + op.error_message = skip.stderr.strip() or skip.stdout.strip() or "cherry-pick --skip failed" 72 66 ctx.db.put_operation(op) 73 67 return driver.failure_message(op, slot_path) 74 68 fmt.record(ctx, logs, f"Skipping empty cherry-pick {fmt.short(commit)} on {label}") ··· 99 93 if proc.returncode != 0: 100 94 op.status = "paused" 101 95 op.error_message = ( 102 - proc.stderr.strip() 103 - or proc.stdout.strip() 104 - or f"cherry-pick failed for {commit}" 96 + proc.stderr.strip() or proc.stdout.strip() or f"cherry-pick failed for {commit}" 105 97 ) 106 98 ctx.db.put_operation(op) 107 99 return driver.failure_message(op, slot_path)
+1
src/project_manager/stacker/commands/__init__.py
··· 1 1 """`pm stacker` sub-app and per-command module registration.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.cli._shared import root
+10 -22
src/project_manager/stacker/commands/_common.py
··· 5 5 things remaining here all resolve/construct values the `StackerService` 6 6 needs; they know nothing about how the user invoked the command. 7 7 """ 8 + 8 9 from project_manager import render 9 10 from project_manager.cli._params import resolve_repo, resolve_repo_optional 10 11 from project_manager.cli._shared import StackerScope ··· 49 50 return branch 50 51 here = locate.slot_for_cwd(paths) 51 52 if here is None: 52 - raise git.GitError( 53 - "pass <branch> (or run from inside a pm worktree slot)." 54 - ) 53 + raise git.GitError("pass <branch> (or run from inside a pm worktree slot).") 55 54 current = git.current_branch(here.path) 56 55 if not current: 57 - raise git.GitError( 58 - f"worktree {here.path} is detached; pass <branch> explicitly." 59 - ) 56 + raise git.GitError(f"worktree {here.path} is detached; pass <branch> explicitly.") 60 57 return current 61 58 62 59 ··· 85 82 if on_spec == "current": 86 83 here = locate.slot_for_cwd(paths) 87 84 if here is None: 88 - raise git.GitError( 89 - "--on current requires running from a pm worktree slot." 90 - ) 85 + raise git.GitError("--on current requires running from a pm worktree slot.") 91 86 current = fallback_branch or git.current_branch(here.path) 92 87 if not current: 93 - raise git.GitError( 94 - "--on current: current worktree has no branch checked out." 95 - ) 88 + raise git.GitError("--on current: current worktree has no branch checked out.") 96 89 return ParentLocator(repo_name=repo_name, branch=current) 97 90 if on_spec == "parent": 98 91 here = locate.slot_for_cwd(paths) 99 92 if here is None: 100 - raise git.GitError( 101 - "--on parent requires running from a pm worktree slot." 102 - ) 93 + raise git.GitError("--on parent requires running from a pm worktree slot.") 103 94 current = git.current_branch(here.path) 104 95 if not current: 105 - raise git.GitError( 106 - "--on parent: current worktree is detached; pass a branch instead." 107 - ) 96 + raise git.GitError("--on parent: current worktree is detached; pass a branch instead.") 108 97 svc = service(paths) 109 98 tracked = svc.db.get_branch(repo_name, current) 110 99 if tracked is None: 111 - raise git.GitError( 112 - f"{current} is not tracked; cannot resolve --on parent." 113 - ) 100 + raise git.GitError(f"{current} is not tracked; cannot resolve --on parent.") 114 101 return ParentLocator( 115 - repo_name=tracked.parent_repo_name, branch=tracked.parent_branch, 102 + repo_name=tracked.parent_repo_name, 103 + branch=tracked.parent_branch, 116 104 ) 117 105 # Literal branch reference — may include cross-repo `repo:branch` form. 118 106 return selectors.resolve_parent_for_base(paths, repo_name, on_spec)
+1
src/project_manager/stacker/commands/abort.py
··· 1 1 """`pm stacker abort`.""" 2 + 2 3 from project_manager import config 3 4 from project_manager.cli._shared import RepoFlag 4 5
+1
src/project_manager/stacker/commands/absorb.py
··· 1 1 """`pm stacker absorb`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/stacker/commands/config.py
··· 1 1 """`pm stacker config` — get/set per-repo stacker config (git-config style).""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+2 -3
src/project_manager/stacker/commands/continue_.py
··· 1 1 """`pm stacker continue`.""" 2 + 2 3 from project_manager import config 3 4 from project_manager.cli._shared import RepoFlag 4 5 ··· 10 11 """Resume a paused stacker operation.""" 11 12 paths = config.load() 12 13 svc = _common.service(paths) 13 - return _common.emit( 14 - svc.continue_operation(_common.resolve_repo(flag.repo, paths)) 15 - ) 14 + return _common.emit(svc.continue_operation(_common.resolve_repo(flag.repo, paths)))
+4 -1
src/project_manager/stacker/commands/create.py
··· 1 1 """`pm stacker create`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 33 34 repo_name = _common.resolve_repo(flag.repo, paths) 34 35 on_spec = _on_spec_for_create(on, replace) 35 36 parent = _common.resolve_on_spec( 36 - paths, repo_name, on_spec, 37 + paths, 38 + repo_name, 39 + on_spec, 37 40 fallback_branch=branch if replace else None, 38 41 ) 39 42 if replace:
+1
src/project_manager/stacker/commands/guard.py
··· 1 1 """`pm stacker guard` — internal guardrails for Git hooks.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager import config
+1
src/project_manager/stacker/commands/log.py
··· 1 1 """`pm stacker log`.""" 2 + 2 3 from project_manager import config 3 4 from project_manager.cli._shared import RepoFlag 4 5
+1
src/project_manager/stacker/commands/ls.py
··· 1 1 """`pm stacker ls`.""" 2 + 2 3 from typing import Annotated, Literal 3 4 4 5 from cyclopts import Parameter
+1
src/project_manager/stacker/commands/pr/__init__.py
··· 1 1 """`pm stacker pr` — sub-app for inspecting/repairing the cached PR association.""" 2 + 2 3 from cyclopts import App 3 4 4 5 from project_manager.stacker.commands import stacker_app
+1
src/project_manager/stacker/commands/pr/refresh.py
··· 1 1 """`pm stacker pr refresh` — re-discover the GitHub PR for a tracked branch.""" 2 + 2 3 from project_manager import config, render 3 4 from project_manager.cli._shared import RepoFlag 4 5 from project_manager.stacker.commands import _common
+1
src/project_manager/stacker/commands/pr/unlink.py
··· 1 1 """`pm stacker pr unlink` — clear cached PR association for a branch.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+2 -3
src/project_manager/stacker/commands/push.py
··· 1 1 """`pm stacker push`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 35 36 publish=publish, 36 37 create_pr=create_pr, 37 38 ) 38 - return _common.emit( 39 - svc.push(_common.target(scope.repo, branch, paths), options) 40 - ) 39 + return _common.emit(svc.push(_common.target(scope.repo, branch, paths), options))
+1
src/project_manager/stacker/commands/remove.py
··· 1 1 """`pm stacker remove`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+2 -3
src/project_manager/stacker/commands/rename.py
··· 1 1 """`pm stacker rename`.""" 2 + 2 3 from project_manager import config 3 4 from project_manager.cli._shared import RepoFlag 4 5 ··· 13 14 """ 14 15 paths = config.load() 15 16 svc = _common.service(paths) 16 - return _common.emit( 17 - svc.rename(_common.target(flag.repo, branch, paths), new_name) 18 - ) 17 + return _common.emit(svc.rename(_common.target(flag.repo, branch, paths), new_name))
+2 -3
src/project_manager/stacker/commands/repair.py
··· 1 1 """`pm stacker repair`.""" 2 + 2 3 from project_manager import config 3 4 from project_manager.cli._shared import RepoFlag 4 5 ··· 18 19 """ 19 20 paths = config.load() 20 21 svc = _common.service(paths) 21 - return _common.emit( 22 - svc.repair(_common.target(flag.repo, branch, paths), base_ref) 23 - ) 22 + return _common.emit(svc.repair(_common.target(flag.repo, branch, paths), base_ref))
+7 -11
src/project_manager/stacker/commands/reparent.py
··· 1 1 """`pm stacker reparent`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 45 46 bool, 46 47 Parameter( 47 48 negative="", 48 - help="skip the PR-state refresh; use cached pr_state for the " 49 - "merged-PR collapse.", 49 + help="skip the PR-state refresh; use cached pr_state for the merged-PR collapse.", 50 50 ), 51 51 ] = False, 52 52 ) -> int: ··· 60 60 if continue_ or abort: 61 61 if allow_drop_parent_modifications or allow_drop_merge or offline: 62 62 raise git.GitError( 63 - "--allow-drop-* and --offline cannot be combined with " 64 - "--continue or --abort.", 63 + "--allow-drop-* and --offline cannot be combined with --continue or --abort.", 65 64 ) 66 65 if continue_: 67 - return _common.emit( 68 - svc.continue_operation(_common.resolve_repo(flag.repo, paths)) 69 - ) 70 - return _common.emit( 71 - svc.abort_operation(_common.resolve_repo(flag.repo, paths)) 72 - ) 66 + return _common.emit(svc.continue_operation(_common.resolve_repo(flag.repo, paths))) 67 + return _common.emit(svc.abort_operation(_common.resolve_repo(flag.repo, paths))) 73 68 if not new_parent: 74 69 raise ValueError("reparent requires <new-parent> (or --continue / --abort).") 75 70 target = _common.target(flag.repo, branch, paths) 76 71 parent = _common.resolve_on_spec(paths, target.repo_name, new_parent) 77 72 return _common.emit( 78 73 svc.reparent( 79 - target, parent, 74 + target, 75 + parent, 80 76 options=SyncOptions( 81 77 allow_drop_parent_modifications=allow_drop_parent_modifications, 82 78 allow_drop_merge=allow_drop_merge,
+1
src/project_manager/stacker/commands/split.py
··· 1 1 """`pm stacker split`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter
+4 -3
src/project_manager/stacker/commands/sync.py
··· 1 1 """`pm stacker sync`.""" 2 + 2 3 from typing import Annotated 3 4 4 5 from cyclopts import Parameter ··· 78 79 if continue_ or abort: 79 80 if allow_drop_parent_modifications or allow_drop_merge or offline: 80 81 raise git.GitError( 81 - "--allow-drop-* and --offline cannot be combined with " 82 - "--continue or --abort.", 82 + "--allow-drop-* and --offline cannot be combined with --continue or --abort.", 83 83 ) 84 84 if continue_: 85 85 return _common.emit(svc.continue_operation(_common.resolve_repo(scope.repo, paths))) ··· 87 87 target = _common.target(scope.repo, branch, paths) 88 88 return _common.emit( 89 89 svc.sync( 90 - target, _common.scope_spec(scope), 90 + target, 91 + _common.scope_spec(scope), 91 92 options=SyncOptions( 92 93 allow_drop_parent_modifications=allow_drop_parent_modifications, 93 94 allow_drop_merge=allow_drop_merge,
+3 -9
src/project_manager/stacker/config_schema.py
··· 20 20 21 21 def _validate_mode(value: str) -> None: 22 22 if value not in PR_MODES: 23 - raise git.GitError( 24 - f"pr.mode must be one of {sorted(PR_MODES)}; got {value!r}." 25 - ) 23 + raise git.GitError(f"pr.mode must be one of {sorted(PR_MODES)}; got {value!r}.") 26 24 27 25 28 26 def _validate_trunk(value: str) -> None: ··· 32 30 33 31 def _validate_target_repo(value: str) -> None: 34 32 if not _REPO_SLUG_RE.match(value): 35 - raise git.GitError( 36 - f"pr.target-repo must be in 'owner/repo' form; got {value!r}." 37 - ) 33 + raise git.GitError(f"pr.target-repo must be in 'owner/repo' form; got {value!r}.") 38 34 39 35 40 36 @dataclass(frozen=True) ··· 67 63 spec = CONFIG_KEYS.get(key) 68 64 if spec is None: 69 65 valid = ", ".join(sorted(CONFIG_KEYS)) 70 - raise git.GitError( 71 - f"Unknown config key {key!r}. Valid keys: {valid}." 72 - ) 66 + raise git.GitError(f"Unknown config key {key!r}. Valid keys: {valid}.") 73 67 return spec 74 68 75 69
+1 -4
src/project_manager/stacker/db.py
··· 410 410 ) 411 411 if "allow_drop_merge" not in cols: 412 412 conn.execute( 413 - "ALTER TABLE operations " 414 - "ADD COLUMN allow_drop_merge INTEGER NOT NULL DEFAULT 0" 413 + "ALTER TABLE operations ADD COLUMN allow_drop_merge INTEGER NOT NULL DEFAULT 0" 415 414 ) 416 415 417 416 ··· 433 432 allow_drop_parent_modifications=bool(row["allow_drop_parent_modifications"]), 434 433 allow_drop_merge=bool(row["allow_drop_merge"]), 435 434 ) 436 - 437 -
+43 -26
src/project_manager/stacker/gh.py
··· 71 71 repo: str, *, head: str | None = None, search: str | None = None 72 72 ) -> list[PullRequest]: 73 73 cmd = [ 74 - "gh", "pr", "list", 75 - "--repo", repo, 76 - "--state", "open", 74 + "gh", 75 + "pr", 76 + "list", 77 + "--repo", 78 + repo, 79 + "--state", 80 + "open", 77 81 "--json", 78 82 "number,url,title,body,headRefName,baseRefName,state,isDraft", 79 83 ] ··· 90 94 if request.head_repo and request.head_repo != request.repo: 91 95 return _create_pr_rest(request) 92 96 cmd = [ 93 - "gh", "pr", "create", 94 - "--repo", request.repo, 95 - "--base", request.base, 96 - "--head", request.head, 97 - "--title", request.title, 98 - "--body-file", str(request.body_file), 97 + "gh", 98 + "pr", 99 + "create", 100 + "--repo", 101 + request.repo, 102 + "--base", 103 + request.base, 104 + "--head", 105 + request.head, 106 + "--title", 107 + request.title, 108 + "--body-file", 109 + str(request.body_file), 99 110 ] 100 111 if request.draft: 101 112 cmd.append("--draft") ··· 110 121 """ 111 122 assert request.head_repo is not None 112 123 cmd = [ 113 - "gh", "api", "--method", "POST", 124 + "gh", 125 + "api", 126 + "--method", 127 + "POST", 114 128 f"/repos/{request.repo}/pulls", 115 - "-f", f"title={request.title}", 116 - "-f", f"body={request.body_file.read_text()}", 117 - "-f", f"head={request.head}", 118 - "-f", f"head_repo={request.head_repo}", 119 - "-f", f"base={request.base}", 120 - "-F", f"draft={'true' if request.draft else 'false'}", 129 + "-f", 130 + f"title={request.title}", 131 + "-f", 132 + f"body={request.body_file.read_text()}", 133 + "-f", 134 + f"head={request.head}", 135 + "-f", 136 + f"head_repo={request.head_repo}", 137 + "-f", 138 + f"base={request.base}", 139 + "-F", 140 + f"draft={'true' if request.draft else 'false'}", 121 141 ] 122 142 proc = run(cmd) 123 143 payload = json.loads(proc.stdout or "{}") ··· 127 147 return url 128 148 129 149 130 - _PR_URL_RE = re.compile( 131 - r"^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)/?$" 132 - ) 150 + _PR_URL_RE = re.compile(r"^https?://github\.com/([^/]+)/([^/]+)/pull/(\d+)/?$") 133 151 134 152 135 153 def parse_pr_url(url: str) -> tuple[str, str, int] | None: ··· 191 209 return None 192 210 owner, repo, number = parsed 193 211 proc = run( 194 - ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"], check=False, 212 + ["gh", "api", f"/repos/{owner}/{repo}/pulls/{number}"], 213 + check=False, 195 214 ) 196 215 if proc.returncode != 0: 197 216 return None ··· 233 252 `▼ ⚑ ✓ ◼` set gitstack ships. 234 253 """ 235 254 236 - state: str # OPEN | MERGED | CLOSED 255 + state: str # OPEN | MERGED | CLOSED 237 256 is_draft: bool 238 257 is_approved: bool 239 258 has_open_comments: bool ··· 267 286 f"latestReviews(last: 50) {{ nodes {{ state }} }} }}" 268 287 for n in numbers 269 288 ) 270 - query = ( 271 - f'query {{ repository(owner: "{owner}", name: "{repo}") {{ ' 272 - f'{alias_body} }} }}' 273 - ) 289 + query = f'query {{ repository(owner: "{owner}", name: "{repo}") {{ {alias_body} }} }}' 274 290 proc = run( 275 - ["gh", "api", "graphql", "-f", f"query={query}"], check=False, 291 + ["gh", "api", "graphql", "-f", f"query={query}"], 292 + check=False, 276 293 ) 277 294 if proc.returncode != 0: 278 295 continue
+33 -35
src/project_manager/stacker/git.py
··· 46 46 worktree_path = Path(git(path, "rev-parse", "--show-toplevel").stdout.strip()) 47 47 branch = current_branch(worktree_path) 48 48 if not branch: 49 - raise GitError( 50 - f"Detached HEAD at {worktree_path}; stacker requires a branch checkout." 51 - ) 49 + raise GitError(f"Detached HEAD at {worktree_path}; stacker requires a branch checkout.") 52 50 return GitContext(repo_root=repo_root, worktree_path=worktree_path, branch=branch) 53 51 54 52 ··· 91 89 return None 92 90 merge = proc.stdout.strip() 93 91 prefix = "refs/heads/" 94 - return merge[len(prefix):] if merge.startswith(prefix) else None 92 + return merge[len(prefix) :] if merge.startswith(prefix) else None 95 93 96 94 97 95 def upstream_remote_name(path: Path) -> str | None: ··· 127 125 128 126 129 127 def branch_exists(repo_root: Path, branch: str) -> bool: 130 - return git(repo_root, "rev-parse", "--verify", branch, check=False).returncode == 0 or git( 131 - repo_root, "rev-parse", "--verify", f"origin/{branch}", check=False 132 - ).returncode == 0 128 + return ( 129 + git(repo_root, "rev-parse", "--verify", branch, check=False).returncode == 0 130 + or git(repo_root, "rev-parse", "--verify", f"origin/{branch}", check=False).returncode == 0 131 + ) 133 132 134 133 135 134 def rev_parse(path: Path, rev: str) -> str: ··· 160 159 commits. `--reverse` matches `rev_list` so cherry-picks run oldest-first. 161 160 """ 162 161 out = git( 163 - path, "rev-list", "--reverse", "--cherry-pick", "--right-only", 164 - "--no-merges", f"{left}...{right}", 162 + path, 163 + "rev-list", 164 + "--reverse", 165 + "--cherry-pick", 166 + "--right-only", 167 + "--no-merges", 168 + f"{left}...{right}", 165 169 ).stdout.strip() 166 170 return [line for line in out.splitlines() if line] 167 171 ··· 309 313 # auto-detected case. 310 314 return run( 311 315 [ 312 - "git", "-C", str(path), "-c", "core.editor=true", 313 - "cherry-pick", "--no-edit", "--empty=drop", commit, 316 + "git", 317 + "-C", 318 + str(path), 319 + "-c", 320 + "core.editor=true", 321 + "cherry-pick", 322 + "--no-edit", 323 + "--empty=drop", 324 + commit, 314 325 ], 315 326 env={"GIT_EDITOR": "true", "GIT_MERGE_AUTOEDIT": "no"}, 316 327 check=False, ··· 357 368 358 369 359 370 def merge_tree_write_tree( 360 - repo_root: Path, base: str, ours: str, theirs: str, 371 + repo_root: Path, 372 + base: str, 373 + ours: str, 374 + theirs: str, 361 375 ) -> str | None: 362 376 """3-way merge of `ours` and `theirs` using `base`, in memory. 363 377 ··· 394 408 current = current_branch(repo_root) 395 409 if current: 396 410 return current 397 - proc = git( 398 - repo_root, "for-each-ref", "--format=%(refname:short)", "refs/heads", check=False 399 - ) 411 + proc = git(repo_root, "for-each-ref", "--format=%(refname:short)", "refs/heads", check=False) 400 412 branches = [line for line in proc.stdout.strip().splitlines() if line] 401 413 if branches: 402 414 return branches[0] ··· 434 446 def upstream_branch_name(self, path: Path) -> str | None: ... 435 447 def upstream_remote_name(self, path: Path) -> str | None: ... 436 448 def remote_url(self, path: Path, remote: str) -> str | None: ... 437 - def log_subject_and_author( 438 - self, path: Path, revspec: str 439 - ) -> list[tuple[str, str]]: ... 440 - def first_commit_title_and_body( 441 - self, path: Path, revspec: str 442 - ) -> tuple[str, str]: ... 449 + def log_subject_and_author(self, path: Path, revspec: str) -> list[tuple[str, str]]: ... 450 + def first_commit_title_and_body(self, path: Path, revspec: str) -> tuple[str, str]: ... 443 451 def guess_trunk_branch(self, repo_root: Path) -> str: ... 444 452 def is_ancestor(self, repo_root: Path, older: str, newer: str) -> bool: ... 445 453 def tree_of(self, path: Path, rev: str) -> str: ... ··· 451 459 class SubprocessGitClient: 452 460 """Production GitClient impl: delegates to module-level helpers.""" 453 461 454 - def run( 455 - self, path: Path, *args: str, check: bool = True 456 - ) -> subprocess.CompletedProcess[str]: 462 + def run(self, path: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: 457 463 return git(path, *args, check=check) 458 464 459 465 def current_branch(self, path: Path) -> str: ··· 489 495 def reset_hard(self, path: Path, target: str) -> None: 490 496 reset_hard(path, target) 491 497 492 - def cherry_pick( 493 - self, path: Path, commit: str 494 - ) -> subprocess.CompletedProcess[str]: 498 + def cherry_pick(self, path: Path, commit: str) -> subprocess.CompletedProcess[str]: 495 499 return cherry_pick(path, commit) 496 500 497 501 def cherry_pick_continue(self, path: Path) -> subprocess.CompletedProcess[str]: ··· 518 522 def remote_url(self, path: Path, remote: str) -> str | None: 519 523 return remote_url(path, remote) 520 524 521 - def log_subject_and_author( 522 - self, path: Path, revspec: str 523 - ) -> list[tuple[str, str]]: 525 + def log_subject_and_author(self, path: Path, revspec: str) -> list[tuple[str, str]]: 524 526 return log_subject_and_author(path, revspec) 525 527 526 - def first_commit_title_and_body( 527 - self, path: Path, revspec: str 528 - ) -> tuple[str, str]: 528 + def first_commit_title_and_body(self, path: Path, revspec: str) -> tuple[str, str]: 529 529 return first_commit_title_and_body(path, revspec) 530 530 531 531 def guess_trunk_branch(self, repo_root: Path) -> str: ··· 541 541 self, repo_root: Path, base: str, ours: str, theirs: str 542 542 ) -> str | None: 543 543 return merge_tree_write_tree(repo_root, base, ours, theirs) 544 - 545 -
+1 -1
src/project_manager/stacker/models.py
··· 106 106 repo_name: str 107 107 branch: str 108 108 pr_url: str 109 - state: str # "OPEN" | "MERGED" | "CLOSED" 109 + state: str # "OPEN" | "MERGED" | "CLOSED" 110 110 pr_number: int | None = None 111 111 is_draft: bool = False 112 112 merged: bool = False
+4 -1
src/project_manager/stacker/ops/absorb.py
··· 80 80 f"({fmt.short(parent_slot_head)})", 81 81 ) 82 82 return cp_driver.run_until_pause_or_finish( 83 - ctx, repo_name, acquired, logs=logs, 83 + ctx, 84 + repo_name, 85 + acquired, 86 + logs=logs, 84 87 )
+5 -1
src/project_manager/stacker/ops/continue_abort.py
··· 29 29 else None 30 30 ) 31 31 return cp_driver.run_until_pause_or_finish( 32 - ctx, repo_name, handle, continuing=True, logs=[], 32 + ctx, 33 + repo_name, 34 + handle, 35 + continuing=True, 36 + logs=[], 33 37 ) 34 38 35 39
+1 -3
src/project_manager/stacker/ops/init.py
··· 23 23 raise git.GitError("Parent and child must be in the same repo.") 24 24 repo_path = ctx.paths.repo(spec.repo_name) 25 25 parent_head = git.rev_parse(repo_path, spec.parent.branch) 26 - start_point = ( 27 - git.rev_parse(repo_path, spec.copy_from) if spec.copy_from else parent_head 28 - ) 26 + start_point = git.rev_parse(repo_path, spec.copy_from) if spec.copy_from else parent_head 29 27 git.git(spec.worktree_path, "checkout", "-b", spec.branch, start_point) 30 28 return _persist_init(ctx, spec, parent_head, parent_head) 31 29
+1 -3
src/project_manager/stacker/ops/remove.py
··· 49 49 return "Removed:\n " + "\n ".join(removed) 50 50 51 51 52 - def _remove_one( 53 - ctx: StackerCtx, tracked: TrackedBranch, *, keep_branch: bool 54 - ) -> None: 52 + def _remove_one(ctx: StackerCtx, tracked: TrackedBranch, *, keep_branch: bool) -> None: 55 53 """Untrack `tracked`, reparent children, optionally delete the git branch.""" 56 54 for child in ctx.db.get_children(tracked.repo_name, tracked.branch): 57 55 ctx.db.upsert_branch(
+1 -3
src/project_manager/stacker/ops/rename.py
··· 32 32 repo_path = ctx.paths.repo(tracked.repo_name) 33 33 if git.branch_exists(repo_path, new_name): 34 34 raise git.GitError(f"Branch {new_name} already exists.") 35 - current_path = worktree.require_checked_out( 36 - ctx, tracked.repo_name, tracked.branch 37 - ) 35 + current_path = worktree.require_checked_out(ctx, tracked.repo_name, tracked.branch) 38 36 children = ctx.db.get_children(tracked.repo_name, target.branch) 39 37 # Capture the PR-state cache under the old name before deleting the 40 38 # branch (which also wipes its pr_state row). Re-upsert under the new
+10 -9
src/project_manager/stacker/ops/reparent.py
··· 21 21 22 22 23 23 def reparent( 24 - ctx: StackerCtx, target: SelectorTarget, new_parent: ParentLocator, 25 - *, options: SyncOptions = DEFAULT_SYNC_OPTIONS, 24 + ctx: StackerCtx, 25 + target: SelectorTarget, 26 + new_parent: ParentLocator, 27 + *, 28 + options: SyncOptions = DEFAULT_SYNC_OPTIONS, 26 29 ) -> str: 27 30 """Move `target` onto a new parent; cascade cherry-pick through descendants. 28 31 ··· 51 54 ) 52 55 repo_path = ctx.paths.repo(tracked.repo_name) 53 56 if not git.branch_exists(repo_path, new_parent.branch): 54 - raise git.GitError( 55 - f"Branch '{new_parent.branch}' not found in {tracked.repo_name}." 56 - ) 57 + raise git.GitError(f"Branch '{new_parent.branch}' not found in {tracked.repo_name}.") 57 58 ctx.db.upsert_branch( 58 59 TrackedBranch( 59 60 repo_name=tracked.repo_name, ··· 66 67 ) 67 68 ) 68 69 return sync_ops.sync( 69 - ctx, target, ScopeSpec(scope="current", skip_ancestors=True), 70 + ctx, 71 + target, 72 + ScopeSpec(scope="current", skip_ancestors=True), 70 73 options=options, 71 74 ) 72 75 73 76 74 - def _is_descendant( 75 - ctx: StackerCtx, tracked: TrackedBranch, candidate: str 76 - ) -> bool: 77 + def _is_descendant(ctx: StackerCtx, tracked: TrackedBranch, candidate: str) -> bool: 77 78 for descendant in toposorted_descendants(ctx, tracked.repo_name, tracked.branch): 78 79 if descendant.branch == candidate: 79 80 return True
+4 -7
src/project_manager/stacker/ops/split.py
··· 100 100 "Use `pm stacker continue` or `pm stacker abort`." 101 101 ) 102 102 tracked = require_tracked(ctx, target) 103 - current_path = worktree.require_checked_out( 104 - ctx, tracked.repo_name, tracked.branch 105 - ) 103 + current_path = worktree.require_checked_out(ctx, tracked.repo_name, tracked.branch) 106 104 if git.has_tracked_changes(current_path): 107 105 raise git.GitError( 108 106 f"Tracked changes present in {current_path}. Commit or discard before splitting." 109 107 ) 110 108 if git.cherry_pick_in_progress(current_path): 111 - raise git.GitError( 112 - f"Cherry-pick in progress in {current_path}. Resolve before splitting." 113 - ) 109 + raise git.GitError(f"Cherry-pick in progress in {current_path}. Resolve before splitting.") 114 110 if git.branch_exists(ctx.paths.repo(tracked.repo_name), new_name): 115 111 raise git.GitError(f"Branch {new_name} already exists.") 116 112 split_sha = git.rev_parse(current_path, split_commit) 117 113 head_sha = git.rev_parse(current_path, "HEAD") 118 114 commit_list = git.rev_list( 119 - current_path, f"{tracked.managed_base_commit}..{head_sha}", 115 + current_path, 116 + f"{tracked.managed_base_commit}..{head_sha}", 120 117 ) 121 118 if split_sha not in commit_list: 122 119 raise git.GitError(
+21 -8
src/project_manager/stacker/ops/sync.py
··· 26 26 27 27 28 28 def sync( 29 - ctx: StackerCtx, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE, 30 - *, options: SyncOptions = DEFAULT_SYNC_OPTIONS, 29 + ctx: StackerCtx, 30 + target: SelectorTarget, 31 + spec: ScopeSpec = DEFAULT_SCOPE, 32 + *, 33 + options: SyncOptions = DEFAULT_SYNC_OPTIONS, 31 34 ) -> str: 32 35 """Cherry-pick a set of tracked branches onto their parents. 33 36 ··· 69 72 70 73 71 74 def _sync_one( 72 - ctx: StackerCtx, tracked: TrackedBranch, *, options: SyncOptions, 75 + ctx: StackerCtx, 76 + tracked: TrackedBranch, 77 + *, 78 + options: SyncOptions, 73 79 ) -> str: 74 80 """Single-branch sync path: local_sync op with up-to-date short-circuit.""" 75 81 ctx.db.put_operation( ··· 91 97 with worktree.acquired_for_op(ctx, tracked.repo_name, tracked.branch) as acquired: 92 98 sync_gates.run_branch_gates(ctx, tracked, acquired.path, options=options) 93 99 collapse_msg = sync_gates.collapse_if_merged( 94 - ctx, tracked, acquired.path, 100 + ctx, 101 + tracked, 102 + acquired.path, 95 103 allow_drop_merge=options.allow_drop_merge, 96 104 ) 97 105 if collapse_msg is not None: ··· 111 119 ensure_syncable(acquired.path) 112 120 logs: list[str] = [] 113 121 cp_driver.prepare_local_operation( 114 - ctx, tracked, op_type="local_sync", slot_path=acquired.path, 122 + ctx, 123 + tracked, 124 + op_type="local_sync", 125 + slot_path=acquired.path, 115 126 logs=logs, 116 127 ) 117 128 return cp_driver.run_until_pause_or_finish( 118 - ctx, tracked.repo_name, acquired, logs=logs, 129 + ctx, 130 + tracked.repo_name, 131 + acquired, 132 + logs=logs, 119 133 ) 120 134 except BaseException: 121 135 # The slot is already handled by the context manager above; we ··· 144 158 and current_head == (tracked.last_clean_head or "") 145 159 ): 146 160 return ( 147 - f"No repair needed for {label}.\n" 148 - f"Stored base already matches {fmt.short(actual_base)}." 161 + f"No repair needed for {label}.\nStored base already matches {fmt.short(actual_base)}." 149 162 ) 150 163 ctx.db.upsert_branch( 151 164 TrackedBranch(
+15 -10
src/project_manager/stacker/ops/sync_gates.py
··· 21 21 driver can call them without re-introducing the 22 22 ops/sync ↔ cherry_pick/driver import cycle. 23 23 """ 24 + 24 25 from __future__ import annotations 25 26 26 27 from pathlib import Path ··· 107 108 return None 108 109 parent_tree = git.tree_of(repo_path, parent_tip) 109 110 merged_tree = git.merge_tree_write_tree( 110 - repo_path, tracked.managed_base_commit, parent_tip, branch_head, 111 + repo_path, 112 + tracked.managed_base_commit, 113 + parent_tip, 114 + branch_head, 111 115 ) 112 116 squash_present = merged_tree is not None and merged_tree == parent_tree 113 117 if squash_present or allow_drop_merge: ··· 140 144 if err is not None and not options.allow_drop_parent_modifications: 141 145 raise git.GitError(err) 142 146 err = merged_collapse_action( 143 - ctx, tracked, slot_path, allow_drop_merge=options.allow_drop_merge, 147 + ctx, 148 + tracked, 149 + slot_path, 150 + allow_drop_merge=options.allow_drop_merge, 144 151 ) 145 152 if isinstance(err, str) and err != "collapse": 146 153 raise git.GitError(err) ··· 162 169 already raised. 163 170 """ 164 171 decision = merged_collapse_action( 165 - ctx, tracked, slot_path, allow_drop_merge=allow_drop_merge, 172 + ctx, 173 + tracked, 174 + slot_path, 175 + allow_drop_merge=allow_drop_merge, 166 176 ) 167 177 if decision != "collapse": 168 178 return None ··· 181 191 ) 182 192 ) 183 193 child_label = selectors.selector_for(tracked.repo_name, tracked.branch) 184 - parent_label = selectors.selector_for( 185 - tracked.parent_repo_name, tracked.parent_branch 186 - ) 187 - return ( 188 - f"Collapsed {child_label} to {parent_label} at " 189 - f"{fmt.short(parent_tip)} (merged PR)." 190 - ) 194 + parent_label = selectors.selector_for(tracked.parent_repo_name, tracked.parent_branch) 195 + return f"Collapsed {child_label} to {parent_label} at {fmt.short(parent_tip)} (merged PR)."
+2 -6
src/project_manager/stacker/ops/track.py
··· 41 41 if git.branch_exists(repo_path, branch): 42 42 raise git.GitError(f"Branch {branch} already exists.") 43 43 parent_head = git.rev_parse(repo_path, parent.branch) 44 - start_point = ( 45 - git.rev_parse(repo_path, copy_from) if copy_from else parent_head 46 - ) 44 + start_point = git.rev_parse(repo_path, copy_from) if copy_from else parent_head 47 45 git.git(repo_path, "branch", branch, start_point) 48 46 tracked = TrackedBranch( 49 47 repo_name=repo_name, ··· 58 56 return tracked 59 57 60 58 61 - def track( 62 - ctx: StackerCtx, target: SelectorTarget, parent: ParentLocator 63 - ) -> TrackedBranch: 59 + def track(ctx: StackerCtx, target: SelectorTarget, parent: ParentLocator) -> TrackedBranch: 64 60 """Adopt an existing branch into stacker tracking. 65 61 66 62 Slot resolution mirrors fresh `create` (`allow_branch_switch=True`) so
+2 -5
src/project_manager/stacker/ops_slot.py
··· 64 64 time.sleep(opts.poll_interval_s) 65 65 66 66 67 - def _try_claim_free( 68 - paths: Paths, pooldb: PoolDB, repo_name: str 69 - ) -> slot_mod.Slot | None: 67 + def _try_claim_free(paths: Paths, pooldb: PoolDB, repo_name: str) -> slot_mod.Slot | None: 70 68 """One claim attempt. Returns the slot on success; None if pool is full. 71 69 72 70 Handles the race where another claimer grabs our target between the ··· 87 85 88 86 def _stacker_owns_any(pooldb: PoolDB, repo_name: str) -> bool: 89 87 return any( 90 - owner.kind == OwnerKind.STACKER 91 - for (_repo, _uuid, owner) in pooldb.list_owned(repo_name) 88 + owner.kind == OwnerKind.STACKER for (_repo, _uuid, owner) in pooldb.list_owned(repo_name) 92 89 ) 93 90 94 91
+1 -4
src/project_manager/stacker/pr/config.py
··· 50 50 notices: list[str] = [] 51 51 if key == config_schema.PR_MODE and value == "pr-pr": 52 52 push_remote = push_remote_slug(ctx, repo_name) 53 - target = ( 54 - ctx.db.get_config(repo_name, config_schema.PR_TARGET_REPO) 55 - or push_remote 56 - ) 53 + target = ctx.db.get_config(repo_name, config_schema.PR_TARGET_REPO) or push_remote 57 54 if target != push_remote: 58 55 raise git.GitError( 59 56 f"pr.mode=pr-pr requires pr.target-repo to equal the push "
+2 -6
src/project_manager/stacker/pr/create_update.py
··· 93 93 # UI survives. 94 94 template = load_pr_template(worktree_path) 95 95 body_seed = ( 96 - inject_body_into_template(first_body, template) 97 - if template is not None 98 - else first_body 96 + inject_body_into_template(first_body, template) if template is not None else first_body 99 97 ) 100 98 body = compose_body_with_block(body_seed, "") 101 99 with body_file(body) as file_path: ··· 164 162 ) 165 163 166 164 167 - def _live_heads( 168 - ctx: StackerCtx, component: list[TrackedBranch] 169 - ) -> dict[str, str]: 165 + def _live_heads(ctx: StackerCtx, component: list[TrackedBranch]) -> dict[str, str]: 170 166 """Map `branch -> git rev-parse <branch>` from the canonical repo. 171 167 172 168 Source of truth for the head SHA in `_files_url`. Reads the branch
+9 -17
src/project_manager/stacker/pr/lineage.py
··· 89 89 return ordered 90 90 91 91 92 - def toposorted_descendants( 93 - ctx: StackerCtx, repo_name: str, branch: str 94 - ) -> list[TrackedBranch]: 92 + def toposorted_descendants(ctx: StackerCtx, repo_name: str, branch: str) -> list[TrackedBranch]: 95 93 ordered: list[TrackedBranch] = [] 96 94 97 95 def walk(parent_branch: str) -> None: ··· 104 102 105 103 106 104 def resolve_scope( 107 - ctx: StackerCtx, repo_name: str, target_branch: str, spec: ScopeSpec, 105 + ctx: StackerCtx, 106 + repo_name: str, 107 + target_branch: str, 108 + spec: ScopeSpec, 108 109 ) -> list[TrackedBranch]: 109 110 """Resolve scope flags into an ordered branch list. 110 111 ··· 119 120 resolved list. 120 121 """ 121 122 if spec.only: 122 - tracked = require_tracked( 123 - ctx, SelectorTarget(repo_name=repo_name, branch=target_branch) 124 - ) 123 + tracked = require_tracked(ctx, SelectorTarget(repo_name=repo_name, branch=target_branch)) 125 124 return [tracked] 126 125 if spec.scope == "all": 127 126 resolved = toposorted_all(ctx, repo_name) 128 127 if spec.from_branch: 129 128 resolved = _drop_until(resolved, spec.from_branch) 130 129 return resolved 131 - target = require_tracked( 132 - ctx, SelectorTarget(repo_name=repo_name, branch=target_branch) 133 - ) 130 + target = require_tracked(ctx, SelectorTarget(repo_name=repo_name, branch=target_branch)) 134 131 component = lineage(ctx, target) 135 132 drop: set[str] = set() 136 133 if spec.skip_ancestors: 137 134 # ancestor_chain returns root → … → target inclusive; drop 138 135 # everything before target (target itself stays in the walk). 139 - drop.update( 140 - b.branch for b in ancestor_chain(ctx, target) if b.branch != target.branch 141 - ) 136 + drop.update(b.branch for b in ancestor_chain(ctx, target) if b.branch != target.branch) 142 137 if spec.skip_descendants: 143 - drop.update( 144 - b.branch 145 - for b in toposorted_descendants(ctx, target.repo_name, target.branch) 146 - ) 138 + drop.update(b.branch for b in toposorted_descendants(ctx, target.repo_name, target.branch)) 147 139 resolved = [b for b in component if b.branch not in drop] 148 140 if spec.from_branch: 149 141 resolved = _drop_until(resolved, spec.from_branch)
+4 -5
src/project_manager/stacker/pr/refresh.py
··· 7 7 checks read the freshly-updated `pr_state` rows from SQLite — no network 8 8 calls in the per-branch path. 9 9 """ 10 + 10 11 from __future__ import annotations 11 12 12 13 from typing import TYPE_CHECKING ··· 20 21 21 22 22 23 def refresh_review_state( 23 - ctx: StackerCtx, branches: list[TrackedBranch], 24 + ctx: StackerCtx, 25 + branches: list[TrackedBranch], 24 26 ) -> None: 25 27 """Bulk-refresh `pr_state` rows via one GraphQL call per (owner, repo). 26 28 ··· 30 32 GraphQL blip can't break `ls` or `sync`; callers fall back to 31 33 whatever's already in the cache. 32 34 """ 33 - cached = { 34 - (pr.repo_name, pr.branch): pr 35 - for pr in ctx.db.list_pr_states() 36 - } 35 + cached = {(pr.repo_name, pr.branch): pr for pr in ctx.db.list_pr_states()} 37 36 entries: list[tuple[tuple[str, str, int], tuple[str, str]]] = [] 38 37 for b in branches: 39 38 pr = cached.get((b.repo_name, b.branch))
+3 -9
src/project_manager/stacker/pr/resolve.py
··· 62 62 return remote_branch 63 63 64 64 65 - def first_commit_text( 66 - ctx: StackerCtx, tracked: TrackedBranch 67 - ) -> tuple[str, str, Path]: 65 + def first_commit_text(ctx: StackerCtx, tracked: TrackedBranch) -> tuple[str, str, Path]: 68 66 path = require_checked_out(ctx, tracked.repo_name, tracked.branch) 69 - title, body = git.first_commit_title_and_body( 70 - path, f"{tracked.managed_base_commit}..HEAD" 71 - ) 67 + title, body = git.first_commit_title_and_body(path, f"{tracked.managed_base_commit}..HEAD") 72 68 if not title: 73 69 title = tracked.branch 74 70 return title, body, path ··· 101 97 # parent's slot is already released by the time we resolve a child's 102 98 # base, and pools with fewer slots than tracked branches always trip it. 103 99 if not parent_pr.head_ref_name: 104 - raise git.GitError( 105 - f"Direct parent {parent_label} has an open PR but no head ref." 106 - ) 100 + raise git.GitError(f"Direct parent {parent_label} has an open PR but no head ref.") 107 101 return parent_pr.head_ref_name
+2 -7
src/project_manager/stacker/pr/stack_block.py
··· 62 62 # ReviewStack's gitstack parser keys off. 63 63 lines = ["<!-- stacker:begin -->", STACK_HEADER, ""] 64 64 component_keys = {node.branch for node in render_ctx.component} 65 - roots = [ 66 - node for node in render_ctx.component if node.parent_branch not in component_keys 67 - ] 65 + roots = [node for node in render_ctx.component if node.parent_branch not in component_keys] 68 66 for index, root in enumerate(sorted(roots, key=lambda item: item.branch)): 69 67 if index: 70 68 lines.append("") ··· 139 137 if not head_sha: 140 138 return None 141 139 base_sha = node.managed_base_commit 142 - return ( 143 - f"{pr.url}/files/" 144 - f"{quote(base_sha, safe=':/')}..{quote(head_sha, safe=':/')}" 145 - ) 140 + return f"{pr.url}/files/{quote(base_sha, safe=':/')}..{quote(head_sha, safe=':/')}" 146 141 147 142 148 143 @contextlib.contextmanager
+1 -5
src/project_manager/stacker/pr/template.py
··· 60 60 if first_header is None: 61 61 return f"{commit_body}\n\n{template}" 62 62 next_header = next( 63 - ( 64 - i 65 - for i in range(first_header + 1, len(lines)) 66 - if lines[i].startswith("## ") 67 - ), 63 + (i for i in range(first_header + 1, len(lines)) if lines[i].startswith("## ")), 68 64 len(lines), 69 65 ) 70 66 out = [
+6 -8
src/project_manager/stacker/pr_backend.py
··· 14 14 recording fake. 15 15 """ 16 16 17 - def repo_info( 18 - self, *, cwd: Path | None = None, repo: str | None = None 19 - ) -> gh.RepoInfo: ... 17 + def repo_info(self, *, cwd: Path | None = None, repo: str | None = None) -> gh.RepoInfo: ... 20 18 21 19 def list_open_prs( 22 20 self, ··· 35 33 def search_prs(self, query: str) -> list[gh.PullRequest]: ... 36 34 37 35 def batch_pr_review( 38 - self, entries: Sequence[tuple[str, str, int]], 36 + self, 37 + entries: Sequence[tuple[str, str, int]], 39 38 ) -> dict[tuple[str, str, int], gh.PRReviewSummary]: ... 40 39 41 40 42 41 class GhCliBackend: 43 42 """Production backend — delegates to `stacker.gh` module-level functions.""" 44 43 45 - def repo_info( 46 - self, *, cwd: Path | None = None, repo: str | None = None 47 - ) -> gh.RepoInfo: 44 + def repo_info(self, *, cwd: Path | None = None, repo: str | None = None) -> gh.RepoInfo: 48 45 return gh.repo_info(cwd=cwd, repo=repo) 49 46 50 47 def list_open_prs( ··· 69 66 return gh.search_prs(query) 70 67 71 68 def batch_pr_review( 72 - self, entries: Sequence[tuple[str, str, int]], 69 + self, 70 + entries: Sequence[tuple[str, str, int]], 73 71 ) -> dict[tuple[str, str, int], gh.PRReviewSummary]: 74 72 return gh.batch_pr_review(entries)
+21 -14
src/project_manager/stacker/render/graph.py
··· 22 22 23 23 24 24 IconKind = Literal[ 25 - "local_only", "no_pr", "pr_open", "merged", 26 - "pr_draft", "pr_approved", 27 - "pr_open_comments", "pr_approved_comments", 25 + "local_only", 26 + "no_pr", 27 + "pr_open", 28 + "merged", 29 + "pr_draft", 30 + "pr_approved", 31 + "pr_open_comments", 32 + "pr_approved_comments", 28 33 ] 29 34 30 35 _SYMBOL: dict[IconKind, str] = { ··· 44 49 "local_only": None, 45 50 "no_pr": None, 46 51 "pr_open": None, 47 - "merged": None, # merged styled via dim/strike, not fg 52 + "merged": None, # merged styled via dim/strike, not fg 48 53 "pr_draft": None, 49 54 "pr_open_comments": "red", 50 55 "pr_approved": "green", ··· 63 68 } 64 69 65 70 _OFFLINE_ICONS: tuple[IconKind, ...] = ( 66 - "local_only", "no_pr", "pr_open", "merged", 71 + "local_only", 72 + "no_pr", 73 + "pr_open", 74 + "merged", 67 75 ) 68 76 _ONLINE_ICONS: tuple[IconKind, ...] = ( 69 - "pr_draft", "pr_open_comments", "pr_approved", "pr_approved_comments", 77 + "pr_draft", 78 + "pr_open_comments", 79 + "pr_approved", 80 + "pr_approved_comments", 70 81 ) 71 82 72 83 ··· 432 443 ) 433 444 if git.cherry_pick_in_progress(path): 434 445 raise git.GitError( 435 - f"Cherry-pick already in progress in {path}. Resolve it before starting " 436 - "another sync." 446 + f"Cherry-pick already in progress in {path}. Resolve it before starting another sync." 437 447 ) 438 448 439 449 ··· 449 459 lines: list[str] = ["Legend:"] 450 460 if render_opts.icons: 451 461 lines.append( 452 - " Icons: " + " ".join( 453 - _legend_icon(kind, render_opts) for kind in _OFFLINE_ICONS 454 - ), 462 + " Icons: " + " ".join(_legend_icon(kind, render_opts) for kind in _OFFLINE_ICONS), 455 463 ) 456 464 if render_opts.online: 457 465 lines.append( 458 - " Online: " + " ".join( 459 - _legend_icon(kind, render_opts) for kind in _ONLINE_ICONS 460 - ), 466 + " Online: " 467 + + " ".join(_legend_icon(kind, render_opts) for kind in _ONLINE_ICONS), 461 468 ) 462 469 lines.append(" Group: (!) needs sync (N↑) N unpushed (!N↑) both") 463 470 lines.append(" Suffix: [LOCAL] [REMOTE] [<url>] [MERGED] [dirty]")
+36 -41
src/project_manager/stacker/render/ls.py
··· 93 93 if options.render.online: 94 94 refresh_review_state(ctx, branches) 95 95 if options.render.hide_merged: 96 - merged = { 97 - (pr.repo_name, pr.branch) 98 - for pr in ctx.db.list_pr_states(repo_name) 99 - if pr.merged 100 - } 96 + merged = {(pr.repo_name, pr.branch) for pr in ctx.db.list_pr_states(repo_name) if pr.merged} 101 97 branches = [b for b in branches if (b.repo_name, b.branch) not in merged] 102 98 if options.json_output: 103 99 # `--legend` is intentionally ignored here — JSON consumers don't ··· 118 114 119 115 120 116 def _resolve_branch_projects( 121 - paths: Paths, live: dict[str, git.WorktreeInfo], 117 + paths: Paths, 118 + live: dict[str, git.WorktreeInfo], 122 119 ) -> dict[str, str]: 123 120 """Map each live branch to the pm project whose slot has it checked out. 124 121 ··· 143 140 144 141 145 142 def _project_for_path( 146 - pooldb: PoolDB, pool_root: Path, wt_path: Path, 143 + pooldb: PoolDB, 144 + pool_root: Path, 145 + wt_path: Path, 147 146 ) -> str | None: 148 147 try: 149 148 rel = wt_path.resolve().relative_to(pool_root) ··· 159 158 160 159 161 160 def _empty_text( 162 - ctx: StackerCtx, repo_name: str | None, current: tuple[str, str] | None, 161 + ctx: StackerCtx, 162 + repo_name: str | None, 163 + current: tuple[str, str] | None, 163 164 ) -> str: 164 165 """Empty-set message. Adds the "not tracked" note when it applies. 165 166 ··· 173 174 return "No tracked branches." 174 175 if ctx.db.get_branch(cur_repo, cur_branch) is not None: 175 176 return "No tracked branches." 176 - return ( 177 - f"No tracked branches in {cur_repo}. " 178 - f"Current branch `{cur_branch}` is not tracked by pm." 179 - ) 177 + return f"No tracked branches in {cur_repo}. Current branch `{cur_branch}` is not tracked by pm." 180 178 181 179 182 180 def _ls_branch_set( ··· 187 185 ) -> list[TrackedBranch]: 188 186 if scope == "current": 189 187 if repo_name is None or target_branch is None: 190 - raise git.GitError( 191 - "ls --scope current requires a repo and target branch." 192 - ) 193 - target = require_tracked( 194 - ctx, SelectorTarget(repo_name=repo_name, branch=target_branch) 195 - ) 188 + raise git.GitError("ls --scope current requires a repo and target branch.") 189 + target = require_tracked(ctx, SelectorTarget(repo_name=repo_name, branch=target_branch)) 196 190 return lineage(ctx, target) 197 191 return ctx.db.list_branches(repo_name) 198 192 ··· 212 206 # One concurrent pre-pass computes every per-repo and per-node git 213 207 # fact before rendering. The render walk below is pure dict lookups. 214 208 facts = prefetch_mod.prefetch_all( 215 - ctx, by_repo, 216 - details=details, current=current, 209 + ctx, 210 + by_repo, 211 + details=details, 212 + current=current, 217 213 limit=config.concurrency().limit, 218 214 ) 219 215 lines: list[str] = [] ··· 225 221 # Two-space gutter mirrors the per-row `> ` current marker. 226 222 lines.append(" " + fmt.style(repo, fg="blue", bold=True)) 227 223 tracked_branches = {item.branch for item in items} 228 - if ( 229 - current is not None 230 - and current[0] == repo 231 - and current[1] not in tracked_branches 232 - ): 224 + if current is not None and current[0] == repo and current[1] not in tracked_branches: 233 225 # Tracked branches already signal their state via the `> / (current)` 234 226 # marker; only call out the non-managed case, where the marker 235 227 # can't fire and the reader would otherwise miss the branch. ··· 243 235 roots: list[tuple[str, bool]] = [] 244 236 seen_roots: set[str] = set() 245 237 for item in items: 246 - if ( 247 - item.parent_branch not in tracked_branches 248 - and item.parent_branch not in seen_roots 249 - ): 238 + if item.parent_branch not in tracked_branches and item.parent_branch not in seen_roots: 250 239 roots.append((item.parent_branch, True)) 251 240 seen_roots.add(item.parent_branch) 252 241 current_branch = current[1] if current and current[0] == repo else None ··· 268 257 lines, 269 258 gctx, 270 259 graph.GraphPos( 271 - branch=parent_branch, prefix="", is_last=True, implicit=implicit, 260 + branch=parent_branch, 261 + prefix="", 262 + is_last=True, 263 + implicit=implicit, 272 264 ), 273 265 ) 274 266 return "\n".join(lines) ··· 302 294 include_counts = details in ("status-counts", "all") 303 295 include_all = details == "all" 304 296 pr_map: dict[tuple[str, str], PRState] = { 305 - (pr.repo_name, pr.branch): pr 306 - for pr in ctx.db.list_pr_states() 297 + (pr.repo_name, pr.branch): pr for pr in ctx.db.list_pr_states() 307 298 } 308 299 tracked_keys: set[tuple[str, str]] = {(b.repo_name, b.branch) for b in branches} 309 300 children_map: dict[tuple[str, str], list[TrackedBranch]] = {} ··· 316 307 # reflects cached state only (matching gitstack's emit-don't-fetch 317 308 # semantics), so RenderOptions isn't threaded through here. 318 309 facts = prefetch_mod.prefetch_all( 319 - ctx, by_repo, 320 - details=details, current=current, 310 + ctx, 311 + by_repo, 312 + details=details, 313 + current=current, 321 314 limit=config.concurrency().limit, 322 315 ) 323 316 ··· 326 319 status = facts[b.repo_name].node_status.get(b.branch) 327 320 if status is None: 328 321 status = graph.NodeStatus( 329 - synced=False, commit_count=0, ahead_of_remote=0, 330 - has_upstream=False, dirty=False, is_current=False, 322 + synced=False, 323 + commit_count=0, 324 + ahead_of_remote=0, 325 + has_upstream=False, 326 + dirty=False, 327 + is_current=False, 331 328 merged=pr is not None and pr.merged, 332 329 ) 333 330 entry: dict[str, object] = { ··· 338 335 "is_root": (b.parent_repo_name, b.parent_branch) not in tracked_keys, 339 336 "pr_url": pr.pr_url if pr is not None else None, 340 337 "merged": pr.merged if pr is not None else False, 341 - "status": _STATUS_JSON[ 342 - graph.classify(b, pr, status, online=False) 343 - ] if include_state else None, 338 + "status": _STATUS_JSON[graph.classify(b, pr, status, online=False)] 339 + if include_state 340 + else None, 344 341 "needs_sync": (not status.synced) if include_state else None, 345 342 "ahead_of_remote": status.ahead_of_remote if include_counts else None, 346 343 "commit_count": status.commit_count if include_counts else None, ··· 367 364 }, 368 365 indent=2, 369 366 ) 370 - 371 -
+32 -22
src/project_manager/stacker/render/prefetch.py
··· 70 70 """ 71 71 try: 72 72 parent_head = git.rev_parse( 73 - ctx.paths.repo(tracked.parent_repo_name), tracked.parent_branch, 73 + ctx.paths.repo(tracked.parent_repo_name), 74 + tracked.parent_branch, 74 75 ) 75 76 except git.GitError: 76 77 return False ··· 143 144 if wt_path is None or ni.details == "none": 144 145 synced = await asyncio.to_thread(_is_synced, ctx, ni.tracked) 145 146 return NodeStatus( 146 - synced=synced, commit_count=0, ahead_of_remote=0, 147 - has_upstream=False, dirty=False, 148 - is_current=is_current, merged=merged, 147 + synced=synced, 148 + commit_count=0, 149 + ahead_of_remote=0, 150 + has_upstream=False, 151 + dirty=False, 152 + is_current=is_current, 153 + merged=merged, 149 154 ) 150 155 151 156 # Fan out the independent calls: is_synced (parent-repo rev-parse), ··· 162 167 asyncio.to_thread(_safe_upstream_branch, wt_path), 163 168 ) 164 169 t_commits = ( 165 - tg.create_task(asyncio.to_thread( 166 - _safe_rev_count, wt_path, 167 - f"{ni.tracked.managed_base_commit}..HEAD", 168 - )) 170 + tg.create_task( 171 + asyncio.to_thread( 172 + _safe_rev_count, 173 + wt_path, 174 + f"{ni.tracked.managed_base_commit}..HEAD", 175 + ) 176 + ) 169 177 if need_counts 170 178 else None 171 179 ) ··· 181 189 # a single rev-list call and 99% of branches have an upstream. 182 190 if upstream_full: 183 191 ahead_of_remote = await asyncio.to_thread( 184 - _safe_rev_count, wt_path, f"{upstream_full}..HEAD", 192 + _safe_rev_count, 193 + wt_path, 194 + f"{upstream_full}..HEAD", 185 195 ) 186 196 else: 187 197 ahead_of_remote = 0 188 198 189 199 return NodeStatus( 190 - synced=synced, commit_count=commit_count, 191 - ahead_of_remote=ahead_of_remote, has_upstream=has_upstream, 192 - dirty=dirty, is_current=is_current, merged=merged, 200 + synced=synced, 201 + commit_count=commit_count, 202 + ahead_of_remote=ahead_of_remote, 203 + has_upstream=has_upstream, 204 + dirty=dirty, 205 + is_current=is_current, 206 + merged=merged, 193 207 ) 194 208 195 209 ··· 206 220 207 221 def _live_sync() -> dict[str, git.WorktreeInfo]: 208 222 try: 209 - return { 210 - info.branch: info 211 - for info in git.worktree_list(repo_path) 212 - if info.branch 213 - } 223 + return {info.branch: info for info in git.worktree_list(repo_path) if info.branch} 214 224 except git.GitError: 215 225 return {} 216 226 ··· 224 234 ) 225 235 226 236 current_branch = ( 227 - opts.current[1] 228 - if opts.current is not None and opts.current[0] == repo_name 229 - else None 237 + opts.current[1] if opts.current is not None and opts.current[0] == repo_name else None 230 238 ) 231 239 232 240 async def _node(item: TrackedBranch) -> tuple[str, NodeStatus]: ··· 245 253 # Bound the per-repo node fanout — one repo on a large stack can 246 254 # spawn tens of nodes, each firing 3-4 git children via to_thread. 247 255 pairs = await bounded_gather( 248 - (_node(item) for item in items), limit=opts.limit, 256 + (_node(item) for item in items), 257 + limit=opts.limit, 249 258 ) 250 259 return RepoFacts( 251 260 live=live, ··· 272 281 # to 100 git children, and each spends most of its wall time in an 273 282 # fsmonitor read — CPU stays low. 274 283 pairs = await bounded_gather( 275 - (_one(name) for name in repo_names), limit=opts.limit, 284 + (_one(name) for name in repo_names), 285 + limit=opts.limit, 276 286 ) 277 287 return dict(pairs) 278 288
+1 -3
src/project_manager/stacker/render/status.py
··· 17 17 lines = [selectors.selector_for(target.repo_name, target.branch)] 18 18 lines.append(f"checked out in: {slot_path or '-'}") 19 19 if tracked: 20 - parent_label = selectors.selector_for( 21 - tracked.parent_repo_name, tracked.parent_branch 22 - ) 20 + parent_label = selectors.selector_for(tracked.parent_repo_name, tracked.parent_branch) 23 21 lines.extend( 24 22 [ 25 23 f"parent: {parent_label}",
+2 -6
src/project_manager/stacker/selectors.py
··· 19 19 def parse_selector(text: str) -> ParsedSelector: 20 20 if ":" in text: 21 21 repo_query, branch = text.split(":", 1) 22 - return ParsedSelector( 23 - repo_query=repo_query, branch=branch or None, is_root=branch == "" 24 - ) 22 + return ParsedSelector(repo_query=repo_query, branch=branch or None, is_root=branch == "") 25 23 return ParsedSelector(repo_query=None, branch=text, is_root=False) 26 24 27 25 ··· 118 116 parts = relative.parts 119 117 if not parts: 120 118 return None 121 - return RepoContext( 122 - repo_name=parts[0], worktree_path=ctx.worktree_path, branch=ctx.branch 123 - ) 119 + return RepoContext(repo_name=parts[0], worktree_path=ctx.worktree_path, branch=ctx.branch)
+17 -11
src/project_manager/stacker/service.py
··· 10 10 methods. No private passthroughs — tests that need internals import 11 11 them directly from the new modules. 12 12 """ 13 + 13 14 from __future__ import annotations 14 15 15 16 from collections.abc import Callable ··· 138 139 self._ctx, repo_name, branch, parent, copy_from=copy_from 139 140 ) 140 141 141 - def track( 142 - self, target: SelectorTarget, parent: ParentLocator 143 - ) -> TrackedBranch: 142 + def track(self, target: SelectorTarget, parent: ParentLocator) -> TrackedBranch: 144 143 return track_ops.track(self._ctx, target, parent) 145 144 146 145 # --- mutate --- ··· 162 161 ) 163 162 164 163 def reparent( 165 - self, target: SelectorTarget, new_parent: ParentLocator, 166 - *, options: SyncOptions = DEFAULT_SYNC_OPTIONS, 164 + self, 165 + target: SelectorTarget, 166 + new_parent: ParentLocator, 167 + *, 168 + options: SyncOptions = DEFAULT_SYNC_OPTIONS, 167 169 ) -> str: 168 170 return reparent_ops.reparent( 169 - self._ctx, target, new_parent, options=options, 171 + self._ctx, 172 + target, 173 + new_parent, 174 + options=options, 170 175 ) 171 176 172 177 def split( ··· 177 182 *, 178 183 stay: bool = False, 179 184 ) -> str: 180 - return split_ops.split( 181 - self._ctx, target, new_name, split_commit, stay=stay 182 - ) 185 + return split_ops.split(self._ctx, target, new_name, split_commit, stay=stay) 183 186 184 187 def rename(self, target: SelectorTarget, new_name: str) -> str: 185 188 return rename_ops.rename(self._ctx, target, new_name) ··· 187 190 # --- sync / push / repair --- 188 191 189 192 def sync( 190 - self, target: SelectorTarget, spec: ScopeSpec = DEFAULT_SCOPE, 191 - *, options: SyncOptions = DEFAULT_SYNC_OPTIONS, 193 + self, 194 + target: SelectorTarget, 195 + spec: ScopeSpec = DEFAULT_SCOPE, 196 + *, 197 + options: SyncOptions = DEFAULT_SYNC_OPTIONS, 192 198 ) -> str: 193 199 return sync_ops.sync(self._ctx, target, spec, options=options) 194 200
+11 -6
src/project_manager/stacker/slot.py
··· 12 12 `pm stacker sync` on the cwd slot after the user explicitly detached HEAD 13 13 to free it; previously each flow had its own ad-hoc resolution. 14 14 """ 15 + 15 16 from __future__ import annotations 16 17 17 18 import contextlib ··· 88 89 pooldb = PoolDB(ctx.paths.pool_db()) 89 90 try: 90 91 claimed = ops_slot.acquire( 91 - ctx.paths, pooldb, repo_name, branch, 92 + ctx.paths, 93 + pooldb, 94 + repo_name, 95 + branch, 92 96 wait=ops_slot.WaitOptions(progress=ctx.progress), 93 97 ) 94 98 except slot_mod.PoolExhaustedError: 95 99 fallback = _cwd_reuse_path( 96 - ctx, repo_name, 100 + ctx, 101 + repo_name, 97 102 CwdReusePolicy(enabled=cwd_reuse.enabled, allow_branch_switch=True), 98 103 ) 99 104 if fallback is None: ··· 121 126 return AcquiredSlot(path=cwd_path, ops=None) 122 127 pooldb = PoolDB(ctx.paths.pool_db()) 123 128 claimed = ops_slot.claim( 124 - ctx.paths, pooldb, repo_name, 129 + ctx.paths, 130 + pooldb, 131 + repo_name, 125 132 wait=ops_slot.WaitOptions(progress=ctx.progress), 126 133 ) 127 134 return AcquiredSlot(path=claimed.path, ops=claimed) ··· 214 221 return True 215 222 216 223 217 - def _cwd_reuse_path( 218 - ctx: StackerCtx, repo_name: str, policy: CwdReusePolicy 219 - ) -> Path | None: 224 + def _cwd_reuse_path(ctx: StackerCtx, repo_name: str, policy: CwdReusePolicy) -> Path | None: 220 225 if not policy.enabled: 221 226 return None 222 227 here = locate.slot_for_cwd(ctx.paths)
+8 -3
src/project_manager/subprocess_run.py
··· 47 47 cwd_str = str(cwd) if cwd is not None else None 48 48 49 49 proc = subprocess.Popen( 50 - cmd, cwd=cwd_str, env=full_env, text=True, 51 - stdout=subprocess.PIPE, stderr=subprocess.PIPE, 50 + cmd, 51 + cwd=cwd_str, 52 + env=full_env, 53 + text=True, 54 + stdout=subprocess.PIPE, 55 + stderr=subprocess.PIPE, 52 56 ) 53 57 cmd_name = Path(cmd[0]).name if cmd else "" 54 58 pid = proc.pid ··· 147 151 148 152 149 153 def format_command_failure( 150 - cmd: list[str], proc: subprocess.CompletedProcess[str], 154 + cmd: list[str], 155 + proc: subprocess.CompletedProcess[str], 151 156 ) -> str: 152 157 header = f"command failed: {' '.join(cmd)}" 153 158 events: list[SubprocessLogLine] | None = getattr(proc, "log_events", None)
+24 -12
src/project_manager/tui.py
··· 58 58 # a flat decode (esc-peek + CSI parse + lookup) with only the escape 59 59 # branch as control flow. 60 60 _SINGLE_BYTE_KEYS: dict[bytes, Key] = { 61 - b"\r": Key.ENTER, b"\n": Key.ENTER, 62 - b"k": Key.UP, b"\x10": Key.UP, # k / Ctrl-P 63 - b"j": Key.DOWN, b"\x0e": Key.DOWN, # j / Ctrl-N 64 - b"q": Key.CANCEL, b"\x03": Key.CANCEL, # q / Ctrl-C byte 61 + b"\r": Key.ENTER, 62 + b"\n": Key.ENTER, 63 + b"k": Key.UP, 64 + b"\x10": Key.UP, # k / Ctrl-P 65 + b"j": Key.DOWN, 66 + b"\x0e": Key.DOWN, # j / Ctrl-N 67 + b"q": Key.CANCEL, 68 + b"\x03": Key.CANCEL, # q / Ctrl-C byte 65 69 } 66 70 67 71 ··· 116 120 """ 117 121 materialized = [(s.title, list(s.rows)) for s in sections] 118 122 flat_rows: list[T] = [r for _, rs in materialized for r in rs] 119 - selectable_idxs = [ 120 - i for i, r in enumerate(flat_rows) if is_selectable(r) 121 - ] 123 + selectable_idxs = [i for i, r in enumerate(flat_rows) if is_selectable(r)] 122 124 if not selectable_idxs: 123 125 return None 124 126 if not sys.stdin.isatty(): ··· 130 132 # → KeyboardInterrupt, which we catch below as a cancel. 131 133 tty.setcbreak(fd) 132 134 return _pick_core( 133 - materialized, flat_rows, selectable_idxs, columns, 134 - group=group, header=header, 135 - read_key=_read_key_stdin, console=Console(stderr=True), 135 + materialized, 136 + flat_rows, 137 + selectable_idxs, 138 + columns, 139 + group=group, 140 + header=header, 141 + read_key=_read_key_stdin, 142 + console=Console(stderr=True), 136 143 ) 137 144 finally: 138 145 termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs) ··· 159 166 160 167 def frame() -> RenderableType: 161 168 table = render.build_sections_table( 162 - materialized, columns, group=group, 169 + materialized, 170 + columns, 171 + group=group, 163 172 selected_flat_idx=selectable_idxs[cur], 164 173 ) 165 174 parts: list[RenderableType] = [] ··· 174 183 # transient=True wipes the picker on exit so the shell scrollback stays 175 184 # clean whether we cancel or exec into an agent immediately after. 176 185 with Live( 177 - frame(), console=console, auto_refresh=False, transient=True, 186 + frame(), 187 + console=console, 188 + auto_refresh=False, 189 + transient=True, 178 190 ) as live: 179 191 while True: 180 192 try:
+89 -35
tests/agent/test_ls.py
··· 38 38 39 39 40 40 def _make_claude_session( 41 - home: Path, cwd: Path, session_id: str, prompt: str, mtime: float, 41 + home: Path, 42 + cwd: Path, 43 + session_id: str, 44 + prompt: str, 45 + mtime: float, 42 46 ) -> None: 43 47 import os 48 + 44 49 encoded = str(cwd).replace("/", "-").replace(".", "-") 45 50 session_dir = home / ".claude" / "projects" / encoded 46 51 session_dir.mkdir(parents=True, exist_ok=True) 47 52 f = session_dir / f"{session_id}.jsonl" 48 53 f.write_text( 49 - json.dumps({"type": "user", "message": {"role": "user", 50 - "content": prompt}}) + "\n", 54 + json.dumps({"type": "user", "message": {"role": "user", "content": prompt}}) + "\n", 51 55 ) 52 56 os.utime(f, (mtime, mtime)) 53 57 54 58 55 59 def _make_codex_row( 56 - home: Path, *, thread_id: str, cwd: str, title: str, updated_at: int, 60 + home: Path, 61 + *, 62 + thread_id: str, 63 + cwd: str, 64 + title: str, 65 + updated_at: int, 57 66 ) -> None: 58 67 db = home / ".codex" / "state_5.sqlite" 59 68 db.parent.mkdir(parents=True, exist_ok=True) ··· 76 85 77 86 78 87 def test_ls_merges_and_sorts_across_sources( 79 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 88 + pm_env: Paths, 89 + tmp_path: Path, 90 + monkeypatch: pytest.MonkeyPatch, 80 91 ) -> None: 81 92 git_pool(pm_env, "foo", n=1) 82 93 create_mod.create(pm_env, "demo", _just(["foo"])) ··· 88 99 # Claude: older prompt, Codex: newer. Both must match the pm 89 100 # project's owned paths via cwd = project_dir. 90 101 _make_claude_session( 91 - fake_home, project_dir, "claude-1", "old claude prompt", mtime=1000, 102 + fake_home, 103 + project_dir, 104 + "claude-1", 105 + "old claude prompt", 106 + mtime=1000, 92 107 ) 93 108 _make_codex_row( 94 - fake_home, thread_id="codex-1", cwd=str(project_dir), 95 - title="new codex title", updated_at=2000, 109 + fake_home, 110 + thread_id="codex-1", 111 + cwd=str(project_dir), 112 + title="new codex title", 113 + updated_at=2000, 96 114 ) 97 115 98 116 rows = asyncio.run( ··· 104 122 105 123 106 124 def test_ls_limit_applies_after_merge( 107 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 125 + pm_env: Paths, 126 + tmp_path: Path, 127 + monkeypatch: pytest.MonkeyPatch, 108 128 ) -> None: 109 129 git_pool(pm_env, "foo", n=1) 110 130 create_mod.create(pm_env, "demo", _just(["foo"])) ··· 115 135 116 136 for i in range(3): 117 137 _make_claude_session( 118 - fake_home, project_dir, f"c-{i}", f"cq{i}", mtime=100 + i, 138 + fake_home, 139 + project_dir, 140 + f"c-{i}", 141 + f"cq{i}", 142 + mtime=100 + i, 119 143 ) 120 144 for i in range(3): 121 145 _make_codex_row( 122 - fake_home, thread_id=f"x-{i}", cwd=str(project_dir), 123 - title=f"xt{i}", updated_at=200 + i, 146 + fake_home, 147 + thread_id=f"x-{i}", 148 + cwd=str(project_dir), 149 + title=f"xt{i}", 150 + updated_at=200 + i, 124 151 ) 125 152 rows = asyncio.run( 126 153 ls_mod.ls(pm_env, ["demo"], limit=2, agents=parse_agents(None)), ··· 131 158 132 159 133 160 def test_ls_emits_placeholder_row_for_project_with_no_sessions( 134 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 161 + pm_env: Paths, 162 + tmp_path: Path, 163 + monkeypatch: pytest.MonkeyPatch, 135 164 ) -> None: 136 165 """A pm project with zero chats across all agents still gets a 137 166 section — a single row where every data column is empty so the ··· 158 187 159 188 160 189 def test_ls_propagates_exceptions_from_sources( 161 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 190 + pm_env: Paths, 191 + tmp_path: Path, 192 + monkeypatch: pytest.MonkeyPatch, 162 193 ) -> None: 163 194 git_pool(pm_env, "foo", n=1) 164 195 create_mod.create(pm_env, "demo", _just(["foo"])) ··· 173 204 with pytest.raises(BaseExceptionGroup) as exc_info: 174 205 asyncio.run(ls_mod.ls(pm_env, ["demo"], 5, parse_agents(None))) 175 206 assert any( 176 - isinstance(e, RuntimeError) and "source fault" in str(e) 177 - for e in exc_info.value.exceptions 178 - ) or any( 179 - isinstance(e, BaseExceptionGroup) 180 - for e in exc_info.value.exceptions 181 - ) 207 + isinstance(e, RuntimeError) and "source fault" in str(e) for e in exc_info.value.exceptions 208 + ) or any(isinstance(e, BaseExceptionGroup) for e in exc_info.value.exceptions) 182 209 183 210 184 211 def test_ls_runs_sources_concurrently( 185 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 212 + pm_env: Paths, 213 + tmp_path: Path, 214 + monkeypatch: pytest.MonkeyPatch, 186 215 ) -> None: 187 216 """Each source sleeps for DELAY in a worker thread. With N sources 188 217 fanned out concurrently, total wall time approaches DELAY, not ··· 215 244 216 245 217 246 def _prepare_resume_env( 218 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 247 + pm_env: Paths, 248 + tmp_path: Path, 249 + monkeypatch: pytest.MonkeyPatch, 219 250 ) -> Path: 220 251 """Create a pm project, fake $HOME, chdir inside it. Returns project_dir.""" 221 252 from project_manager.project import create as create_mod 253 + 222 254 git_pool(pm_env, "foo", n=1) 223 255 create_mod.create(pm_env, "demo", _just(["foo"])) 224 256 fake_home = tmp_path / "home" ··· 247 279 248 280 def _force_tty(monkeypatch: pytest.MonkeyPatch) -> None: 249 281 import sys as _sys 282 + 250 283 monkeypatch.setattr(_sys, "stdin", _TTYWrapper(_sys.stdin)) 251 284 monkeypatch.setattr(_sys, "stderr", _TTYWrapper(_sys.stderr)) 252 285 253 286 254 287 def test_resume_combined_with_json_fails_fast( 255 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 288 + pm_env: Paths, 289 + tmp_path: Path, 290 + monkeypatch: pytest.MonkeyPatch, 256 291 capsys: pytest.CaptureFixture[str], 257 292 ) -> None: 258 293 _prepare_resume_env(pm_env, tmp_path, monkeypatch) 259 294 from project_manager import cli as cli_mod 295 + 260 296 rc = cli_mod.main(["agent", "ls", "--resume", "--json"]) 261 297 assert rc == 2 262 298 assert "--resume cannot be combined with --json" in capsys.readouterr().err 263 299 264 300 265 301 def test_resume_requires_tty( 266 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 302 + pm_env: Paths, 303 + tmp_path: Path, 304 + monkeypatch: pytest.MonkeyPatch, 267 305 capsys: pytest.CaptureFixture[str], 268 306 ) -> None: 269 307 _prepare_resume_env(pm_env, tmp_path, monkeypatch) ··· 271 309 class NonTTY: 272 310 def isatty(self) -> bool: 273 311 return False 312 + 274 313 monkeypatch.setattr("sys.stdin", NonTTY()) 275 314 from project_manager import cli as cli_mod 315 + 276 316 rc = cli_mod.main(["agent", "ls", "--resume"]) 277 317 assert rc == 2 278 - assert ( 279 - "--resume requires an interactive terminal" 280 - in capsys.readouterr().err 281 - ) 318 + assert "--resume requires an interactive terminal" in capsys.readouterr().err 282 319 283 320 284 321 def test_resume_empty_prints_note_and_exits_zero( 285 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 322 + pm_env: Paths, 323 + tmp_path: Path, 324 + monkeypatch: pytest.MonkeyPatch, 286 325 capsys: pytest.CaptureFixture[str], 287 326 ) -> None: 288 327 """No real sessions → placeholder-only rows; `_resume` short-circuits ··· 292 331 293 332 # Ensure `tui.pick` isn't reached — loud failure if it is. 294 333 from project_manager import tui 334 + 295 335 def boom(*_args: object, **_kwargs: object) -> None: 296 336 raise AssertionError("picker should not run when no sessions exist") 337 + 297 338 monkeypatch.setattr(tui, "pick", boom) 298 339 299 340 from project_manager import cli as cli_mod 341 + 300 342 rc = cli_mod.main(["agent", "ls", "--resume"]) 301 343 assert rc == 0 302 344 assert "no sessions to resume" in capsys.readouterr().err 303 345 304 346 305 347 def test_resume_cancel_path_exits_zero_without_exec( 306 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 348 + pm_env: Paths, 349 + tmp_path: Path, 350 + monkeypatch: pytest.MonkeyPatch, 307 351 ) -> None: 308 352 """Picker returns None (user hit Esc) → CLI returns 0, never calls 309 353 `run_mod.run`, so no execvp happens.""" ··· 311 355 _force_tty(monkeypatch) 312 356 # Seed one real session so the empty-short-circuit doesn't fire. 313 357 _make_codex_row( 314 - tmp_path / "home", thread_id="c-1", 358 + tmp_path / "home", 359 + thread_id="c-1", 315 360 cwd=str(pm_env.project("demo")), 316 - title="t", updated_at=1000, 361 + title="t", 362 + updated_at=1000, 317 363 ) 318 364 from project_manager import tui 319 365 from project_manager.agent import run as run_mod 366 + 320 367 monkeypatch.setattr(tui, "pick", lambda *_a, **_kw: None) 321 368 322 369 def no_run(*_args: object, **_kwargs: object) -> None: 323 370 raise AssertionError("run should not be called on cancel") 371 + 324 372 monkeypatch.setattr(run_mod, "run", no_run) 325 373 326 374 from project_manager import cli as cli_mod 375 + 327 376 rc = cli_mod.main(["agent", "ls", "--resume"]) 328 377 assert rc == 0 329 378 330 379 331 380 def test_resume_happy_path_execs_with_agent_resume_args( 332 - pm_env: Paths, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 381 + pm_env: Paths, 382 + tmp_path: Path, 383 + monkeypatch: pytest.MonkeyPatch, 333 384 ) -> None: 334 385 """On ENTER, CLI dispatches to `run_mod.run(agent, project, forwarded)` 335 386 where `forwarded` is exactly what `resume_args` produces for that ··· 338 389 _prepare_resume_env(pm_env, tmp_path, monkeypatch) 339 390 _force_tty(monkeypatch) 340 391 _make_codex_row( 341 - tmp_path / "home", thread_id="thread-xyz", 392 + tmp_path / "home", 393 + thread_id="thread-xyz", 342 394 cwd=str(pm_env.project("demo")), 343 - title="resume me", updated_at=1000, 395 + title="resume me", 396 + updated_at=1000, 344 397 ) 345 398 346 399 # Stub the picker to return the one codex row we seeded. Reach into ··· 368 421 monkeypatch.setattr(run_mod, "run", fake_run) 369 422 370 423 from project_manager import cli as cli_mod 424 + 371 425 with pytest.raises(SystemExit): 372 426 cli_mod.main(["agent", "ls", "--resume"]) 373 427
+22 -7
tests/agent/test_run.py
··· 67 67 68 68 def test_build_command_cursor_no_forwarded_args() -> None: 69 69 _cwd, argv = run_mod.build_command( 70 - AgentName.CURSOR, Path("/proj/a"), (), {"cursor": "agent"}, 70 + AgentName.CURSOR, 71 + Path("/proj/a"), 72 + (), 73 + {"cursor": "agent"}, 71 74 ) 72 75 assert argv == ["agent"] 73 76 ··· 75 78 def test_build_command_missing_config_raises() -> None: 76 79 with pytest.raises(ProjectError, match=r"no \[agents\.commands\]\.codex"): 77 80 run_mod.build_command( 78 - AgentName.CODEX, Path("/proj/a"), (), {"claude": "isaac"}, 81 + AgentName.CODEX, 82 + Path("/proj/a"), 83 + (), 84 + {"claude": "isaac"}, 79 85 ) 80 86 81 87 82 88 def test_build_command_empty_string_raises() -> None: 83 89 with pytest.raises(ProjectError, match="is empty"): 84 90 run_mod.build_command( 85 - AgentName.CLAUDE, Path("/proj/a"), (), {"claude": " "}, 91 + AgentName.CLAUDE, 92 + Path("/proj/a"), 93 + (), 94 + {"claude": " "}, 86 95 ) 87 96 88 97 ··· 96 105 97 106 98 107 def _write_config_with_agents( 99 - pm_env: Paths, commands: dict[str, str], 108 + pm_env: Paths, 109 + commands: dict[str, str], 100 110 ) -> None: 101 111 """The pm_env fixture already wrote a `[paths]`-only config and set 102 112 PM_CONFIG. Append an `[agents.commands]` table by rewriting the file 103 113 with the same paths plus the supplied agent commands. 104 114 """ 105 115 import os 116 + 106 117 cfg_path = Path(os.environ["PM_CONFIG"]) 107 118 body = ( 108 119 "[paths]\n" ··· 118 129 119 130 120 131 def test_run_execvps_the_configured_command_in_project_dir( 121 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, 132 + pm_env: Paths, 133 + monkeypatch: pytest.MonkeyPatch, 122 134 ) -> None: 123 135 project_dir = _prepare_project(pm_env) 124 136 _write_config_with_agents(pm_env, {"claude": "isaac"}) ··· 151 163 152 164 153 165 def test_run_rewraps_filenotfound_as_project_error( 154 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, 166 + pm_env: Paths, 167 + monkeypatch: pytest.MonkeyPatch, 155 168 ) -> None: 156 169 _prepare_project(pm_env) 157 170 _write_config_with_agents(pm_env, {"claude": "definitely-not-on-path"}) ··· 170 183 171 184 172 185 def test_run_raises_when_cwd_not_in_any_project( 173 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 186 + pm_env: Paths, 187 + monkeypatch: pytest.MonkeyPatch, 188 + tmp_path: Path, 174 189 ) -> None: 175 190 """Mirrors the `-p` requirement when the user is outside any pm 176 191 project — uses the existing `resolve_project` behavior, so we
+91 -48
tests/agent/test_sources_claude.py
··· 23 23 24 24 25 25 def test_returns_empty_when_claude_root_missing( 26 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 26 + tmp_path: Path, 27 + monkeypatch: pytest.MonkeyPatch, 27 28 ) -> None: 28 29 _home(tmp_path, monkeypatch) 29 30 cwd = Path("/some/where") ··· 31 32 32 33 33 34 def test_picks_first_user_message_when_no_summary( 34 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 35 + tmp_path: Path, 36 + monkeypatch: pytest.MonkeyPatch, 35 37 ) -> None: 36 38 home = _home(tmp_path, monkeypatch) 37 39 cwd = Path("/proj/a") 38 40 session_dir = home / ".claude" / "projects" / _encoded(cwd) 39 41 f = session_dir / "abc123.jsonl" 40 - _write_jsonl(f, [ 41 - {"type": "user", "message": {"role": "user", "content": "build me a thing"}}, 42 - {"type": "assistant", "message": {"role": "assistant", "model": "claude-4", 43 - "content": [{"type": "text", "text": "ok"}]}}, 44 - ]) 42 + _write_jsonl( 43 + f, 44 + [ 45 + {"type": "user", "message": {"role": "user", "content": "build me a thing"}}, 46 + { 47 + "type": "assistant", 48 + "message": { 49 + "role": "assistant", 50 + "model": "claude-4", 51 + "content": [{"type": "text", "text": "ok"}], 52 + }, 53 + }, 54 + ], 55 + ) 45 56 entries = asyncio.run(claude.fetch({cwd}, 5)) 46 57 assert len(entries) == 1 47 58 assert entries[0].session_id == "abc123" ··· 51 62 52 63 53 64 def test_synthetic_summary_beats_first_user( 54 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 65 + tmp_path: Path, 66 + monkeypatch: pytest.MonkeyPatch, 55 67 ) -> None: 56 68 home = _home(tmp_path, monkeypatch) 57 69 cwd = Path("/proj/a") 58 70 f = home / ".claude" / "projects" / _encoded(cwd) / "xyz.jsonl" 59 71 synth_text = "Summary:\n\nFinal: did a thing." 60 - _write_jsonl(f, [ 61 - {"type": "user", "message": {"role": "user", "content": "original prompt"}}, 62 - {"type": "assistant", "message": { 63 - "role": "assistant", "model": "<synthetic>", 64 - "content": [{"type": "text", "text": synth_text}], 65 - }}, 66 - ]) 72 + _write_jsonl( 73 + f, 74 + [ 75 + {"type": "user", "message": {"role": "user", "content": "original prompt"}}, 76 + { 77 + "type": "assistant", 78 + "message": { 79 + "role": "assistant", 80 + "model": "<synthetic>", 81 + "content": [{"type": "text", "text": synth_text}], 82 + }, 83 + }, 84 + ], 85 + ) 67 86 entries = asyncio.run(claude.fetch({cwd}, 5)) 68 87 assert entries[0].title == "Final: did a thing." 69 88 70 89 71 90 def test_skips_injected_system_user_messages( 72 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 91 + tmp_path: Path, 92 + monkeypatch: pytest.MonkeyPatch, 73 93 ) -> None: 74 94 home = _home(tmp_path, monkeypatch) 75 95 cwd = Path("/proj/a") 76 96 f = home / ".claude" / "projects" / _encoded(cwd) / "sysfirst.jsonl" 77 97 caveat = "<local-command-caveat>skip me</local-command-caveat>" 78 - _write_jsonl(f, [ 79 - {"type": "user", "message": {"role": "user", "content": caveat}}, 80 - {"type": "user", "message": {"role": "user", 81 - "content": "real question here"}}, 82 - ]) 98 + _write_jsonl( 99 + f, 100 + [ 101 + {"type": "user", "message": {"role": "user", "content": caveat}}, 102 + {"type": "user", "message": {"role": "user", "content": "real question here"}}, 103 + ], 104 + ) 83 105 entries = asyncio.run(claude.fetch({cwd}, 5)) 84 106 assert entries[0].title == "real question here" 85 107 86 108 87 109 def test_ignores_non_summary_synthetic_messages( 88 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 110 + tmp_path: Path, 111 + monkeypatch: pytest.MonkeyPatch, 89 112 ) -> None: 90 113 """<synthetic> model is used for non-response stubs + API error 91 114 envelopes, not just compaction summaries. Only text starting with ··· 95 118 home = _home(tmp_path, monkeypatch) 96 119 cwd = Path("/proj/a") 97 120 f = home / ".claude" / "projects" / _encoded(cwd) / "xyz.jsonl" 98 - _write_jsonl(f, [ 99 - {"type": "user", "message": {"role": "user", 100 - "content": "the actual question"}}, 101 - # Not a summary — just a non-response stub Claude emits. 102 - {"type": "assistant", "message": { 103 - "role": "assistant", "model": "<synthetic>", 104 - "content": [{"type": "text", "text": "No response requested."}], 105 - }}, 106 - ]) 121 + _write_jsonl( 122 + f, 123 + [ 124 + {"type": "user", "message": {"role": "user", "content": "the actual question"}}, 125 + # Not a summary — just a non-response stub Claude emits. 126 + { 127 + "type": "assistant", 128 + "message": { 129 + "role": "assistant", 130 + "model": "<synthetic>", 131 + "content": [{"type": "text", "text": "No response requested."}], 132 + }, 133 + }, 134 + ], 135 + ) 107 136 entries = asyncio.run(claude.fetch({cwd}, 5)) 108 137 assert entries[0].title == "the actual question" 109 138 110 139 111 140 def test_skips_bash_only_sessions( 112 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 141 + tmp_path: Path, 142 + monkeypatch: pytest.MonkeyPatch, 113 143 ) -> None: 114 144 home = _home(tmp_path, monkeypatch) 115 145 cwd = Path("/proj/a") 116 146 # Every user message is a system-injected wrapper — no synthetic 117 147 # summary either. Session is noise; shouldn't appear in the listing. 118 148 f = home / ".claude" / "projects" / _encoded(cwd) / "bash-only.jsonl" 119 - _write_jsonl(f, [ 120 - {"type": "user", "message": {"role": "user", 121 - "content": "<bash-input>ls</bash-input>"}}, 122 - {"type": "user", "message": {"role": "user", 123 - "content": "<bash-stdout>hi</bash-stdout>"}}, 124 - ]) 149 + _write_jsonl( 150 + f, 151 + [ 152 + {"type": "user", "message": {"role": "user", "content": "<bash-input>ls</bash-input>"}}, 153 + { 154 + "type": "user", 155 + "message": {"role": "user", "content": "<bash-stdout>hi</bash-stdout>"}, 156 + }, 157 + ], 158 + ) 125 159 assert asyncio.run(claude.fetch({cwd}, 5)) == [] 126 160 127 161 128 162 def test_keeps_scanning_past_empties_to_fill_limit( 129 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 163 + tmp_path: Path, 164 + monkeypatch: pytest.MonkeyPatch, 130 165 ) -> None: 131 166 """Even if the N newest files are all bash-only, we should keep 132 167 scanning older files until we have `limit` valid sessions. A fixed ··· 139 174 # with older mtimes. `-n 2` must still return both real ones. 140 175 for i in range(5): 141 176 f = pdir / f"empty-{i}.jsonl" 142 - _write_jsonl(f, [ 143 - {"type": "user", "message": {"role": "user", 144 - "content": "<bash-input>x</bash-input>"}}, 145 - ]) 177 + _write_jsonl( 178 + f, 179 + [ 180 + { 181 + "type": "user", 182 + "message": {"role": "user", "content": "<bash-input>x</bash-input>"}, 183 + }, 184 + ], 185 + ) 146 186 os.utime(f, (2000 + i, 2000 + i)) 147 187 for i in range(2): 148 188 f = pdir / f"real-{i}.jsonl" 149 - _write_jsonl(f, [ 150 - {"type": "user", "message": {"role": "user", 151 - "content": f"real prompt {i}"}}, 152 - ]) 189 + _write_jsonl( 190 + f, 191 + [ 192 + {"type": "user", "message": {"role": "user", "content": f"real prompt {i}"}}, 193 + ], 194 + ) 153 195 os.utime(f, (1000 + i, 1000 + i)) 154 196 entries = asyncio.run(claude.fetch({cwd}, 2)) 155 197 assert [e.session_id for e in entries] == ["real-1", "real-0"] 156 198 157 199 158 200 def test_limit_applies_to_top_n_by_mtime( 159 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 201 + tmp_path: Path, 202 + monkeypatch: pytest.MonkeyPatch, 160 203 ) -> None: 161 204 home = _home(tmp_path, monkeypatch) 162 205 cwd = Path("/proj/a")
+18 -14
tests/agent/test_sources_codex.py
··· 39 39 "INSERT INTO threads(id,cwd,title,first_user_message,updated_at,archived)" 40 40 " VALUES(:id,:cwd,:title,:first_user_message,:updated_at,:archived)", 41 41 { 42 - "title": "", "first_user_message": "", "archived": 0, "updated_at": 0, 42 + "title": "", 43 + "first_user_message": "", 44 + "archived": 0, 45 + "updated_at": 0, 43 46 **row, 44 47 }, 45 48 ) ··· 48 51 49 52 50 53 def test_returns_empty_when_db_missing( 51 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 54 + tmp_path: Path, 55 + monkeypatch: pytest.MonkeyPatch, 52 56 ) -> None: 53 57 _home(tmp_path, monkeypatch) 54 58 assert asyncio.run(codex.fetch({Path("/any")}, 5)) == [] 55 59 56 60 57 61 def test_filters_by_cwd_and_orders_by_updated_at( 58 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 62 + tmp_path: Path, 63 + monkeypatch: pytest.MonkeyPatch, 59 64 ) -> None: 60 65 home = _home(tmp_path, monkeypatch) 61 66 db = _make_db(home) 62 67 _insert(db, {"id": "a", "cwd": "/proj/a", "title": "A", "updated_at": 100}) 63 68 _insert(db, {"id": "b", "cwd": "/proj/a", "title": "B", "updated_at": 300}) 64 69 _insert(db, {"id": "c", "cwd": "/proj/b", "title": "C", "updated_at": 500}) 65 - _insert(db, {"id": "d", "cwd": "/proj/a", "first_user_message": "prompted", 66 - "updated_at": 200}) 70 + _insert(db, {"id": "d", "cwd": "/proj/a", "first_user_message": "prompted", "updated_at": 200}) 67 71 entries = asyncio.run(codex.fetch({Path("/proj/a")}, 5)) 68 72 assert [e.session_id for e in entries] == ["b", "d", "a"] 69 73 assert [e.title for e in entries] == ["B", "prompted", "A"] 70 74 71 75 72 76 def test_skips_archived_sessions( 73 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 77 + tmp_path: Path, 78 + monkeypatch: pytest.MonkeyPatch, 74 79 ) -> None: 75 80 home = _home(tmp_path, monkeypatch) 76 81 db = _make_db(home) 77 82 _insert(db, {"id": "live", "cwd": "/p", "title": "L", "updated_at": 100}) 78 - _insert(db, {"id": "dead", "cwd": "/p", "title": "D", "updated_at": 200, 79 - "archived": 1}) 83 + _insert(db, {"id": "dead", "cwd": "/p", "title": "D", "updated_at": 200, "archived": 1}) 80 84 entries = asyncio.run(codex.fetch({Path("/p")}, 5)) 81 85 assert [e.session_id for e in entries] == ["live"] 82 86 83 87 84 88 def test_skips_rows_with_no_title_and_no_first_user_message( 85 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 89 + tmp_path: Path, 90 + monkeypatch: pytest.MonkeyPatch, 86 91 ) -> None: 87 92 home = _home(tmp_path, monkeypatch) 88 93 db = _make_db(home) 89 94 _insert(db, {"id": "empty", "cwd": "/p", "updated_at": 100}) 90 - _insert(db, {"id": "real", "cwd": "/p", "title": "real title", 91 - "updated_at": 50}) 95 + _insert(db, {"id": "real", "cwd": "/p", "title": "real title", "updated_at": 50}) 92 96 entries = asyncio.run(codex.fetch({Path("/p")}, 5)) 93 97 assert [e.session_id for e in entries] == ["real"] 94 98 95 99 96 100 def test_respects_limit( 97 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 101 + tmp_path: Path, 102 + monkeypatch: pytest.MonkeyPatch, 98 103 ) -> None: 99 104 home = _home(tmp_path, monkeypatch) 100 105 db = _make_db(home) 101 106 for i in range(10): 102 - _insert(db, {"id": f"s{i}", "cwd": "/p", "title": f"t{i}", 103 - "updated_at": i}) 107 + _insert(db, {"id": f"s{i}", "cwd": "/p", "title": f"t{i}", "updated_at": i}) 104 108 entries = asyncio.run(codex.fetch({Path("/p")}, 3)) 105 109 assert [e.session_id for e in entries] == ["s9", "s8", "s7"]
+75 -27
tests/agent/test_sources_cursor.py
··· 22 22 23 23 24 24 def _mk_transcript(home: Path, cwd: Path, session_id: str, lines: list[dict]) -> Path: 25 - session_dir = ( 26 - home / ".cursor" / "projects" / _encoded(cwd) / "agent-transcripts" / session_id 27 - ) 25 + session_dir = home / ".cursor" / "projects" / _encoded(cwd) / "agent-transcripts" / session_id 28 26 session_dir.mkdir(parents=True, exist_ok=True) 29 27 f = session_dir / f"{session_id}.jsonl" 30 28 f.write_text("".join(json.dumps(obj) + "\n" for obj in lines)) ··· 47 45 segments, not `--`. Cursor collapses consecutive path separators 48 46 (both `/` and `.`) into one dash.""" 49 47 from project_manager.agent.sources.cursor import _encode_path 48 + 50 49 assert ( 51 50 _encode_path(Path("/home/jordan.isaacs/.projects/pm-claude")) 52 51 == "home-jordan-isaacs-projects-pm-claude" ··· 54 53 55 54 56 55 def test_returns_empty_when_cursor_root_missing( 57 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 56 + tmp_path: Path, 57 + monkeypatch: pytest.MonkeyPatch, 58 58 ) -> None: 59 59 _home(tmp_path, monkeypatch) 60 60 assert asyncio.run(cursor.fetch({Path("/x")}, 5)) == [] 61 61 62 62 63 63 def test_prefers_store_db_name_over_transcript( 64 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 64 + tmp_path: Path, 65 + monkeypatch: pytest.MonkeyPatch, 65 66 ) -> None: 66 67 home = _home(tmp_path, monkeypatch) 67 68 cwd = Path("/proj/a") 68 69 sid = "11111111-1111-1111-1111-111111111111" 69 - _mk_transcript(home, cwd, sid, [ 70 - {"role": "user", "message": {"content": [ 71 - {"type": "text", "text": "<user_query>\nsomething\n</user_query>"}]}}, 72 - ]) 73 - _mk_store(home, "wsabc", sid, { 74 - "agentId": sid, "name": "Refactor auth layer", "createdAt": 1700000000000, 75 - }) 70 + _mk_transcript( 71 + home, 72 + cwd, 73 + sid, 74 + [ 75 + { 76 + "role": "user", 77 + "message": { 78 + "content": [{"type": "text", "text": "<user_query>\nsomething\n</user_query>"}] 79 + }, 80 + }, 81 + ], 82 + ) 83 + _mk_store( 84 + home, 85 + "wsabc", 86 + sid, 87 + { 88 + "agentId": sid, 89 + "name": "Refactor auth layer", 90 + "createdAt": 1700000000000, 91 + }, 92 + ) 76 93 entries = asyncio.run(cursor.fetch({cwd}, 5)) 77 94 assert entries[0].session_id == sid 78 95 assert entries[0].title == "Refactor auth layer" ··· 80 97 81 98 82 99 def test_falls_back_to_first_user_query_when_no_store_db( 83 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 100 + tmp_path: Path, 101 + monkeypatch: pytest.MonkeyPatch, 84 102 ) -> None: 85 103 home = _home(tmp_path, monkeypatch) 86 104 cwd = Path("/proj/a") 87 105 sid = "22222222-2222-2222-2222-222222222222" 88 - _mk_transcript(home, cwd, sid, [ 89 - {"role": "user", "message": {"content": [ 90 - {"type": "text", "text": "<user_query>\nhow do I do X?\n</user_query>"}]}}, 91 - ]) 106 + _mk_transcript( 107 + home, 108 + cwd, 109 + sid, 110 + [ 111 + { 112 + "role": "user", 113 + "message": { 114 + "content": [ 115 + {"type": "text", "text": "<user_query>\nhow do I do X?\n</user_query>"} 116 + ] 117 + }, 118 + }, 119 + ], 120 + ) 92 121 entries = asyncio.run(cursor.fetch({cwd}, 5)) 93 122 assert entries[0].title == "how do I do X?" 94 123 95 124 96 125 def test_skips_sessions_with_no_store_name_and_no_user_query( 97 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 126 + tmp_path: Path, 127 + monkeypatch: pytest.MonkeyPatch, 98 128 ) -> None: 99 129 home = _home(tmp_path, monkeypatch) 100 130 cwd = Path("/proj/a") 101 131 sid = "33333333-3333-3333-3333-333333333333" 102 132 # Transcript exists but contains no `<user_query>` — cursor's 103 133 # equivalent of a bash-only / metadata-only session. 104 - _mk_transcript(home, cwd, sid, [ 105 - {"role": "assistant", "message": {"content": [ 106 - {"type": "text", "text": "automated turn"}]}}, 107 - ]) 134 + _mk_transcript( 135 + home, 136 + cwd, 137 + sid, 138 + [ 139 + { 140 + "role": "assistant", 141 + "message": {"content": [{"type": "text", "text": "automated turn"}]}, 142 + }, 143 + ], 144 + ) 108 145 assert asyncio.run(cursor.fetch({cwd}, 5)) == [] 109 146 110 147 111 148 def test_limits_across_sessions_under_one_cwd( 112 - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, 149 + tmp_path: Path, 150 + monkeypatch: pytest.MonkeyPatch, 113 151 ) -> None: 114 152 home = _home(tmp_path, monkeypatch) 115 153 cwd = Path("/proj/a") 116 154 import os 155 + 117 156 for i in range(4): 118 157 sid = f"0000000{i}-0000-0000-0000-000000000000" 119 - f = _mk_transcript(home, cwd, sid, [ 120 - {"role": "user", "message": {"content": [ 121 - {"type": "text", "text": f"<user_query>\nq{i}\n</user_query>"}]}}, 122 - ]) 158 + f = _mk_transcript( 159 + home, 160 + cwd, 161 + sid, 162 + [ 163 + { 164 + "role": "user", 165 + "message": { 166 + "content": [{"type": "text", "text": f"<user_query>\nq{i}\n</user_query>"}] 167 + }, 168 + }, 169 + ], 170 + ) 123 171 os.utime(f, (1000 + i, 1000 + i)) 124 172 entries = asyncio.run(cursor.fetch({cwd}, 2)) 125 173 assert [e.title for e in entries] == ["q3", "q2"]
+11 -5
tests/cli/test_cd.py
··· 1 1 """Tests for `pm cd` (see cli/cd.py).""" 2 + 2 3 import pytest 3 4 4 5 # Import for side-effect: registers every sub-app including `cd`. ··· 15 16 16 17 17 18 def test_cd_project_only_prints_project_dir( 18 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 19 + pm_env: Paths, 20 + capsys: pytest.CaptureFixture[str], 19 21 ) -> None: 20 22 git_pool(pm_env, "foo", n=1) 21 23 create_mod.create(pm_env, "demo", _just_repos(["foo"])) ··· 24 26 25 27 26 28 def test_cd_print_flag_is_accepted( 27 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 29 + pm_env: Paths, 30 + capsys: pytest.CaptureFixture[str], 28 31 ) -> None: 29 32 # `--print` is consumed by the shell wrapper, but must still parse 30 33 # cleanly when the wrapper isn't sourced (or when scripts call it). ··· 35 38 36 39 37 40 def test_cd_project_and_wt_prints_forward_symlink( 38 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 41 + pm_env: Paths, 42 + capsys: pytest.CaptureFixture[str], 39 43 ) -> None: 40 44 git_pool(pm_env, "foo", n=1) 41 45 create_mod.create(pm_env, "demo", [("foo1", "foo")]) ··· 52 56 53 57 54 58 def test_cd_unknown_wt_errors( 55 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 59 + pm_env: Paths, 60 + capsys: pytest.CaptureFixture[str], 56 61 ) -> None: 57 62 git_pool(pm_env, "foo", n=1) 58 63 create_mod.create(pm_env, "demo", [("foo1", "foo")]) ··· 61 66 62 67 63 68 def test_cd_detached_wt_errors( 64 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 69 + pm_env: Paths, 70 + capsys: pytest.CaptureFixture[str], 65 71 ) -> None: 66 72 git_pool(pm_env, "foo", n=1) 67 73 create_mod.create(pm_env, "demo", [("foo1", "foo")])
+12 -5
tests/cli/test_complete.py
··· 1 1 """Smoke tests for `pm __complete` verbs (see cli/_complete.py).""" 2 + 2 3 from pathlib import Path 3 4 4 5 import pytest ··· 17 18 18 19 19 20 def test_projects_lists_created_projects( 20 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 21 + pm_env: Paths, 22 + capsys: pytest.CaptureFixture[str], 21 23 ) -> None: 22 24 git_pool(pm_env, "foo", n=1) 23 25 create_mod.create(pm_env, "demo", _just_repos(["foo"])) ··· 34 36 35 37 36 38 def test_repos_lists_pool_repos( 37 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 39 + pm_env: Paths, 40 + capsys: pytest.CaptureFixture[str], 38 41 ) -> None: 39 42 git_pool(pm_env, "foo", n=1) 40 43 git_pool(pm_env, "bar", n=1) ··· 52 55 53 56 54 57 def test_worktrees_lists_project_wts( 55 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 58 + pm_env: Paths, 59 + capsys: pytest.CaptureFixture[str], 56 60 ) -> None: 57 61 git_pool(pm_env, "foo", n=2) 58 62 git_pool(pm_env, "bar", n=1) ··· 74 78 75 79 76 80 def test_worktrees_filter_by_attach_state( 77 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 81 + pm_env: Paths, 82 + capsys: pytest.CaptureFixture[str], 78 83 ) -> None: 79 84 """state=attached/unattached filter correctly.""" 80 85 from project_manager.project import detach as detach_mod 86 + 81 87 git_pool(pm_env, "foo", n=2) 82 88 create_mod.create(pm_env, "demo", [("foo1", "foo"), ("foo2", "foo")]) 83 89 # Detach foo1 → it should appear in "unattached", not in "attached". ··· 108 114 109 115 110 116 def test_verbs_swallow_broken_config( 111 - tmp_path: Path, capsys: pytest.CaptureFixture[str], 117 + tmp_path: Path, 118 + capsys: pytest.CaptureFixture[str], 112 119 monkeypatch: pytest.MonkeyPatch, 113 120 ) -> None: 114 121 # Pointing PM_CONFIG at a bogus file must not raise — completion should
+15 -4
tests/helpers.py
··· 24 24 ) 25 25 subprocess.run(["git", "-C", str(path), "config", "user.name", "test"], check=True) 26 26 subprocess.run( 27 - ["git", "-C", str(path), "config", "commit.gpgsign", "false"], check=True, 27 + ["git", "-C", str(path), "config", "commit.gpgsign", "false"], 28 + check=True, 28 29 ) 29 30 (path / "README.md").write_text("# test\n") 30 31 subprocess.run(["git", "-C", str(path), "add", "README.md"], check=True) 31 32 subprocess.run( 32 33 [ 33 - "git", "-C", str(path), "-c", "core.hooksPath=/dev/null", 34 - "commit", "-q", "-m", "init", 34 + "git", 35 + "-C", 36 + str(path), 37 + "-c", 38 + "core.hooksPath=/dev/null", 39 + "commit", 40 + "-q", 41 + "-m", 42 + "init", 35 43 ], 36 44 check=True, 37 45 ) 38 46 39 47 40 48 def git_pool( 41 - paths: Paths, repo: str, n: int = 1, branch: str = "main", 49 + paths: Paths, 50 + repo: str, 51 + n: int = 1, 52 + branch: str = "main", 42 53 ) -> list[Slot]: 43 54 """Ensure `paths.repo(repo)` is a real git repo and mint `n` worktree slots.""" 44 55 main = paths.repo(repo)
+8 -5
tests/stacker/conftest.py
··· 46 46 _git("commit", "-q", "-m", "init", cwd=repo_path) 47 47 48 48 49 - def commit_file( 50 - path: Path, relpath: str, content: str, message: str 51 - ) -> str: 49 + def commit_file(path: Path, relpath: str, content: str, message: str) -> str: 52 50 file_path = path / relpath 53 51 file_path.parent.mkdir(parents=True, exist_ok=True) 54 52 file_path.write_text(content) ··· 121 119 # fixture want production-accurate ownership so ops_slot.claim cannot 122 120 # grab a slot that already has a branch checked out. 123 121 pooldb.claim( 124 - repo_name, slot.uuid, Owner(OwnerKind.PROJECT, project_label), 122 + repo_name, 123 + slot.uuid, 124 + Owner(OwnerKind.PROJECT, project_label), 125 125 ) 126 126 slots[branch] = slot 127 127 service.init_new_branch( ··· 133 133 ) 134 134 ) 135 135 commits[branch] = commit_file( 136 - slot.path, f"{branch}.txt", f"{branch}\n", f"{branch}: first commit", 136 + slot.path, 137 + f"{branch}.txt", 138 + f"{branch}\n", 139 + f"{branch}: first commit", 137 140 ) 138 141 return TrackedStack( 139 142 repo_name=repo_name,
+4 -1
tests/stacker/fakes.py
··· 4 4 tests can assert on the exact request stacker would have sent to GitHub 5 5 without shelling out to `gh`. 6 6 """ 7 + 7 8 from __future__ import annotations 8 9 9 10 from collections.abc import Sequence ··· 96 97 return None 97 98 98 99 def batch_pr_review( 99 - self, entries: Sequence[tuple[str, str, int]], 100 + self, 101 + entries: Sequence[tuple[str, str, int]], 100 102 ) -> dict[tuple[str, str, int], gh.PRReviewSummary]: 101 103 self.review_calls.append(list(entries)) 102 104 return {e: self.review_by_pr[e] for e in entries if e in self.review_by_pr} ··· 170 172 raise NotImplementedError( 171 173 f"FakeGitClient.{name} not implemented; override in a subclass." 172 174 ) 175 + 173 176 return _not_implemented
+6 -2
tests/stacker/test_absorb.py
··· 246 246 247 247 service.db.put_operation( 248 248 OperationState( 249 - repo_name=repo_name, op_type="local_sync", status="paused", 249 + repo_name=repo_name, 250 + op_type="local_sync", 251 + status="paused", 250 252 branch="feature", 251 253 ) 252 254 ) ··· 287 289 assert b_row_after == b_row_before 288 290 # Sibling B's managed_base is still a reachable ancestor of new main. 289 291 assert stacker_git.is_ancestor( 290 - repo_path, b_row_after.managed_base_commit, _head(repo_path), 292 + repo_path, 293 + b_row_after.managed_base_commit, 294 + _head(repo_path), 291 295 ) 292 296 293 297 # Sync on B rebases it onto new main. Use skip_descendants so the walk
+10 -6
tests/stacker/test_ancestor_chain.py
··· 25 25 stacker_repo: tuple[str, Path], # noqa: ARG001 (creates demo repo) 26 26 ) -> StackerService: 27 27 return StackerService( 28 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 28 + StackerDB(pm_env.stacker_db()), 29 + pm_env, 30 + pr_backend=RecordingPRBackend(), 29 31 ) 30 32 31 33 ··· 95 97 return True 96 98 97 99 @contextlib.contextmanager 98 - def _fake_acquired_for_op( 99 - _ctx: object, _repo: str, _branch: str 100 - ) -> Iterator[_Acquired]: 100 + def _fake_acquired_for_op(_ctx: object, _repo: str, _branch: str) -> Iterator[_Acquired]: 101 101 yield _Acquired(path=service.paths.repo("demo"), ops=None) 102 102 103 103 def _fake_create( ··· 107 107 return gh.PullRequest( 108 108 number=len(pr_order), 109 109 url=f"https://gh/fake/pull/{len(pr_order)}", 110 - title="fake", body="", head_ref_name=tb.branch, 111 - base_ref_name="main", state="OPEN", is_draft=False, 110 + title="fake", 111 + body="", 112 + head_ref_name=tb.branch, 113 + base_ref_name="main", 114 + state="OPEN", 115 + is_draft=False, 112 116 ) 113 117 114 118 # push_ops uses namespace imports (`worktree.run_single_pp`) so patching the
+4 -1
tests/stacker/test_cli.py
··· 4 4 command surface: the subcommand set, scope flag grammar, and the handlers 5 5 wired to each command. 6 6 """ 7 + 7 8 import pytest 8 9 9 10 # Import for side-effect: registers every sub-app/command on `root`. ··· 86 87 ], 87 88 ) 88 89 def test_every_subcommand_is_registered( 89 - subcommand: str, args: list[str], expected_fn, 90 + subcommand: str, 91 + args: list[str], 92 + expected_fn, 90 93 ) -> None: 91 94 fn, _ = _parse(subcommand, *args) 92 95 assert fn is expected_fn
+2 -1
tests/stacker/test_config_cli.py
··· 52 52 assert _run(list_=True, json=True) == 0 53 53 payload = json.loads(capsys.readouterr().out) 54 54 assert payload == [ 55 - {"key": "pr.mode", "value": "repo-pr"}, 55 + {"key": "pr.mode", "value": "repo-pr"}, 56 56 {"key": "pr.trunk", "value": "master"}, 57 57 ] 58 58 ··· 70 70 def test_unknown_key_exits_2(capsys: pytest.CaptureFixture[str]) -> None: 71 71 # Routed through `main()` to exercise the global CommandError handler. 72 72 from project_manager.cli import main 73 + 73 74 code = main(["stacker", "config", "--repo", "demo", "nope", "x"]) 74 75 assert code == 2 75 76 assert "Unknown config key" in capsys.readouterr().err
+1 -3
tests/stacker/test_config_invariants.py
··· 16 16 @pytest.fixture 17 17 def backend() -> RecordingPRBackend: 18 18 return RecordingPRBackend( 19 - default_repo=gh.RepoInfo( 20 - name_with_owner="acme/widgets", owner="acme", name="widgets" 21 - ) 19 + default_repo=gh.RepoInfo(name_with_owner="acme/widgets", owner="acme", name="widgets") 22 20 ) 23 21 24 22
+6 -6
tests/stacker/test_create.py
··· 86 86 """`--no-checkout` path: branch ref exists, no slot is claimed.""" 87 87 repo_name, repo_path = stacker_repo 88 88 tracked = service.create_tracked_branch( 89 - repo_name, "ghost-branch", 89 + repo_name, 90 + "ghost-branch", 90 91 ParentLocator(repo_name=repo_name, branch="main"), 91 92 ) 92 93 assert tracked.branch == "ghost-branch" ··· 103 104 stacker_git.git(repo_path, "branch", "existing", "main") 104 105 with pytest.raises(stacker_git.GitError, match="already exists"): 105 106 service.create_tracked_branch( 106 - repo_name, "existing", 107 + repo_name, 108 + "existing", 107 109 ParentLocator(repo_name=repo_name, branch="main"), 108 110 ) 109 111 ··· 148 150 assert stacker_git.current_branch(here.path) == "feature-detached" 149 151 # No claim taken on the cwd slot. 150 152 from project_manager.pool.db import PoolDB 153 + 151 154 assert PoolDB(pm_env.pool_db()).get_owner(here.repo, here.uuid) is None 152 155 153 156 ··· 173 176 assert tracked.parent_branch == "main" 174 177 # An ops slot was claimed and the branch is now checked out there. 175 178 pooldb = PoolDB(pm_env.pool_db()) 176 - claimed = [ 177 - s for s in three_slots 178 - if pooldb.get_owner(s.repo, s.uuid) == OWNER_STACKER_OPS 179 - ] 179 + claimed = [s for s in three_slots if pooldb.get_owner(s.repo, s.uuid) == OWNER_STACKER_OPS] 180 180 assert len(claimed) == 1 181 181 assert stacker_git.current_branch(claimed[0].path) == "feature-orphan"
+35 -13
tests/stacker/test_create_slot_resolution.py
··· 136 136 cwd_slot = three_slots[0] 137 137 # Project-claim cwd_slot so ops_slot.claim cannot grab it. 138 138 pooldb.claim( 139 - cwd_slot.repo, cwd_slot.uuid, Owner(OwnerKind.PROJECT, "user-work"), 139 + cwd_slot.repo, 140 + cwd_slot.uuid, 141 + Owner(OwnerKind.PROJECT, "user-work"), 140 142 ) 141 143 stacker_git.git(cwd_slot.path, "checkout", "-b", "user-feature") 142 144 monkeypatch.chdir(cwd_slot.path) ··· 202 204 assert pooldb.get_owner(three_slots[0].repo, three_slots[0].uuid) is None 203 205 # Released slots are detached so the next claimant doesn't inherit a branch. 204 206 head = stacker_git.git( 205 - three_slots[0].path, "symbolic-ref", "-q", "HEAD", check=False, 207 + three_slots[0].path, 208 + "symbolic-ref", 209 + "-q", 210 + "HEAD", 211 + check=False, 206 212 ) 207 213 assert head.returncode != 0 208 214 ··· 246 252 stacker_git.git(work.path, "add", "README.md") 247 253 stacker_git.git( 248 254 work.path, 249 - "-c", "user.email=t@e.com", "-c", "user.name=t", 250 - "commit", "-m", "side: divergent", 255 + "-c", 256 + "user.email=t@e.com", 257 + "-c", 258 + "user.name=t", 259 + "commit", 260 + "-m", 261 + "side: divergent", 251 262 ) 252 263 side_sha = stacker_git.rev_parse(work.path, "HEAD") 253 264 ··· 256 267 stacker_git.git(work.path, "add", "README.md") 257 268 stacker_git.git( 258 269 work.path, 259 - "-c", "user.email=t@e.com", "-c", "user.name=t", 260 - "commit", "-m", "main: divergent", 270 + "-c", 271 + "user.email=t@e.com", 272 + "-c", 273 + "user.name=t", 274 + "commit", 275 + "-m", 276 + "main: divergent", 261 277 ) 262 278 263 279 stacker_git.git(work.path, "cherry-pick", side_sha, check=False) ··· 285 301 repo_name, repo_path = stacker_repo 286 302 stacker_git.git(repo_path, "branch", "feature-target", "main") 287 303 288 - with pytest.raises(KeyboardInterrupt), slot.acquired_for_op( 289 - service.ctx, repo_name, "feature-target", 304 + with ( 305 + pytest.raises(KeyboardInterrupt), 306 + slot.acquired_for_op( 307 + service.ctx, 308 + repo_name, 309 + "feature-target", 310 + ), 290 311 ): 291 312 raise KeyboardInterrupt 292 313 293 314 stacker_owned = [ 294 - uuid for repo, uuid, owner in pooldb.list_owned(repo_name) 315 + uuid 316 + for repo, uuid, owner in pooldb.list_owned(repo_name) 295 317 if owner.kind == OwnerKind.STACKER 296 318 ] 297 - assert stacker_owned == [], ( 298 - "interrupt with a clean worktree must not leak the stacker claim" 299 - ) 319 + assert stacker_owned == [], "interrupt with a clean worktree must not leak the stacker claim" 300 320 301 321 302 322 def test_resolve_slot_falls_back_to_cwd_when_pool_exhausted( ··· 344 364 monkeypatch.chdir(here.path) 345 365 346 366 acquired = slot.resolve_slot( 347 - service.ctx, repo_name, "feature-z", 367 + service.ctx, 368 + repo_name, 369 + "feature-z", 348 370 cwd_reuse=CwdReusePolicy(enabled=False), 349 371 ) 350 372 # Cwd was eligible (detached, same repo) but policy disabled — fresh ops slot used.
+15 -7
tests/stacker/test_gh_parse.py
··· 28 28 ("org-100@github.com:acme/widgets.git", "acme/widgets"), 29 29 ], 30 30 ) 31 - def test_parse_github_slug_handles_common_url_forms( 32 - url: str, expected: str 33 - ) -> None: 31 + def test_parse_github_slug_handles_common_url_forms(url: str, expected: str) -> None: 34 32 assert parse_github_slug(url) == expected 35 33 36 34 ··· 53 51 scenario), `_head_repo_for_branch` returns that fork's slug.""" 54 52 repo_name, _ = stacker_repo 55 53 service = StackerService( 56 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 54 + StackerDB(pm_env.stacker_db()), 55 + pm_env, 56 + pr_backend=RecordingPRBackend(), 57 57 ) 58 58 slot = three_slots[0] 59 59 tracked = service.init_new_branch( ··· 67 67 68 68 # Config-only setup — stacker reads branch.X.remote/merge directly. 69 69 stacker_git.git( 70 - slot.path, "remote", "add", "forkremote", 70 + slot.path, 71 + "remote", 72 + "add", 73 + "forkremote", 71 74 "git@github.com:acme/widgets-dev.git", 72 75 ) 73 76 stacker_git.git(slot.path, "config", "branch.feature-fork.remote", "forkremote") 74 77 stacker_git.git( 75 - slot.path, "config", "branch.feature-fork.merge", "refs/heads/feature-fork", 78 + slot.path, 79 + "config", 80 + "branch.feature-fork.merge", 81 + "refs/heads/feature-fork", 76 82 ) 77 83 78 84 assert head_repo_for_branch(service.ctx, tracked) == "acme/widgets-dev" ··· 85 91 ) -> None: 86 92 repo_name, _ = stacker_repo 87 93 service = StackerService( 88 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 94 + StackerDB(pm_env.stacker_db()), 95 + pm_env, 96 + pr_backend=RecordingPRBackend(), 89 97 ) 90 98 tracked = service.init_new_branch( 91 99 WorktreeInit(
+1 -3
tests/stacker/test_guard.py
··· 12 12 from project_manager.stacker.service import StackerService 13 13 14 14 15 - def _initialize( 16 - service: StackerService, repo_name: str, slot: slot_mod.Slot, branch: str 17 - ) -> None: 15 + def _initialize(service: StackerService, repo_name: str, slot: slot_mod.Slot, branch: str) -> None: 18 16 service.init_new_branch( 19 17 WorktreeInit( 20 18 repo_name=repo_name,
+1 -3
tests/stacker/test_locate.py
··· 63 63 assert found.uuid == slot.uuid 64 64 65 65 66 - def test_slot_for_cwd_returns_none_outside_pool( 67 - pm_env: Paths, tmp_path: Path 68 - ) -> None: 66 + def test_slot_for_cwd_returns_none_outside_pool(pm_env: Paths, tmp_path: Path) -> None: 69 67 assert locate.slot_for_cwd(pm_env, tmp_path) is None 70 68 assert locate.slot_for_cwd(pm_env, pm_env.worktrees) is None
+45 -18
tests/stacker/test_ls.py
··· 55 55 LsOptions(target_branch="c1", scope="current"), 56 56 ) 57 57 for name in ("a", "b1", "c1", "b2", "c2"): 58 - assert name in out, ( 59 - f"`ls -c` from c1 should include {name}; got:\n{out}" 60 - ) 58 + assert name in out, f"`ls -c` from c1 should include {name}; got:\n{out}" 61 59 62 60 63 61 def test_ls_details_none_strips_suffixes( ··· 79 77 service: StackerService, 80 78 ) -> None: 81 79 out = service.ls_text( 82 - tracked_stack.repo_name, LsOptions(details="status-counts"), 80 + tracked_stack.repo_name, 81 + LsOptions(details="status-counts"), 83 82 ) 84 83 # Each branch has 1 commit since its managed_base; no upstream, so the 85 84 # whole count is `unpushed` → `↑` arrow shows. ··· 146 145 service: StackerService, 147 146 ) -> None: 148 147 raw = service.ls_text( 149 - tracked_stack.repo_name, LsOptions(details="none", json_output=True), 148 + tracked_stack.repo_name, 149 + LsOptions(details="none", json_output=True), 150 150 ) 151 151 payload = json.loads(raw) 152 152 for b in _all_branches(payload): ··· 160 160 service: StackerService, 161 161 ) -> None: 162 162 raw = service.ls_text( 163 - tracked_stack.repo_name, LsOptions(details="all", json_output=True), 163 + tracked_stack.repo_name, 164 + LsOptions(details="all", json_output=True), 164 165 ) 165 166 payload = json.loads(raw) 166 167 for b in _all_branches(payload): ··· 186 187 connector leading into `b` for `├─✗` / `└─✗`. 187 188 """ 188 189 commit_file( 189 - tracked_stack.slots["a"].path, "a_extra.txt", "a2\n", "a: second commit", 190 + tracked_stack.slots["a"].path, 191 + "a_extra.txt", 192 + "a2\n", 193 + "a: second commit", 190 194 ) 191 195 out = service.ls_text(tracked_stack.repo_name, LsOptions(details="status")) 192 196 assert "!" in out ··· 198 202 service: StackerService, 199 203 ) -> None: 200 204 commit_file( 201 - tracked_stack.slots["a"].path, "a_extra.txt", "a2\n", "a: second commit", 205 + tracked_stack.slots["a"].path, 206 + "a_extra.txt", 207 + "a2\n", 208 + "a: second commit", 202 209 ) 203 210 out = service.ls_text( 204 211 tracked_stack.repo_name, ··· 328 335 ) 329 336 # The fixture claims all four slots for project "tracked-stack". 330 337 branch_lines = [ 331 - line for line in out.splitlines() 338 + line 339 + for line in out.splitlines() 332 340 if any(f"demo:{name}" in line for name in ("a", "b", "c", "d")) 333 341 ] 334 342 assert len(branch_lines) == 4 ··· 433 441 import io 434 442 435 443 from rich.console import Console 444 + 436 445 buf = io.StringIO() 437 - Console(file=buf, force_terminal=False, no_color=True, width=120, 438 - highlight=False, markup=False).print( 439 - s, markup=True, highlight=False, 446 + Console( 447 + file=buf, force_terminal=False, no_color=True, width=120, highlight=False, markup=False 448 + ).print( 449 + s, 450 + markup=True, 451 + highlight=False, 440 452 ) 441 453 return buf.getvalue() 442 454 ··· 446 458 import io 447 459 448 460 from rich.console import Console 461 + 449 462 buf = io.StringIO() 450 - Console(file=buf, force_terminal=True, color_system="truecolor", 451 - width=120, highlight=False, markup=False).print( 452 - s, markup=True, highlight=False, 463 + Console( 464 + file=buf, 465 + force_terminal=True, 466 + color_system="truecolor", 467 + width=120, 468 + highlight=False, 469 + markup=False, 470 + ).print( 471 + s, 472 + markup=True, 473 + highlight=False, 453 474 ) 454 475 return buf.getvalue() 455 476 456 477 457 478 def _seed_approved( 458 - svc: StackerService, repo_name: str, branch: str, 479 + svc: StackerService, 480 + repo_name: str, 481 + branch: str, 459 482 ) -> RecordingPRBackend: 460 483 """Install an approved PR for `branch` via a fake PR backend. 461 484 ··· 510 533 pm_env: Paths, 511 534 ) -> None: 512 535 svc = StackerService( 513 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 536 + StackerDB(pm_env.stacker_db()), 537 + pm_env, 538 + pr_backend=RecordingPRBackend(), 514 539 ) 515 540 _seed_approved(svc, tracked_stack.repo_name, "b") 516 541 out = svc.ls_text( ··· 528 553 pm_env: Paths, 529 554 ) -> None: 530 555 svc = StackerService( 531 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 556 + StackerDB(pm_env.stacker_db()), 557 + pm_env, 558 + pr_backend=RecordingPRBackend(), 532 559 ) 533 560 _seed_approved(svc, tracked_stack.repo_name, "b") 534 561 out = svc.ls_text(
+5 -7
tests/stacker/test_ops_slot.py
··· 50 50 51 51 ops_slot.release(pooldb, acquired) 52 52 assert pooldb.get_owner(acquired.repo, acquired.uuid) is None 53 - head_result = stacker_git.git( 54 - acquired.path, "symbolic-ref", "-q", "HEAD", check=False 55 - ) 53 + head_result = stacker_git.git(acquired.path, "symbolic-ref", "-q", "HEAD", check=False) 56 54 assert head_result.returncode != 0 # detached 57 55 58 56 ··· 93 91 real_git = stacker_git.git 94 92 95 93 def _interrupt_checkout( 96 - path: Path, *args: str, check: bool = True, 94 + path: Path, 95 + *args: str, 96 + check: bool = True, 97 97 ) -> object: 98 98 if args[:1] == ("checkout",): 99 99 raise KeyboardInterrupt ··· 184 184 pm_env, 185 185 pooldb, 186 186 repo_name, 187 - wait=ops_slot.WaitOptions( 188 - timeout_s=5.0, poll_interval_s=0.05, progress=notices.append 189 - ), 187 + wait=ops_slot.WaitOptions(timeout_s=5.0, poll_interval_s=0.05, progress=notices.append), 190 188 ) 191 189 assert claimed.uuid == held.uuid 192 190 assert any("Waiting" in n for n in notices)
+19 -5
tests/stacker/test_pr.py
··· 1 1 """Tests for the PR cache surface: unlink/refresh + the track-time PR auto-link.""" 2 + 2 3 from __future__ import annotations 3 4 4 5 from pathlib import Path ··· 72 73 73 74 def _config_upstream(slot_path: Path, branch: str) -> None: 74 75 stacker_git.git( 75 - slot_path, "remote", "add", "origin-fake", 76 - "git@github.com:acme/widgets.git", check=False, 76 + slot_path, 77 + "remote", 78 + "add", 79 + "origin-fake", 80 + "git@github.com:acme/widgets.git", 81 + check=False, 77 82 ) 78 83 stacker_git.git( 79 - slot_path, "config", f"branch.{branch}.remote", "origin-fake", 84 + slot_path, 85 + "config", 86 + f"branch.{branch}.remote", 87 + "origin-fake", 80 88 ) 81 89 stacker_git.git( 82 - slot_path, "config", f"branch.{branch}.merge", 90 + slot_path, 91 + "config", 92 + f"branch.{branch}.merge", 83 93 f"refs/heads/{branch}", 84 94 ) 85 95 86 96 87 97 def _seed_pr( 88 - backend: RecordingPRBackend, repo: str, head: str, *, number: int = 99, 98 + backend: RecordingPRBackend, 99 + repo: str, 100 + head: str, 101 + *, 102 + number: int = 99, 89 103 ) -> gh.PullRequest: 90 104 pr = gh.PullRequest( 91 105 number=number,
+15 -5
tests/stacker/test_pr_base.py
··· 26 26 backend: RecordingPRBackend, 27 27 ) -> StackerService: 28 28 return StackerService( 29 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=backend, 29 + StackerDB(pm_env.stacker_db()), 30 + pm_env, 31 + pr_backend=backend, 30 32 ) 31 33 32 34 ··· 75 77 76 78 77 79 def test_pr_pr_mode_uses_parent_pr_head_ref_when_parent_not_in_worktree( 78 - service: StackerService, backend: RecordingPRBackend, 80 + service: StackerService, 81 + backend: RecordingPRBackend, 79 82 ) -> None: 80 83 # Repro of the live bug: parent is tracked and has an open PR, but is not 81 84 # checked out in any worktree (e.g. its slot was released after pm pushed ··· 108 111 ) 109 112 110 113 base = pr_base_for_current_branch( 111 - service.ctx, child, _config("pr-pr"), _repo_info(), 114 + service.ctx, 115 + child, 116 + _config("pr-pr"), 117 + _repo_info(), 112 118 ) 113 119 assert base == "jordan-isaacs_data/feat-a" 114 120 115 121 116 122 def test_pr_pr_mode_errors_if_parent_pr_has_empty_head_ref( 117 - service: StackerService, backend: RecordingPRBackend, 123 + service: StackerService, 124 + backend: RecordingPRBackend, 118 125 ) -> None: 119 126 # Defensive: gh.PullRequest.head_ref_name is `str` not `str | None`, but 120 127 # if a backend ever returns an open PR with an empty ref we must surface ··· 146 153 147 154 with pytest.raises(git.GitError, match="no head ref"): 148 155 pr_base_for_current_branch( 149 - service.ctx, child, _config("pr-pr"), _repo_info(), 156 + service.ctx, 157 + child, 158 + _config("pr-pr"), 159 + _repo_info(), 150 160 )
+32 -15
tests/stacker/test_pr_body_rendering.py
··· 285 285 stacker_git.git(slot_b.path, "add", "feature-b-extra.txt") 286 286 stacker_git.git( 287 287 slot_b.path, 288 - "-c", "user.email=t@e.com", "-c", "user.name=t", 289 - "commit", "-m", "B: extra local commit", 288 + "-c", 289 + "user.email=t@e.com", 290 + "-c", 291 + "user.name=t", 292 + "commit", 293 + "-m", 294 + "B: extra local commit", 290 295 ) 291 296 actual_head_b = stacker_git.rev_parse(slot_b.path, "HEAD") 292 297 ··· 302 307 f"current HEAD ({actual_head_b}); last_clean_head was not refreshed " 303 308 f"after the local commit" 304 309 ) 305 - assert base_sha != head_sha, ( 306 - f"base..head collapsed to {base_sha}..{head_sha} (empty diff)" 307 - ) 310 + assert base_sha != head_sha, f"base..head collapsed to {base_sha}..{head_sha} (empty diff)" 308 311 309 312 310 313 def test_repo_pr_root_branch_files_link_omits_range( ··· 464 467 slot_a, slot_b, slot_c = three_slots[0], three_slots[1], three_slots[2] 465 468 466 469 _build_branch( 467 - repo_path, slot_a.path, "feature-a", "main", ("A\n", "A: first commit\n\n"), 470 + repo_path, 471 + slot_a.path, 472 + "feature-a", 473 + "main", 474 + ("A\n", "A: first commit\n\n"), 468 475 ) 469 476 _setup_fake_upstream(slot_a.path, "feature-a") 470 477 service.init_adopt_branch( ··· 477 484 ) 478 485 479 486 _build_branch( 480 - repo_path, slot_b.path, "feature-b", "feature-a", ("B\n", "B: second commit\n\n"), 487 + repo_path, 488 + slot_b.path, 489 + "feature-b", 490 + "feature-a", 491 + ("B\n", "B: second commit\n\n"), 481 492 ) 482 493 _setup_fake_upstream(slot_b.path, "feature-b") 483 494 service.init_adopt_branch( ··· 490 501 ) 491 502 492 503 _build_branch( 493 - repo_path, slot_c.path, "feature-c", "feature-b", ("C\n", "C: third commit\n\n"), 504 + repo_path, 505 + slot_c.path, 506 + "feature-c", 507 + "feature-b", 508 + ("C\n", "C: third commit\n\n"), 494 509 ) 495 510 _setup_fake_upstream(slot_c.path, "feature-c") 496 511 service.init_adopt_branch( ··· 508 523 # Default scope walks the full lineage parent→leaf, so PR #1 (feature-a), 509 524 # PR #2 (feature-b), PR #3 (feature-c) get created in that order. 510 525 service.push( 511 - SelectorTarget(repo_name=repo_name, branch="feature-b"), PushOptions(draft=True), 526 + SelectorTarget(repo_name=repo_name, branch="feature-b"), 527 + PushOptions(draft=True), 512 528 ) 513 529 514 530 final = {req.number: body for (req, body) in backend.edited if body is not None} ··· 808 824 809 825 pushed_branches = {request.head.rsplit(":", 1)[-1] for request, _ in backend.created} 810 826 assert pushed_branches == {"feature-c1"}, ( 811 - f"--only push from feature-c1 must push only feature-c1, " 812 - f"got {pushed_branches}" 827 + f"--only push from feature-c1 must push only feature-c1, got {pushed_branches}" 813 828 ) 814 829 815 830 ··· 836 851 ) 837 852 838 853 final = {req.number: body for (req, body) in backend.edited if body is not None} 839 - pr_numbers_by_branch = { 840 - head: pr.number for (_repo, head), pr in backend.prs_by_head.items() 841 - } 854 + pr_numbers_by_branch = {head: pr.number for (_repo, head), pr in backend.prs_by_head.items()} 842 855 expected_branches = ( 843 - "feature-a", "feature-b1", "feature-c1", "feature-b2", "feature-c2", 856 + "feature-a", 857 + "feature-b1", 858 + "feature-c1", 859 + "feature-b2", 860 + "feature-c2", 844 861 ) 845 862 for branch in expected_branches: 846 863 assert branch in pr_numbers_by_branch, (
+15 -8
tests/stacker/test_pr_url_cache.py
··· 5 5 the `pr_state` table rather than on `tracked_branches` so PR metadata 6 6 stays orthogonal to stack state. 7 7 """ 8 + 8 9 from __future__ import annotations 9 10 10 11 from pathlib import Path ··· 31 32 def _commit_one(slot_path: Path, name: str) -> None: 32 33 (slot_path / name).write_text(f"{name}\n") 33 34 stacker_git.git(slot_path, "add", name) 34 - stacker_git.git(slot_path, "-c", "user.email=t@e.com", "-c", "user.name=t", 35 - "commit", "-m", f"add {name}") 35 + stacker_git.git( 36 + slot_path, "-c", "user.email=t@e.com", "-c", "user.name=t", "commit", "-m", f"add {name}" 37 + ) 36 38 37 39 38 40 def _config_upstream(slot_path: Path, branch: str) -> None: 39 - stacker_git.git(slot_path, "remote", "add", "origin-fake", 40 - "git@github.com:acme/widgets.git", check=False) 41 + stacker_git.git( 42 + slot_path, "remote", "add", "origin-fake", "git@github.com:acme/widgets.git", check=False 43 + ) 41 44 stacker_git.git(slot_path, "config", f"branch.{branch}.remote", "origin-fake") 42 - stacker_git.git(slot_path, "config", f"branch.{branch}.merge", 43 - f"refs/heads/{branch}") 45 + stacker_git.git(slot_path, "config", f"branch.{branch}.merge", f"refs/heads/{branch}") 44 46 45 47 46 48 @pytest.fixture ··· 71 73 slot = three_slots[0] 72 74 service.init_new_branch( 73 75 WorktreeInit( 74 - repo_name=repo_name, worktree_path=slot.path, branch="feature-a", 76 + repo_name=repo_name, 77 + worktree_path=slot.path, 78 + branch="feature-a", 75 79 parent=ParentLocator(repo_name=repo_name, branch="main"), 76 80 ) 77 81 ) ··· 106 110 original = backend.list_open_prs 107 111 108 112 def _count( 109 - repo: str, *, head: str | None = None, search: str | None = None, 113 + repo: str, 114 + *, 115 + head: str | None = None, 116 + search: str | None = None, 110 117 ) -> list[gh.PullRequest]: 111 118 search_calls.append(search or head or "") 112 119 return original(repo, head=head, search=search)
+37 -29
tests/stacker/test_push.py
··· 1 1 """Coverage for the scope-aware `push` command (force-push + PR refresh).""" 2 + 2 3 from __future__ import annotations 3 4 4 5 from pathlib import Path ··· 28 29 29 30 def _configure_upstreams(stack: TrackedStack) -> None: 30 31 for branch, slot in stack.slots.items(): 31 - stacker_git.git(slot.path, "remote", "add", "origin-fake", 32 - "git@github.com:acme/widgets.git", check=False) 32 + stacker_git.git( 33 + slot.path, 34 + "remote", 35 + "add", 36 + "origin-fake", 37 + "git@github.com:acme/widgets.git", 38 + check=False, 39 + ) 33 40 stacker_git.git(slot.path, "config", f"branch.{branch}.remote", "origin-fake") 34 - stacker_git.git(slot.path, "config", f"branch.{branch}.merge", 35 - f"refs/heads/{branch}") 41 + stacker_git.git(slot.path, "config", f"branch.{branch}.merge", f"refs/heads/{branch}") 36 42 37 43 38 44 @pytest.fixture ··· 86 92 ) -> None: 87 93 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="b") 88 94 push_service.push( 89 - target, PushOptions(scope=ScopeSpec(skip_ancestors=True)), 95 + target, 96 + PushOptions(scope=ScopeSpec(skip_ancestors=True)), 90 97 ) 91 98 heads = [req.head for req, _body in backend.created] 92 99 assert heads == ["b", "c", "d"] ··· 99 106 ) -> None: 100 107 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="b") 101 108 push_service.push( 102 - target, PushOptions(scope=ScopeSpec(skip_descendants=True)), 109 + target, 110 + PushOptions(scope=ScopeSpec(skip_descendants=True)), 103 111 ) 104 112 heads = [req.head for req, _body in backend.created] 105 113 assert heads == ["a", "b"] ··· 163 171 164 172 165 173 def _setup_fake_upstream(slot_path: Path, branch: str) -> None: 166 - stacker_git.git(slot_path, "remote", "add", "origin-fake", 167 - "git@github.com:acme/widgets.git", check=False) 174 + stacker_git.git( 175 + slot_path, "remote", "add", "origin-fake", "git@github.com:acme/widgets.git", check=False 176 + ) 168 177 stacker_git.git(slot_path, "config", f"branch.{branch}.remote", "origin-fake") 169 - stacker_git.git(slot_path, "config", f"branch.{branch}.merge", 170 - f"refs/heads/{branch}") 178 + stacker_git.git(slot_path, "config", f"branch.{branch}.merge", f"refs/heads/{branch}") 171 179 172 180 173 181 def _seed_unattached_branch( ··· 188 196 svc = StackerService(StackerDB(pm_env.stacker_db()), pm_env, pr_backend=backend) 189 197 svc.db.set_config(repo_name, "pr.mode", "repo-pr") 190 198 svc.db.set_config(repo_name, "pr.trunk", "main") 191 - svc.init_new_branch(WorktreeInit( 192 - repo_name=repo_name, worktree_path=init_slot.path, branch=branch, 193 - parent=ParentLocator(repo_name=repo_name, branch="main"), 194 - )) 199 + svc.init_new_branch( 200 + WorktreeInit( 201 + repo_name=repo_name, 202 + worktree_path=init_slot.path, 203 + branch=branch, 204 + parent=ParentLocator(repo_name=repo_name, branch="main"), 205 + ) 206 + ) 195 207 commit_file(init_slot.path, f"{branch}.txt", f"{branch}\n", f"{branch}: first commit") 196 208 _setup_fake_upstream(init_slot.path, branch) 197 209 stacker_git.git(init_slot.path, "checkout", "--detach", "HEAD") ··· 222 234 svc.push(SelectorTarget(repo_name=repo_name, branch="feature-x"), PushOptions(draft=True)) 223 235 224 236 stacker_owned = [ 225 - uuid for repo, uuid, owner in pooldb.list_owned(repo_name) 237 + uuid 238 + for repo, uuid, owner in pooldb.list_owned(repo_name) 226 239 if owner.kind == OwnerKind.STACKER and repo == repo_name 227 240 ] 228 - assert stacker_owned == [], ( 229 - f"clean push left a stacker-owned slot leaked: {stacker_owned}" 230 - ) 241 + assert stacker_owned == [], f"clean push left a stacker-owned slot leaked: {stacker_owned}" 231 242 232 243 233 244 def test_push_holds_target_slot_when_worktree_dirty( ··· 258 269 svc.push(SelectorTarget(repo_name=repo_name, branch="feature-y"), PushOptions(draft=True)) 259 270 260 271 stacker_owned = [ 261 - uuid for repo, uuid, owner in pooldb.list_owned(repo_name) 272 + uuid 273 + for repo, uuid, owner in pooldb.list_owned(repo_name) 262 274 if owner.kind == OwnerKind.STACKER and repo == repo_name 263 275 ] 264 276 assert len(stacker_owned) == 1, ( ··· 308 320 """Pushing a merged target reports the merge and creates no PRs.""" 309 321 _mark_merged(push_service, tracked_stack.repo_name, "b", number=11) 310 322 target = SelectorTarget( 311 - repo_name=tracked_stack.repo_name, branch="b", 323 + repo_name=tracked_stack.repo_name, 324 + branch="b", 312 325 ) 313 326 result = push_service.push( 314 - target, PushOptions(scope=ScopeSpec(only=True)), 327 + target, 328 + PushOptions(scope=ScopeSpec(only=True)), 315 329 ) 316 330 assert backend.created == [] 317 331 assert "already merged" in result ··· 384 398 ) 385 399 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="a") 386 400 push_service.push(target, PushOptions(scope=ScopeSpec(only=True))) 387 - title_syncs = [ 388 - req for req, _body in backend.edited if req.title is not None 389 - ] 401 + title_syncs = [req for req, _body in backend.edited if req.title is not None] 390 402 assert title_syncs == [], ( 391 403 f"manually-edited title should be preserved, got title_syncs={title_syncs}" 392 404 ) ··· 409 421 ) 410 422 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="a") 411 423 push_service.push(target, PushOptions(scope=ScopeSpec(only=True))) 412 - title_syncs = [ 413 - req.title for req, _body in backend.edited if req.title is not None 414 - ] 424 + title_syncs = [req.title for req, _body in backend.edited if req.title is not None] 415 425 assert title_syncs == ["a: first commit"] 416 - 417 -
+4 -2
tests/stacker/test_render_prefetch.py
··· 39 39 (slot_b.path / "b.txt").write_text("modified\n") 40 40 41 41 out = service.ls_text( 42 - tracked_stack.repo_name, LsOptions(details="status-counts"), 42 + tracked_stack.repo_name, 43 + LsOptions(details="status-counts"), 43 44 ) 44 45 assert "[dirty]" in out, out 45 46 line_b = _line_for_branch(out, tracked_stack.repo_name, "b") ··· 62 63 # paint every row dirty, and `git status` on a clean worktree must 63 64 # continue to return empty output post-collapse. 64 65 out = service.ls_text( 65 - tracked_stack.repo_name, LsOptions(details="status-counts"), 66 + tracked_stack.repo_name, 67 + LsOptions(details="status-counts"), 66 68 ) 67 69 assert "[dirty]" not in out 68 70
+10 -3
tests/stacker/test_reparent.py
··· 124 124 c = service.db.get_branch(tracked_stack.repo_name, "c") 125 125 assert c is not None 126 126 assert c.parent_branch == "a" 127 - head_log = stacker_git.git( 128 - c_slot.path, "log", "--format=%s", "-3", 129 - ).stdout.strip().splitlines() 127 + head_log = ( 128 + stacker_git.git( 129 + c_slot.path, 130 + "log", 131 + "--format=%s", 132 + "-3", 133 + ) 134 + .stdout.strip() 135 + .splitlines() 136 + ) 130 137 assert head_log[0] == "c: shared" 131 138 assert head_log[1] == "c: first commit" 132 139 assert head_log[2] == "a: first commit"
+2 -3
tests/stacker/test_split.py
··· 44 44 assert b_tracked is not None 45 45 # only one commit since managed_base (the original b1) 46 46 commits = stacker_git.rev_list( 47 - b_slot.path, f"{b_tracked.managed_base_commit}..{b_head}", 47 + b_slot.path, 48 + f"{b_tracked.managed_base_commit}..{b_head}", 48 49 ) 49 50 assert len(commits) == 1 50 51 ··· 165 166 assert "Split" in result 166 167 assert "could not claim a slot" in result 167 168 assert service.db.get_branch(tracked_stack.repo_name, "b-split") is not None 168 - 169 -
+3 -1
tests/stacker/test_stack_block.py
··· 21 21 stacker_repo: tuple[str, Path], # noqa: ARG001 (creates repo on disk) 22 22 ) -> StackerService: 23 23 return StackerService( 24 - StackerDB(pm_env.stacker_db()), pm_env, pr_backend=RecordingPRBackend(), 24 + StackerDB(pm_env.stacker_db()), 25 + pm_env, 26 + pr_backend=RecordingPRBackend(), 25 27 ) 26 28 27 29
+1 -3
tests/stacker/test_stack_block_format.py
··· 84 84 sep_line = next( 85 85 i for i, line in enumerate(out.split("\n")) if line.strip().startswith(STACK_SEPARATOR) 86 86 ) 87 - summary_line = next( 88 - i for i, line in enumerate(out.split("\n")) if line.strip() == "## Summary" 89 - ) 87 + summary_line = next(i for i, line in enumerate(out.split("\n")) if line.strip() == "## Summary") 90 88 assert summary_line > sep_line
+41 -27
tests/stacker/test_sync.py
··· 24 24 from .fakes import RecordingPRBackend 25 25 26 26 27 - def _initialize( 28 - service: StackerService, repo_name: str, slot: slot_mod.Slot, branch: str 29 - ) -> None: 27 + def _initialize(service: StackerService, repo_name: str, slot: slot_mod.Slot, branch: str) -> None: 30 28 service.init_new_branch( 31 29 WorktreeInit( 32 30 repo_name=repo_name, ··· 106 104 leaf_slot = three_slots[1] 107 105 _initialize(service, repo_name, parent_slot, "feature-parent") 108 106 parent_commit = commit_file( 109 - parent_slot.path, "parent.txt", "p\n", "parent: first commit", 107 + parent_slot.path, 108 + "parent.txt", 109 + "p\n", 110 + "parent: first commit", 110 111 ) 111 112 service.init_new_branch( 112 113 WorktreeInit( ··· 133 134 assert "Sync complete." in result 134 135 135 136 leaked = [ 136 - slot for slot in slot_mod.list_slots(pm_env, repo_name) 137 + slot 138 + for slot in slot_mod.list_slots(pm_env, repo_name) 137 139 if pooldb.get_owner(slot.repo, slot.uuid) == OWNER_STACKER_OPS 138 140 ] 139 141 assert leaked == [], ( 140 - f"downstream_sync left {len(leaked)} ops slot(s) claimed: " 141 - f"{[s.uuid for s in leaked]}" 142 + f"downstream_sync left {len(leaked)} ops slot(s) claimed: {[s.uuid for s in leaked]}" 142 143 ) 143 144 144 145 ··· 245 246 commit_file(tracked_stack.repo_path, "main-bump.txt", "m\n", "main bump") 246 247 target = SelectorTarget(repo_name=tracked_stack.repo_name, branch="b") 247 248 result = service.sync( 248 - target, ScopeSpec(scope="current", from_branch="c"), 249 + target, 250 + ScopeSpec(scope="current", from_branch="c"), 249 251 ) 250 252 assert "Sync complete." in result 251 253 ··· 274 276 """ 275 277 repo_name = multi_arm_stack.repo_name 276 278 new_main = commit_file( 277 - multi_arm_stack.repo_path, "shared.txt", "v\n", "main: advance", 279 + multi_arm_stack.repo_path, 280 + "shared.txt", 281 + "v\n", 282 + "main: advance", 278 283 ) 279 284 280 285 result = service.sync(SelectorTarget(repo_name=repo_name, branch="c1")) ··· 283 288 a = service.db.get_branch(repo_name, "a") 284 289 assert a is not None 285 290 assert a.managed_base_commit == new_main, ( 286 - f"a.managed_base should advance to new main {new_main[:8]}, " 287 - f"got {a.managed_base_commit[:8]}" 291 + f"a.managed_base should advance to new main {new_main[:8]}, got {a.managed_base_commit[:8]}" 288 292 ) 289 293 a_head = stacker_git.rev_parse(multi_arm_stack.slots["a"].path, "a") 290 294 ··· 302 306 303 307 for branch, parent in (("c1", "b1"), ("c2", "b2")): 304 308 parent_head = stacker_git.rev_parse( 305 - multi_arm_stack.slots[parent].path, parent, 309 + multi_arm_stack.slots[parent].path, 310 + parent, 306 311 ) 307 312 row = service.db.get_branch(repo_name, branch) 308 313 assert row is not None ··· 316 321 317 322 318 323 def _service_with_backend( 319 - pm_env: Paths, backend: RecordingPRBackend, 324 + pm_env: Paths, 325 + backend: RecordingPRBackend, 320 326 ) -> StackerService: 321 327 """Build a fresh StackerService against the shared DB with a fake PR backend. 322 328 ··· 329 335 330 336 331 337 def _rebase_below_anchor( 332 - slot_path: Path, repo_path: Path, fork_from: str, 338 + slot_path: Path, 339 + repo_path: Path, 340 + fork_from: str, 333 341 ) -> str: 334 342 """Rewrite the branch's anchor: fork main from `fork_from`, rebase branch. 335 343 ··· 343 351 old_anchor = stacker_git.rev_parse(slot_path, "HEAD~1") 344 352 stacker_git.git(repo_path, "reset", "--hard", fork_from) 345 353 new_anchor = commit_file( 346 - repo_path, "main-fork.txt", "fork\n", "main: forked", 354 + repo_path, 355 + "main-fork.txt", 356 + "fork\n", 357 + "main: forked", 347 358 ) 348 359 # Omit the third arg so rebase runs on the currently-checked-out 349 360 # branch ref; passing the literal `HEAD` here detaches and leaves ··· 393 404 commit_file(feature_slot.path, "feat.txt", "feat\n", "feat: own commit") 394 405 395 406 new_anchor = _rebase_below_anchor( 396 - feature_slot.path, repo_path, fork_from=main_root, 407 + feature_slot.path, 408 + repo_path, 409 + fork_from=main_root, 397 410 ) 398 411 399 412 result = service.sync( ··· 468 481 469 482 470 483 def _seed_open_pr( 471 - db: StackerDB, repo_name: str, branch: str, number: int, 484 + db: StackerDB, 485 + repo_name: str, 486 + branch: str, 487 + number: int, 472 488 ) -> str: 473 489 """Insert an OPEN pr_state row for `branch`. Returns the PR URL.""" 474 490 url = _PR_URL.format(n=number) ··· 499 515 """ 500 516 if online: 501 517 backend.review_by_pr[("acme", "widgets", number)] = gh.PRReviewSummary( 502 - state="MERGED", is_draft=False, 503 - is_approved=False, has_open_comments=False, 518 + state="MERGED", 519 + is_draft=False, 520 + is_approved=False, 521 + has_open_comments=False, 504 522 ) 505 523 506 524 ··· 690 708 assert row.managed_base_commit == squashed_main, branch 691 709 assert row.last_clean_head == squashed_main, branch 692 710 slot_head = stacker_git.rev_parse( 693 - tracked_stack.slots[branch].path, "HEAD", 711 + tracked_stack.slots[branch].path, 712 + "HEAD", 694 713 ) 695 714 assert slot_head == squashed_main, branch 696 715 ··· 772 791 ) 773 792 svc.sync(SelectorTarget(repo_name=repo_name, branch="d")) 774 793 775 - snapshot = { 776 - branch: svc.db.get_branch(repo_name, branch) 777 - for branch in ("a", "b", "c", "d") 778 - } 794 + snapshot = {branch: svc.db.get_branch(repo_name, branch) for branch in ("a", "b", "c", "d")} 779 795 780 796 # Resync each individually with offline=True so we don't re-collapse — 781 797 # they should each short-circuit on the unchanged-parent check. ··· 785 801 ScopeSpec(scope="current", skip_descendants=True, skip_ancestors=True), 786 802 options=SyncOptions(offline=True), 787 803 ) 788 - assert ( 789 - "Nothing to sync" in result or "Sync complete." in result 790 - ), f"{branch}: {result!r}" 804 + assert "Nothing to sync" in result or "Sync complete." in result, f"{branch}: {result!r}" 791 805 assert svc.db.get_branch(repo_name, branch) == snapshot[branch]
+10 -10
tests/stacker/test_upstream_config.py
··· 4 4 `git branch --set-upstream-to`. No `@{upstream}` rev-parse, no 5 5 dependency on a materialized remote-tracking ref. 6 6 """ 7 + 7 8 from __future__ import annotations 8 9 9 10 from pathlib import Path ··· 35 36 # No remote-tracking ref exists; we only set the config keys. 36 37 stacker_git.git(slot_on_branch.path, "config", "branch.feature-X.remote", "origin") 37 38 stacker_git.git( 38 - slot_on_branch.path, "config", "branch.feature-X.merge", 39 + slot_on_branch.path, 40 + "config", 41 + "branch.feature-X.merge", 39 42 "refs/heads/user-prefix/feature-X", 40 43 ) 41 44 assert stacker_git.upstream_remote_name(slot_on_branch.path) == "origin" 42 - assert ( 43 - stacker_git.upstream_branch_name(slot_on_branch.path) 44 - == "user-prefix/feature-X" 45 - ) 46 - assert ( 47 - stacker_git.upstream_branch(slot_on_branch.path) 48 - == "origin/user-prefix/feature-X" 49 - ) 45 + assert stacker_git.upstream_branch_name(slot_on_branch.path) == "user-prefix/feature-X" 46 + assert stacker_git.upstream_branch(slot_on_branch.path) == "origin/user-prefix/feature-X" 50 47 51 48 52 49 def test_malformed_merge_returns_none(slot_on_branch: slot_mod.Slot) -> None: 53 50 # `merge` must start with `refs/heads/`; anything else is ignored. 54 51 stacker_git.git(slot_on_branch.path, "config", "branch.feature-X.remote", "origin") 55 52 stacker_git.git( 56 - slot_on_branch.path, "config", "branch.feature-X.merge", "refs/tags/v1.0", 53 + slot_on_branch.path, 54 + "config", 55 + "branch.feature-X.merge", 56 + "refs/tags/v1.0", 57 57 ) 58 58 assert stacker_git.upstream_branch_name(slot_on_branch.path) is None 59 59 assert stacker_git.upstream_branch(slot_on_branch.path) is None
+24 -14
tests/test_config.py
··· 27 27 28 28 29 29 def test_display_defaults_to_none_timezone( 30 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 30 + monkeypatch: pytest.MonkeyPatch, 31 + tmp_path: Path, 31 32 ) -> None: 32 33 monkeypatch.delenv("PM_CONFIG", raising=False) 33 34 monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) ··· 37 38 38 39 39 40 def test_display_reads_configured_timezone( 40 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 41 + monkeypatch: pytest.MonkeyPatch, 42 + tmp_path: Path, 41 43 ) -> None: 42 44 cfg = tmp_path / "pm.toml" 43 45 cfg.write_text( ··· 51 53 52 54 53 55 def test_display_rejects_unknown_timezone( 54 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 56 + monkeypatch: pytest.MonkeyPatch, 57 + tmp_path: Path, 55 58 ) -> None: 56 59 cfg = tmp_path / "pm.toml" 57 60 cfg.write_text( ··· 65 68 66 69 67 70 def test_agents_defaults_to_empty_commands( 68 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 71 + monkeypatch: pytest.MonkeyPatch, 72 + tmp_path: Path, 69 73 ) -> None: 70 74 monkeypatch.delenv("PM_CONFIG", raising=False) 71 75 monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) ··· 74 78 75 79 76 80 def test_agents_reads_commands_table( 77 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 81 + monkeypatch: pytest.MonkeyPatch, 82 + tmp_path: Path, 78 83 ) -> None: 79 84 cfg = tmp_path / "pm.toml" 80 85 cfg.write_text( 81 86 '[paths]\nrepos = "/r"\nworktrees = "/w"\nprojects = "/p"\n' 82 87 'stacker_root = "/s"\n' 83 - '[agents.commands]\n' 88 + "[agents.commands]\n" 84 89 'claude = "isaac"\n' 85 90 'codex = "isaac codex --"\n' 86 91 'cursor = "agent"\n', ··· 95 100 96 101 97 102 def test_agents_rejects_non_string_command( 98 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 103 + monkeypatch: pytest.MonkeyPatch, 104 + tmp_path: Path, 99 105 ) -> None: 100 106 cfg = tmp_path / "pm.toml" 101 107 cfg.write_text( 102 108 '[paths]\nrepos = "/r"\nworktrees = "/w"\nprojects = "/p"\n' 103 109 'stacker_root = "/s"\n' 104 - '[agents.commands]\nclaude = 42\n', 110 + "[agents.commands]\nclaude = 42\n", 105 111 ) 106 112 monkeypatch.setenv("PM_CONFIG", str(cfg)) 107 113 with pytest.raises(ValueError, match=r"\[agents\.commands\]\.claude"): ··· 109 115 110 116 111 117 def test_concurrency_defaults( 112 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 118 + monkeypatch: pytest.MonkeyPatch, 119 + tmp_path: Path, 113 120 ) -> None: 114 121 monkeypatch.delenv("PM_CONFIG", raising=False) 115 122 monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) ··· 118 125 119 126 120 127 def test_concurrency_reads_limit( 121 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 128 + monkeypatch: pytest.MonkeyPatch, 129 + tmp_path: Path, 122 130 ) -> None: 123 131 cfg = tmp_path / "pm.toml" 124 132 cfg.write_text( 125 133 '[paths]\nrepos = "/r"\nworktrees = "/w"\nprojects = "/p"\n' 126 134 'stacker_root = "/s"\n' 127 - '[concurrency]\nlimit = 4\n', 135 + "[concurrency]\nlimit = 4\n", 128 136 ) 129 137 monkeypatch.setenv("PM_CONFIG", str(cfg)) 130 138 assert config.concurrency().limit == 4 131 139 132 140 133 141 def test_concurrency_rejects_non_positive_limit( 134 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 142 + monkeypatch: pytest.MonkeyPatch, 143 + tmp_path: Path, 135 144 ) -> None: 136 145 cfg = tmp_path / "pm.toml" 137 146 cfg.write_text( 138 147 '[paths]\nrepos = "/r"\nworktrees = "/w"\nprojects = "/p"\n' 139 148 'stacker_root = "/s"\n' 140 - '[concurrency]\nlimit = 0\n', 149 + "[concurrency]\nlimit = 0\n", 141 150 ) 142 151 monkeypatch.setenv("PM_CONFIG", str(cfg)) 143 152 with pytest.raises(ValueError, match="positive integer"): ··· 145 154 146 155 147 156 def test_concurrency_rejects_non_integer_limit( 148 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 157 + monkeypatch: pytest.MonkeyPatch, 158 + tmp_path: Path, 149 159 ) -> None: 150 160 cfg = tmp_path / "pm.toml" 151 161 cfg.write_text(
+9 -4
tests/test_current.py
··· 31 31 32 32 33 33 def test_detects_from_physical_worktree_via_pool_db( 34 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, 34 + pm_env: Paths, 35 + monkeypatch: pytest.MonkeyPatch, 35 36 ) -> None: 36 37 _mk_pool(pm_env, "foo", ["a"]) 37 38 create_mod.create(pm_env, "demo", _just(["foo"])) ··· 40 41 41 42 42 43 def test_detach_while_inside_worktree_clears_detection( 43 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, 44 + pm_env: Paths, 45 + monkeypatch: pytest.MonkeyPatch, 44 46 ) -> None: 45 47 """After detach the pool row is gone, so worktree-path detection can't recover 46 48 the project name. Users in this corner case must pass the name explicitly. ··· 53 55 54 56 55 57 def test_resolve_project_prefers_explicit( 56 - pm_env: Paths, monkeypatch: pytest.MonkeyPatch, 58 + pm_env: Paths, 59 + monkeypatch: pytest.MonkeyPatch, 57 60 ) -> None: 58 61 _mk_pool(pm_env, "foo", ["a"]) 59 62 create_mod.create(pm_env, "demo", _just(["foo"])) ··· 62 65 63 66 64 67 def test_resolve_project_raises_when_nothing_to_detect( 65 - pm_env: Paths, tmp_path, monkeypatch: pytest.MonkeyPatch, 68 + pm_env: Paths, 69 + tmp_path, 70 + monkeypatch: pytest.MonkeyPatch, 66 71 ) -> None: 67 72 _chdir(monkeypatch, tmp_path) 68 73 with pytest.raises(ProjectError, match="no project specified"):
+2 -6
tests/test_pool.py
··· 13 13 14 14 def _init_repo(path: Path, branch: str = "main") -> None: 15 15 subprocess.run(["git", "init", "-q", "-b", branch, str(path)], check=True) 16 - subprocess.run( 17 - ["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True 18 - ) 16 + subprocess.run(["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True) 19 17 subprocess.run(["git", "-C", str(path), "config", "user.name", "test"], check=True) 20 - subprocess.run( 21 - ["git", "-C", str(path), "config", "commit.gpgsign", "false"], check=True 22 - ) 18 + subprocess.run(["git", "-C", str(path), "config", "commit.gpgsign", "false"], check=True) 23 19 (path / "README.md").write_text("# test\n") 24 20 subprocess.run(["git", "-C", str(path), "add", "README.md"], check=True) 25 21 subprocess.run(
+22 -15
tests/test_project.py
··· 527 527 slot = _forward(pm_env, "demo", "foo").resolve() 528 528 (slot / ".gitignore").write_text("*.log\n") 529 529 git_in_slot(slot, "add", ".gitignore") 530 - git_in_slot(slot, "-c", "user.email=t@t", "-c", "user.name=t", 531 - "-c", "commit.gpgsign=false", "commit", "-m", "ignore logs") 530 + git_in_slot( 531 + slot, 532 + "-c", 533 + "user.email=t@t", 534 + "-c", 535 + "user.name=t", 536 + "-c", 537 + "commit.gpgsign=false", 538 + "commit", 539 + "-m", 540 + "ignore logs", 541 + ) 532 542 (slot / "build.log").write_text("ignored\n") 533 543 detach_mod.detach(pm_env, "demo", wts=None) 534 544 assert not _forward(pm_env, "demo", "foo").exists() ··· 557 567 ], 558 568 ) 559 569 def test_detach_blocks_on_in_progress_op( 560 - pm_env: Paths, marker: str, expected: str, 570 + pm_env: Paths, 571 + marker: str, 572 + expected: str, 561 573 ) -> None: 562 574 git_pool(pm_env, "foo", n=1) 563 575 create_mod.create(pm_env, "demo", _just_repos(["foo"])) ··· 579 591 git_in_slot(slot, "add", "README.md") 580 592 with pytest.raises(ProjectError, match="uncommitted changes"): 581 593 delete_mod.delete(pm_env, "demo", wts=["foo"]) 582 - assert _db_rows(pm_env, "demo") == [ 583 - ("foo", "foo", _current_uuid(pm_env, "demo", "foo")) 584 - ] 594 + assert _db_rows(pm_env, "demo") == [("foo", "foo", _current_uuid(pm_env, "demo", "foo"))] 585 595 assert _forward(pm_env, "demo", "foo").is_symlink() 586 596 587 597 ··· 708 718 git_pool(pm_env, "foo", n=1) 709 719 create_mod.create(pm_env, "demo", _just_repos(["foo"])) 710 720 from project_manager.cli._shared import ProjectFlag, WtSelection 721 + 711 722 rc = cli_wt_detach( 712 723 flag=ProjectFlag(project="demo"), 713 724 sel=WtSelection(all=True), ··· 718 729 719 730 720 731 def test_cli_wt_detach_dry_run_exits_one_on_blocker( 721 - pm_env: Paths, capsys: pytest.CaptureFixture[str], 732 + pm_env: Paths, 733 + capsys: pytest.CaptureFixture[str], 722 734 ) -> None: 723 735 git_pool(pm_env, "foo", n=1) 724 736 create_mod.create(pm_env, "demo", _just_repos(["foo"])) 725 737 slot = _forward(pm_env, "demo", "foo").resolve() 726 738 (slot / "scratch.log").write_text("noise\n") 727 739 from project_manager.cli._shared import ProjectFlag, WtSelection 740 + 728 741 rc = cli_wt_detach( 729 742 flag=ProjectFlag(project="demo"), 730 743 sel=WtSelection(all=True), ··· 787 800 conn = sqlite3.connect(db_path) 788 801 try: 789 802 conn.execute( 790 - "CREATE TABLE repos (" 791 - " name TEXT PRIMARY KEY," 792 - " slot_uuid TEXT NOT NULL," 793 - " branch TEXT" 794 - ")" 803 + "CREATE TABLE repos ( name TEXT PRIMARY KEY, slot_uuid TEXT NOT NULL, branch TEXT)" 795 804 ) 796 805 conn.execute( 797 806 "INSERT INTO repos (name, slot_uuid, branch) VALUES (?, ?, ?)", ··· 810 819 rows = db.list_wts(conn) 811 820 tables = { 812 821 r[0] 813 - for r in conn.execute( 814 - "SELECT name FROM sqlite_master WHERE type='table'" 815 - ).fetchall() 822 + for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() 816 823 } 817 824 818 825 assert "repos" not in tables
+34 -15
tests/test_render.py
··· 5 5 ANSI-styled output) or `force_terminal=False` (to assert on pipe-shaped 6 6 plain output). 7 7 """ 8 + 8 9 from __future__ import annotations 9 10 10 11 import io ··· 119 120 class Custom: 120 121 a: int 121 122 b: int 123 + 122 124 def __pm_json__(self) -> dict[str, object]: 123 125 return {"sum": self.a + self.b} 124 126 ··· 146 148 out = _capture( 147 149 monkeypatch, 148 150 lambda: render.emit_rows( 149 - [Row(Color.RED, Path("/nonexistent/x"))], row_cols, as_json=True, 151 + [Row(Color.RED, Path("/nonexistent/x"))], 152 + row_cols, 153 + as_json=True, 150 154 ), 151 155 tty=False, 152 156 ) ··· 168 172 out = _capture( 169 173 monkeypatch, 170 174 lambda: render.emit_sections( 171 - secs, _COLUMNS, group=render.GroupColumn("G"), 175 + secs, 176 + _COLUMNS, 177 + group=render.GroupColumn("G"), 172 178 ), 173 179 tty=False, 174 180 ) 175 181 # Two rules: one under the header, one between the two sections. 176 - rule_lines = [ 177 - line for line in out.split("\n") if line and line.strip().startswith("─") 178 - ] 182 + rule_lines = [line for line in out.split("\n") if line and line.strip().startswith("─")] 179 183 assert len(rule_lines) == 2 180 184 assert _data_lines(out) == [ 181 185 ["G", "Name", "Count"], ··· 197 201 out = _capture( 198 202 monkeypatch, 199 203 lambda: render.emit_sections( 200 - secs, _COLUMNS, as_json=True, shape=JsonShape("repo", "slots"), 204 + secs, 205 + _COLUMNS, 206 + as_json=True, 207 + shape=JsonShape("repo", "slots"), 201 208 ), 202 209 tty=False, 203 210 ) 204 211 payload = json.loads(out) 205 212 assert payload == [ 206 - {"repo": "backend", "slots": [{"name": "a", "count": 1}]}, 213 + {"repo": "backend", "slots": [{"name": "a", "count": 1}]}, 207 214 {"repo": "frontend", "slots": [{"name": "b", "count": 2}]}, 208 215 ] 209 216 ··· 219 226 ] 220 227 materialized = [(s.title, list(s.rows)) for s in secs] 221 228 table = render.build_sections_table( 222 - materialized, _COLUMNS, group=render.GroupColumn("G"), 229 + materialized, 230 + _COLUMNS, 231 + group=render.GroupColumn("G"), 223 232 ) 224 233 # Row count: 2 + 1 data rows. Header is a separate attribute, not a row. 225 234 assert len(table.rows) == 3 ··· 236 245 ] 237 246 materialized = [(s.title, list(s.rows)) for s in secs] 238 247 table = render.build_sections_table( 239 - materialized, _COLUMNS, 240 - group=render.GroupColumn("G"), selected_flat_idx=2, 248 + materialized, 249 + _COLUMNS, 250 + group=render.GroupColumn("G"), 251 + selected_flat_idx=2, 241 252 ) 242 253 styles = [r.style for r in table.rows] 243 254 # Flat order: ("first","a"), ("first","b"), ("second","c") — index 2 ··· 255 266 secs = [Section(title="first", rows=[_Row("a", 1)])] 256 267 materialized = [(s.title, list(s.rows)) for s in secs] 257 268 table = render.build_sections_table( 258 - materialized, _COLUMNS, 259 - group=render.GroupColumn("G"), selected_flat_idx=0, 269 + materialized, 270 + _COLUMNS, 271 + group=render.GroupColumn("G"), 272 + selected_flat_idx=0, 260 273 ) 261 274 console = Console( 262 275 file=io.StringIO(), ··· 381 394 382 395 383 396 def test_format_datetime_applies_configured_timezone( 384 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 397 + monkeypatch: pytest.MonkeyPatch, 398 + tmp_path: Path, 385 399 ) -> None: 386 400 """Every listing command rendering a time goes through 387 401 `render.format_datetime` so `[display].timezone` is honored in one 388 402 place — not re-implemented per handler. 389 403 """ 390 404 from datetime import UTC, datetime 405 + 391 406 monkeypatch.setenv("PM_CONFIG", str(_write_tz_config(tmp_path, "America/Los_Angeles"))) 392 407 # 2026-04-24 07:00 UTC -> 2026-04-24 00:00 PDT (-7h DST offset). 393 408 dt = datetime(2026, 4, 24, 7, 0, tzinfo=UTC) ··· 395 410 396 411 397 412 def test_format_log_time_applies_configured_timezone( 398 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 413 + monkeypatch: pytest.MonkeyPatch, 414 + tmp_path: Path, 399 415 ) -> None: 400 416 from datetime import UTC, datetime 417 + 401 418 monkeypatch.setenv("PM_CONFIG", str(_write_tz_config(tmp_path, "America/Los_Angeles"))) 402 419 dt = datetime(2026, 4, 24, 7, 0, 45, 123456, tzinfo=UTC) 403 420 # Same wall clock math + ms precision preserved for interleaved ··· 406 423 407 424 408 425 def test_format_datetime_falls_back_to_system_tz_when_unset( 409 - monkeypatch: pytest.MonkeyPatch, tmp_path: Path, 426 + monkeypatch: pytest.MonkeyPatch, 427 + tmp_path: Path, 410 428 ) -> None: 411 429 from datetime import UTC, datetime 430 + 412 431 monkeypatch.delenv("PM_CONFIG", raising=False) 413 432 monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 414 433 monkeypatch.setenv("HOME", str(tmp_path))
+114 -61
tests/test_repo.py
··· 12 12 13 13 def _init_repo(path: Path, branch: str = "main") -> None: 14 14 subprocess.run(["git", "init", "-q", "-b", branch, str(path)], check=True) 15 - subprocess.run( 16 - ["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True 17 - ) 15 + subprocess.run(["git", "-C", str(path), "config", "user.email", "test@example.com"], check=True) 18 16 subprocess.run(["git", "-C", str(path), "config", "user.name", "test"], check=True) 19 - subprocess.run( 20 - ["git", "-C", str(path), "config", "commit.gpgsign", "false"], check=True 21 - ) 17 + subprocess.run(["git", "-C", str(path), "config", "commit.gpgsign", "false"], check=True) 22 18 (path / "README.md").write_text("# test\n") 23 19 subprocess.run(["git", "-C", str(path), "add", "README.md"], check=True) 24 20 subprocess.run( 25 - ["git", "-C", str(path), "-c", "core.hooksPath=/dev/null", 26 - "commit", "-q", "-m", "init"], 21 + ["git", "-C", str(path), "-c", "core.hooksPath=/dev/null", "commit", "-q", "-m", "init"], 27 22 check=True, 28 23 ) 29 24 ··· 32 27 (repo / name).write_text(content) 33 28 subprocess.run(["git", "-C", str(repo), "add", name], check=True) 34 29 subprocess.run( 35 - ["git", "-C", str(repo), "-c", "core.hooksPath=/dev/null", 36 - "commit", "-q", "-m", f"add {name}"], 30 + [ 31 + "git", 32 + "-C", 33 + str(repo), 34 + "-c", 35 + "core.hooksPath=/dev/null", 36 + "commit", 37 + "-q", 38 + "-m", 39 + f"add {name}", 40 + ], 37 41 check=True, 38 42 ) 39 43 ··· 44 48 ["git", "-C", str(local), "config", "user.email", "test@example.com"], check=True 45 49 ) 46 50 subprocess.run(["git", "-C", str(local), "config", "user.name", "test"], check=True) 47 - subprocess.run( 48 - ["git", "-C", str(local), "config", "commit.gpgsign", "false"], check=True 49 - ) 51 + subprocess.run(["git", "-C", str(local), "config", "commit.gpgsign", "false"], check=True) 50 52 subprocess.run(["git", "-C", str(local), "checkout", "-q", branch], check=True) 51 53 52 54 ··· 71 73 _init_repo(repo, branch="main") 72 74 sha = subprocess.run( 73 75 ["git", "-C", str(repo), "rev-parse", "HEAD"], 74 - capture_output=True, text=True, check=True, 76 + capture_output=True, 77 + text=True, 78 + check=True, 75 79 ).stdout.strip() 76 80 subprocess.run(["git", "-C", str(repo), "checkout", "-q", sha], check=True) 77 81 ··· 113 117 114 118 115 119 def test_repo_ls_fetch_needed_when_remote_moves( 116 - pm_env: Paths, tmp_path: Path, 120 + pm_env: Paths, 121 + tmp_path: Path, 117 122 ) -> None: 118 123 upstream = tmp_path / "upstream" 119 124 upstream.mkdir() ··· 137 142 138 143 139 144 def test_repo_ls_expected_branch_from_origin_head( 140 - pm_env: Paths, tmp_path: Path, 145 + pm_env: Paths, 146 + tmp_path: Path, 141 147 ) -> None: 142 148 upstream = tmp_path / "upstream" 143 149 upstream.mkdir() ··· 161 167 162 168 163 169 def test_repo_ls_expected_branch_from_stacker_config( 164 - pm_env: Paths, tmp_path: Path, 170 + pm_env: Paths, 171 + tmp_path: Path, 165 172 ) -> None: 166 173 from project_manager.stacker import config_schema 167 174 from project_manager.stacker.db import StackerDB ··· 182 189 183 190 184 191 def test_repo_ls_offline_skips_remote_check( 185 - pm_env: Paths, tmp_path: Path, 192 + pm_env: Paths, 193 + tmp_path: Path, 186 194 ) -> None: 187 195 upstream = tmp_path / "upstream" 188 196 upstream.mkdir() ··· 226 234 227 235 head_before = subprocess.run( 228 236 ["git", "-C", str(local), "rev-parse", "HEAD"], 229 - capture_output=True, text=True, check=True, 237 + capture_output=True, 238 + text=True, 239 + check=True, 230 240 ).stdout.strip() 231 241 232 242 results = pull_mod.pull(pm_env, ["foo"]) ··· 235 245 236 246 head_after = subprocess.run( 237 247 ["git", "-C", str(local), "rev-parse", "HEAD"], 238 - capture_output=True, text=True, check=True, 248 + capture_output=True, 249 + text=True, 250 + check=True, 239 251 ).stdout.strip() 240 252 assert head_before == head_after 241 253 ··· 302 314 assert "no upstream" in results[0].message 303 315 304 316 305 - def _setup_parent_with_submodule( 306 - tmp_path: Path, local: Path 307 - ) -> tuple[Path, Path]: 317 + def _setup_parent_with_submodule(tmp_path: Path, local: Path) -> tuple[Path, Path]: 308 318 """Create sub-upstream, parent-upstream (with sub as submodule), and clone to `local`. 309 319 310 320 Returns (parent_upstream, sub_upstream). Upstream dirs are namespaced by ··· 319 329 parent_upstream.mkdir() 320 330 _init_repo(parent_upstream, branch="main") 321 331 subprocess.run( 322 - ["git", "-C", str(parent_upstream), "-c", "protocol.file.allow=always", 323 - "submodule", "add", "-q", str(sub_upstream), "vendor/sub"], 332 + [ 333 + "git", 334 + "-C", 335 + str(parent_upstream), 336 + "-c", 337 + "protocol.file.allow=always", 338 + "submodule", 339 + "add", 340 + "-q", 341 + str(sub_upstream), 342 + "vendor/sub", 343 + ], 324 344 check=True, 325 345 ) 326 346 subprocess.run( 327 - ["git", "-C", str(parent_upstream), "-c", "core.hooksPath=/dev/null", 328 - "commit", "-q", "-m", "add submodule"], 347 + [ 348 + "git", 349 + "-C", 350 + str(parent_upstream), 351 + "-c", 352 + "core.hooksPath=/dev/null", 353 + "commit", 354 + "-q", 355 + "-m", 356 + "add submodule", 357 + ], 329 358 check=True, 330 359 ) 331 360 332 361 subprocess.run( 333 - ["git", "-c", "protocol.file.allow=always", "clone", "-q", 334 - "--recurse-submodules", str(parent_upstream), str(local)], 362 + [ 363 + "git", 364 + "-c", 365 + "protocol.file.allow=always", 366 + "clone", 367 + "-q", 368 + "--recurse-submodules", 369 + str(parent_upstream), 370 + str(local), 371 + ], 335 372 check=True, 336 373 ) 337 374 subprocess.run( ··· 339 376 check=True, 340 377 ) 341 378 subprocess.run( 342 - ["git", "-C", str(local), "config", "user.name", "test"], check=True, 379 + ["git", "-C", str(local), "config", "user.name", "test"], 380 + check=True, 343 381 ) 344 382 subprocess.run( 345 383 ["git", "-C", str(local), "config", "commit.gpgsign", "false"], ··· 357 395 """Advance sub_upstream by one commit and point parent_upstream's gitlink at it.""" 358 396 _commit_file(sub_upstream, "sub-new.txt") 359 397 subprocess.run( 360 - ["git", "-C", str(parent_upstream / "vendor/sub"), 361 - "-c", "protocol.file.allow=always", "fetch", "-q", "origin", "main"], 398 + [ 399 + "git", 400 + "-C", 401 + str(parent_upstream / "vendor/sub"), 402 + "-c", 403 + "protocol.file.allow=always", 404 + "fetch", 405 + "-q", 406 + "origin", 407 + "main", 408 + ], 362 409 check=True, 363 410 ) 364 411 subprocess.run( 365 - ["git", "-C", str(parent_upstream / "vendor/sub"), 366 - "checkout", "-q", "FETCH_HEAD"], 412 + ["git", "-C", str(parent_upstream / "vendor/sub"), "checkout", "-q", "FETCH_HEAD"], 367 413 check=True, 368 414 ) 369 415 subprocess.run( 370 - ["git", "-C", str(parent_upstream), "add", "vendor/sub"], check=True, 416 + ["git", "-C", str(parent_upstream), "add", "vendor/sub"], 417 + check=True, 371 418 ) 372 419 subprocess.run( 373 - ["git", "-C", str(parent_upstream), "-c", "core.hooksPath=/dev/null", 374 - "commit", "-q", "-m", "bump submodule"], 420 + [ 421 + "git", 422 + "-C", 423 + str(parent_upstream), 424 + "-c", 425 + "core.hooksPath=/dev/null", 426 + "commit", 427 + "-q", 428 + "-m", 429 + "bump submodule", 430 + ], 375 431 check=True, 376 432 ) 377 433 378 434 379 - def test_repo_pull_ignores_out_of_date_submodules( 380 - pm_env: Paths, tmp_path: Path 381 - ) -> None: 435 + def test_repo_pull_ignores_out_of_date_submodules(pm_env: Paths, tmp_path: Path) -> None: 382 436 """Stale submodule working trees must not block a parent fast-forward. 383 437 384 438 When the parent repo's gitlink for a submodule advances (e.g. because ··· 394 448 # the local submodule working tree stale. 395 449 _bump_submodule_pointer(parent_upstream, sub_upstream) 396 450 subprocess.run( 397 - ["git", "-C", str(local), "fetch", "-q", "origin"], check=True, 451 + ["git", "-C", str(local), "fetch", "-q", "origin"], 452 + check=True, 398 453 ) 399 454 subprocess.run( 400 - ["git", "-C", str(local), "merge", "-q", "--ff-only", "@{u}"], check=True, 455 + ["git", "-C", str(local), "merge", "-q", "--ff-only", "@{u}"], 456 + check=True, 401 457 ) 402 458 porcelain = subprocess.run( 403 459 ["git", "-C", str(local), "status", "--porcelain"], 404 - capture_output=True, text=True, check=True, 460 + capture_output=True, 461 + text=True, 462 + check=True, 405 463 ).stdout 406 464 assert "vendor/sub" in porcelain, "precondition: local starts with stale submodule" 407 465 ··· 414 472 assert (local / "parent-new.txt").is_file() 415 473 416 474 417 - def test_repo_pull_updates_submodules_on_fast_forward( 418 - pm_env: Paths, tmp_path: Path 419 - ) -> None: 475 + def test_repo_pull_updates_submodules_on_fast_forward(pm_env: Paths, tmp_path: Path) -> None: 420 476 local = pm_env.repo("foo") 421 477 parent_upstream, sub_upstream = _setup_parent_with_submodule(tmp_path, local) 422 478 _bump_submodule_pointer(parent_upstream, sub_upstream) ··· 427 483 assert (local / "vendor/sub" / "sub-new.txt").is_file() 428 484 429 485 430 - def test_repo_pull_syncs_submodules_when_parent_up_to_date( 431 - pm_env: Paths, tmp_path: Path 432 - ) -> None: 486 + def test_repo_pull_syncs_submodules_when_parent_up_to_date(pm_env: Paths, tmp_path: Path) -> None: 433 487 """If the parent has nothing new but submodules are stale, pull still syncs them.""" 434 488 local = pm_env.repo("foo") 435 489 parent_upstream, sub_upstream = _setup_parent_with_submodule(tmp_path, local) ··· 437 491 # Produce a stale-submodule state without bringing in any new parent commits. 438 492 _bump_submodule_pointer(parent_upstream, sub_upstream) 439 493 subprocess.run( 440 - ["git", "-C", str(local), "fetch", "-q", "origin"], check=True, 494 + ["git", "-C", str(local), "fetch", "-q", "origin"], 495 + check=True, 441 496 ) 442 497 subprocess.run( 443 - ["git", "-C", str(local), "merge", "-q", "--ff-only", "@{u}"], check=True, 498 + ["git", "-C", str(local), "merge", "-q", "--ff-only", "@{u}"], 499 + check=True, 444 500 ) 445 501 assert not (local / "vendor/sub" / "sub-new.txt").is_file(), "precondition" 446 502 ··· 450 506 assert (local / "vendor/sub" / "sub-new.txt").is_file() 451 507 452 508 453 - def test_repo_ls_reports_submodule_state( 454 - pm_env: Paths, tmp_path: Path 455 - ) -> None: 509 + def test_repo_ls_reports_submodule_state(pm_env: Paths, tmp_path: Path) -> None: 456 510 # Plain repo → no submodules. 457 511 plain = pm_env.repo("plain") 458 512 plain.mkdir() ··· 467 521 stale_parent, stale_sub = _setup_parent_with_submodule(tmp_path, stale_local) 468 522 _bump_submodule_pointer(stale_parent, stale_sub) 469 523 subprocess.run( 470 - ["git", "-C", str(stale_local), "fetch", "-q", "origin"], check=True, 524 + ["git", "-C", str(stale_local), "fetch", "-q", "origin"], 525 + check=True, 471 526 ) 472 527 subprocess.run( 473 528 ["git", "-C", str(stale_local), "merge", "-q", "--ff-only", "@{u}"], ··· 493 548 slot_dir = pm_env.pool(repo) / uuid 494 549 slot_dir.parent.mkdir(parents=True, exist_ok=True) 495 550 subprocess.run( 496 - ["git", "-C", str(pm_env.repo(repo)), "worktree", "add", "--detach", 497 - str(slot_dir), "HEAD"], 498 - check=True, capture_output=True, 551 + ["git", "-C", str(pm_env.repo(repo)), "worktree", "add", "--detach", str(slot_dir), "HEAD"], 552 + check=True, 553 + capture_output=True, 499 554 ) 500 555 return slot_dir 501 556 ··· 516 571 assert ("(repo)", "worktree-prune") in ops 517 572 assert ("(repo)", "update-index") in ops 518 573 assert ("aaaa", "update-index") in ops 519 - assert all(r.ok for r in results), [ 520 - (r.target, r.op, r.message) for r in results if not r.ok 521 - ] 574 + assert all(r.ok for r in results), [(r.target, r.op, r.message) for r in results if not r.ok] 522 575 # Sanity: worktree metadata survived the prune call. 523 576 assert (slot_dir / ".git").exists() 524 577
+7 -6
tests/test_slot.py
··· 60 60 61 61 62 62 def _claim_child( 63 - paths: Paths, repo: str, uuid: str, index: int, queue: "mp.Queue[str]", 63 + paths: Paths, 64 + repo: str, 65 + uuid: str, 66 + index: int, 67 + queue: "mp.Queue[str]", 64 68 ) -> None: 65 69 """Run in a spawned child; report outcome to `queue`.""" 66 70 pooldb = PoolDB(paths.pool_db()) ··· 87 91 queue: mp.Queue[str] = ctx.Queue() 88 92 n = 8 89 93 procs = [ 90 - ctx.Process(target=_claim_child, args=(pm_env, s.repo, s.uuid, i, queue)) 91 - for i in range(n) 94 + ctx.Process(target=_claim_child, args=(pm_env, s.repo, s.uuid, i, queue)) for i in range(n) 92 95 ] 93 96 for p in procs: 94 97 p.start() ··· 105 108 assert len(losses) == n - 1 106 109 107 110 winner_idx = int(wins[0][1:]) 108 - assert _pooldb(pm_env).get_owner(s.repo, s.uuid) == Owner( 109 - OwnerKind.PROJECT, f"p{winner_idx}" 110 - ) 111 + assert _pooldb(pm_env).get_owner(s.repo, s.uuid) == Owner(OwnerKind.PROJECT, f"p{winner_idx}")
+1 -3
tests/test_status.py
··· 107 107 alpha = status_mod.status(pm_env, "alpha") 108 108 beta = status_mod.status(pm_env, "beta") 109 109 assert any(r.finding.kind == check_mod.Kind.ORPHAN_OWNER for r in alpha.worktrees) 110 - assert all( 111 - r.finding.kind != check_mod.Kind.ORPHAN_OWNER for r in beta.worktrees 112 - ) 110 + assert all(r.finding.kind != check_mod.Kind.ORPHAN_OWNER for r in beta.worktrees) 113 111 114 112 115 113 def test_status_orphan_owner_row_has_no_wt(pm_env: Paths) -> None:
+44 -22
tests/test_tui.py
··· 6 6 `pick`'s TTY guard is exercised directly (it raises before touching 7 7 termios). 8 8 """ 9 + 9 10 from __future__ import annotations 10 11 11 12 import io ··· 61 62 ) -> _Row | None: 62 63 materialized = [(s.title, list(s.rows)) for s in sections] 63 64 flat_rows = [r for _, rs in materialized for r in rs] 64 - selectable_idxs = [ 65 - i for i, r in enumerate(flat_rows) if is_selectable(r) 66 - ] 65 + selectable_idxs = [i for i, r in enumerate(flat_rows) if is_selectable(r)] 67 66 return tui._pick_core( 68 - materialized, flat_rows, selectable_idxs, _COLUMNS, 69 - group=GroupColumn("G"), header=None, 70 - read_key=_script(keys), console=_sink_console(), 67 + materialized, 68 + flat_rows, 69 + selectable_idxs, 70 + _COLUMNS, 71 + group=GroupColumn("G"), 72 + header=None, 73 + read_key=_script(keys), 74 + console=_sink_console(), 71 75 ) 72 76 73 77 ··· 95 99 def test_down_at_bottom_stays_at_bottom() -> None: 96 100 secs = [Section(title="s", rows=[_Row("a"), _Row("b")])] 97 101 assert _call_core( 98 - secs, [Key.DOWN, Key.DOWN, Key.DOWN, Key.ENTER], 102 + secs, 103 + [Key.DOWN, Key.DOWN, Key.DOWN, Key.ENTER], 99 104 ) == _Row("b") 100 105 101 106 ··· 109 114 # OTHER should not move the cursor or select — only the final ENTER 110 115 # resolves the pick, still on row 0. 111 116 assert _call_core( 112 - secs, [Key.OTHER, Key.OTHER, Key.ENTER], 117 + secs, 118 + [Key.OTHER, Key.OTHER, Key.ENTER], 113 119 ) == _Row("a") 114 120 115 121 116 122 def test_keyboard_interrupt_cancels() -> None: 117 123 """Ctrl-C during cbreak raises KeyboardInterrupt in the caller's 118 124 key reader; the picker treats it as cancel, not an unhandled crash.""" 125 + 119 126 def read() -> Key: 120 127 raise KeyboardInterrupt 121 128 122 129 materialized = [("s", [_Row("a")])] 123 130 got = tui._pick_core( 124 - materialized, [_Row("a")], [0], _COLUMNS, 125 - group=GroupColumn("G"), header=None, 126 - read_key=read, console=_sink_console(), 131 + materialized, 132 + [_Row("a")], 133 + [0], 134 + _COLUMNS, 135 + group=GroupColumn("G"), 136 + header=None, 137 + read_key=read, 138 + console=_sink_console(), 127 139 ) 128 140 assert got is None 129 141 ··· 133 145 `pm agent ls`) but navigation hops over them — DOWN from row 0 134 146 lands on row 2, skipping the middle placeholder.""" 135 147 secs = [ 136 - Section(title="s", rows=[ 137 - _Row("a", selectable=True), 138 - _Row("placeholder", selectable=False), 139 - _Row("c", selectable=True), 140 - ]), 148 + Section( 149 + title="s", 150 + rows=[ 151 + _Row("a", selectable=True), 152 + _Row("placeholder", selectable=False), 153 + _Row("c", selectable=True), 154 + ], 155 + ), 141 156 ] 142 157 got = _call_core( 143 - secs, [Key.DOWN, Key.ENTER], 158 + secs, 159 + [Key.DOWN, Key.ENTER], 144 160 is_selectable=lambda r: r.selectable, 145 161 ) 146 162 assert got == _Row("c") ··· 151 167 scripted reader is never consumed, which our `_script` would 152 168 flag as an assertion error if the picker tried to read.""" 153 169 secs = [ 154 - Section(title="s", rows=[ 155 - _Row("p1", selectable=False), 156 - _Row("p2", selectable=False), 157 - ]), 170 + Section( 171 + title="s", 172 + rows=[ 173 + _Row("p1", selectable=False), 174 + _Row("p2", selectable=False), 175 + ], 176 + ), 158 177 ] 159 178 got = tui.pick( 160 - secs, _COLUMNS, group=GroupColumn("G"), 179 + secs, 180 + _COLUMNS, 181 + group=GroupColumn("G"), 161 182 is_selectable=lambda r: r.selectable, 162 183 ) 163 184 assert got is None ··· 173 194 ) -> None: 174 195 """Library-level guard — call sites should normally pre-check, but 175 196 `pick` still fails loudly rather than silently hanging on a non-TTY.""" 197 + 176 198 class NonTtyStdin: 177 199 def isatty(self) -> bool: 178 200 return False