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

Configure Feed

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

Initial pm scaffolding with project + pool management

Jordan Isaacs (Apr 22, 2026, 9:25 PM UTC) bfd6afa9 13f69942

+1655 -1
+11
.gitignore
··· 1 + .venv/ 2 + __pycache__/ 3 + *.py[cod] 4 + .pytest_cache/ 5 + .ruff_cache/ 6 + dist/ 7 + build/ 8 + *.egg-info/ 9 + 10 + # pm ownership marker — this symlink lives at the worktree root when the slot is claimed. 11 + .owner
+16
Makefile
··· 1 + .PHONY: check lint typecheck test fmt 2 + 3 + check: lint typecheck test 4 + 5 + lint: 6 + uv run ruff check src tests 7 + 8 + typecheck: 9 + uv run ty check src tests 10 + 11 + test: 12 + uv run pytest 13 + 14 + fmt: 15 + uv run ruff format src tests 16 + uv run ruff check --fix --unsafe-fixes src tests
+124 -1
README.md
··· 1 - # project-manager 1 + # project-manager (`pm`) 2 + 3 + A small CLI that manages a claim-release pool of git worktrees and binds them to named projects via symlinks. 4 + 5 + ## What it does 6 + 7 + - **Pool**: stable UUID-named worktree slots under `~/.worktrees/<repo>/<uuid>/`. Reused across projects — checking out another branch does not re-incur cold-build cost. 8 + - **Projects**: a project is a directory of forward symlinks at `~/.projects/<project>/<repo>`, each pointing at a claimed pool slot. 9 + - **Atomic claim**: a slot is "owned" by the project whose forward symlink it's bound to. Ownership is recorded by a reverse symlink `<slot>/.owner` created via `ln -s`, which is POSIX-atomic — two concurrent claimers racing for the same slot, exactly one wins. 10 + - **No branch management**: `pm` does not touch branches or stacks. Whatever checkout state the slot already has is inherited. Layer `git-stack` / Rodrigo's `stacker` on top as you wish. 11 + 12 + ## Install 13 + 14 + ```bash 15 + uv sync 16 + ``` 17 + 18 + Adds `pm` to the venv. Activate the venv or run commands through `uv run pm …`. 19 + 20 + ## Paths (configurable) 21 + 22 + | Role | Default | Override | 23 + |---|---|---| 24 + | canonical repo checkouts | `~/.repos/<repo>/` | `paths.repos` | 25 + | pool slots | `~/.worktrees/<repo>/<uuid>/` | `paths.worktrees` | 26 + | forward symlinks | `~/.projects/<project>/<repo>` | `paths.projects` | 27 + 28 + Config resolution order: `$PM_CONFIG` → `$XDG_CONFIG_HOME/pm/config.toml` → `~/.config/pm/config.toml` → built-in defaults. Schema: 29 + 30 + ```toml 31 + [paths] 32 + repos = "~/.repos" 33 + worktrees = "~/.worktrees" 34 + projects = "~/.projects" 35 + ``` 36 + 37 + ## Commands 38 + 39 + ``` 40 + pm project new <name> --repos r1,r2,... # claim a free slot per repo 41 + pm project release <name> # drop .owner, keep forward symlinks 42 + pm project delete <name> # release + remove forward symlinks + rmdir 43 + pm project ls # active projects and their bindings 44 + pm pool ls [<repo>] # pool slots with claim status 45 + pm pool add <repo> # mint a new slot (git worktree add --detach) 46 + pm check [--fix] # invariant scan; --fix reconciles orphans/broken 47 + ``` 48 + 49 + ### Example 50 + 51 + ```bash 52 + # Mint two slots for universe 53 + pm pool add universe 54 + pm pool add universe 55 + 56 + pm pool ls universe 57 + # universe <uuid-1> FREE 58 + # universe <uuid-2> FREE 59 + 60 + # Create a project using one slot 61 + pm project new my-feature --repos universe 62 + ls -la ~/.projects/my-feature/ 63 + # my-feature/universe -> ~/.worktrees/universe/<uuid-1> 64 + 65 + # Release — slot is back in the pool, forward symlink stays for browsing 66 + pm project release my-feature 67 + pm pool ls universe 68 + # universe <uuid-1> FREE 69 + # universe <uuid-2> FREE 70 + 71 + # Delete — remove the project entirely (fails if extra files exist) 72 + pm project delete my-feature 73 + ``` 74 + 75 + ## Invariants (`pm check`) 76 + 77 + For every slot `S = <worktrees>/<repo>/<uuid>/`: 78 + - If `S/.owner` exists, it must point at a forward symlink that points back at `S`. Otherwise → **orphan**. 79 + 80 + For every forward `P = <projects>/<name>/<repo>`: 81 + - `P` must point at an existing slot `S`. Otherwise → **broken**. 82 + - If `S/.owner` points back at `P` → **active** (healthy). 83 + - If `S/.owner` is absent → **detached** (post-release state; intentional). 84 + - If `S/.owner` points at a different forward → **stale** (slot was re-claimed after this project released it). 85 + 86 + `pm check --fix` reconciles orphans and broken entries. Detached and stale are intentional states and left alone. 87 + 88 + ## Testing 89 + 90 + ```bash 91 + make check # ruff + ty + pytest 92 + make lint # just ruff 93 + make typecheck # just ty (strict via [tool.ty.terminal] error-on-warning) 94 + make test # just pytest 95 + make fmt # ruff format + auto-fix 96 + ``` 97 + 98 + Tests run in isolated `tmp_path` via the `pm_env` fixture — nothing touches real `~/.repos`, `~/.worktrees`, or `~/.projects`. 99 + 100 + ## Layout 101 + 102 + ``` 103 + src/project_manager/ 104 + ├── cli.py # top-level argparse 105 + ├── config.py # TOML loader 106 + ├── paths.py # Paths(repos, worktrees, projects) dataclass 107 + ├── check.py # invariant scan + --fix 108 + ├── project/ 109 + │ ├── cli.py 110 + │ ├── new.py # claim + forward-link with rollback 111 + │ ├── release.py # drop .owner 112 + │ ├── delete.py # release + remove forward symlinks + rmdir 113 + │ └── ls.py 114 + └── pool/ 115 + ├── cli.py 116 + ├── slot.py # atomic claim()/release() via os.symlink 117 + ├── add.py # mint a new UUID slot 118 + ├── ls.py 119 + └── worktree.py # git worktree add, submodule init, CLAUDE copy, init.sh 120 + ``` 121 + 122 + ## Acknowledgements 123 + 124 + `pool/worktree.py` ports the submodule-init-with-`--reference`, CLAUDE-file copy, and `init.sh` runner from Rodrigo Gomes' standalone `wt` script (`experimental/rodrigo-gomes_data/wt/wt` @ `ab3b42e526cf8` in databricks-eng/universe).
+72
pyproject.toml
··· 1 + [project] 2 + name = "project-manager" 3 + version = "0.1.0" 4 + description = "Claim-release pool of git worktrees bound to named projects via symlinks." 5 + readme = "README.md" 6 + authors = [ 7 + { name = "Jordan Isaacs", email = "jordan.isaacs@databricks.com" } 8 + ] 9 + requires-python = ">=3.11" 10 + dependencies = [] 11 + 12 + [project.scripts] 13 + pm = "project_manager.cli:main" 14 + 15 + [build-system] 16 + requires = ["uv_build>=0.11.2,<0.12.0"] 17 + build-backend = "uv_build" 18 + 19 + [dependency-groups] 20 + dev = [ 21 + "pytest>=8.0", 22 + "ruff>=0.8", 23 + "ty>=0.0.1a1", 24 + ] 25 + 26 + [tool.ruff] 27 + line-length = 100 28 + target-version = "py311" 29 + 30 + [tool.ruff.lint] 31 + select = ["ALL"] 32 + ignore = [ 33 + # Docstring enforcement across the project is noise for a small CLI. 34 + "D", 35 + # Trailing-comma rules conflict with the formatter. 36 + "COM812", 37 + # Copyright headers. 38 + "CPY", 39 + # print() is how the CLI talks to stdout. 40 + "T201", 41 + # Allow Exception subclasses without a trailing period in the message. 42 + "EM101", "EM102", 43 + # Allow implicit Optional via `X | None` — we already require 3.11. 44 + "TC", 45 + # Don't require from __future__ import annotations. 46 + "FA", 47 + # Too-long messages in raise — we prefer expressive errors. 48 + "TRY003", 49 + # Using assert in tests is fine. 50 + "S101", 51 + # subprocess calls are deliberate and vetted. 52 + "S603", "S607", 53 + ] 54 + 55 + [tool.ruff.lint.per-file-ignores] 56 + "tests/*" = [ 57 + "ANN", # tests don't need full annotation coverage 58 + "PLR2004", # magic numbers in tests are expected 59 + "SLF001", # tests may poke at privates 60 + ] 61 + 62 + [tool.ruff.lint.pydocstyle] 63 + convention = "google" 64 + 65 + # ty is maximal via `[tool.ty.terminal] error-on-warning`. This picks up every 66 + # default-warn rule automatically, including any added in future ty releases — 67 + # no hand-maintained list to drift. 68 + [tool.ty.terminal] 69 + error-on-warning = true 70 + 71 + [tool.pytest.ini_options] 72 + testpaths = ["tests"]
src/project_manager/__init__.py

This is a binary file and will not be displayed.

+4
src/project_manager/__main__.py
··· 1 + from project_manager.cli import main 2 + 3 + if __name__ == "__main__": 4 + main()
+124
src/project_manager/check.py
··· 1 + import contextlib 2 + from collections.abc import Iterator 3 + from dataclasses import dataclass 4 + from enum import StrEnum 5 + from pathlib import Path 6 + 7 + from project_manager.paths import Paths 8 + from project_manager.pool import slot as slot_mod 9 + 10 + 11 + class Kind(StrEnum): 12 + ACTIVE = "active" # healthy: forward ↔ slot both link back 13 + DETACHED = "detached" # forward → slot, no .owner (post-release) 14 + STALE = "stale" # forward → slot, .owner → different forward 15 + BROKEN = "broken" # forward target does not exist 16 + ORPHAN = "orphan" # slot has .owner but forward missing/wrong 17 + 18 + 19 + @dataclass(frozen=True) 20 + class Finding: 21 + kind: Kind 22 + slot_path: Path | None 23 + forward_path: Path | None 24 + detail: str 25 + 26 + 27 + def _slot_findings(paths: Paths) -> Iterator[Finding]: 28 + if not paths.worktrees.is_dir(): 29 + return 30 + for repo_dir in sorted(paths.worktrees.iterdir()): 31 + if not repo_dir.is_dir(): 32 + continue 33 + for s in slot_mod.list_slots(paths, repo_dir.name): 34 + owner = s.owner_target() 35 + if owner is None: 36 + continue 37 + if not owner.is_symlink(): 38 + yield Finding( 39 + kind=Kind.ORPHAN, 40 + slot_path=s.path, 41 + forward_path=owner, 42 + detail=f".owner → {owner} does not exist", 43 + ) 44 + continue 45 + forward_target = owner.readlink() 46 + if forward_target != s.path: 47 + yield Finding( 48 + kind=Kind.ORPHAN, 49 + slot_path=s.path, 50 + forward_path=owner, 51 + detail=( 52 + f".owner → {owner} but that forward points at {forward_target}, " 53 + f"not back at the slot" 54 + ), 55 + ) 56 + 57 + 58 + def _classify_forward(entry: Path, target: Path) -> Finding: 59 + if not target.is_dir(): 60 + return Finding( 61 + kind=Kind.BROKEN, 62 + slot_path=target, 63 + forward_path=entry, 64 + detail=f"forward → {target} does not exist", 65 + ) 66 + s = slot_mod.Slot(repo=entry.name, uuid=target.name, path=target) 67 + owner = s.owner_target() 68 + if owner is None: 69 + return Finding( 70 + kind=Kind.DETACHED, 71 + slot_path=s.path, 72 + forward_path=entry, 73 + detail="slot has no .owner (post-release)", 74 + ) 75 + if owner == entry: 76 + return Finding( 77 + kind=Kind.ACTIVE, 78 + slot_path=s.path, 79 + forward_path=entry, 80 + detail="healthy", 81 + ) 82 + return Finding( 83 + kind=Kind.STALE, 84 + slot_path=s.path, 85 + forward_path=entry, 86 + detail=f"slot .owner points at {owner}", 87 + ) 88 + 89 + 90 + def _forward_findings(paths: Paths) -> Iterator[Finding]: 91 + if not paths.projects.is_dir(): 92 + return 93 + for project_dir in sorted(paths.projects.iterdir()): 94 + if not project_dir.is_dir(): 95 + continue 96 + for entry in sorted(project_dir.iterdir()): 97 + if not entry.is_symlink(): 98 + continue 99 + yield _classify_forward(entry, entry.readlink()) 100 + 101 + 102 + def check(paths: Paths) -> list[Finding]: 103 + return list(_slot_findings(paths)) + list(_forward_findings(paths)) 104 + 105 + 106 + def fix(paths: Paths, findings: list[Finding]) -> int: 107 + """Apply safe fixes. Returns count of applied fixes. 108 + 109 + Safe: orphan (remove stray .owner), broken (remove dangling forward symlink). 110 + Not touched: detached / stale (those are intentional post-release states). 111 + """ 112 + del paths 113 + n = 0 114 + for f in findings: 115 + if f.kind == Kind.ORPHAN and f.slot_path is not None: 116 + owner_path = f.slot_path / slot_mod.OWNER_FILENAME 117 + with contextlib.suppress(FileNotFoundError): 118 + owner_path.unlink() 119 + n += 1 120 + elif f.kind == Kind.BROKEN and f.forward_path is not None: 121 + with contextlib.suppress(FileNotFoundError): 122 + f.forward_path.unlink() 123 + n += 1 124 + return n
+43
src/project_manager/cli.py
··· 1 + import argparse 2 + import sys 3 + 4 + from project_manager import check as check_mod 5 + from project_manager import config 6 + from project_manager.pool import cli as pool_cli 7 + from project_manager.project import cli as project_cli 8 + 9 + 10 + def _cmd_check(args: argparse.Namespace) -> int: 11 + paths = config.load() 12 + findings = check_mod.check(paths) 13 + for f in findings: 14 + slot = f.slot_path if f.slot_path is not None else "-" 15 + forward = f.forward_path if f.forward_path is not None else "-" 16 + print(f"{f.kind.value}\t{forward}\t{slot}\t{f.detail}") 17 + if args.fix: 18 + applied = check_mod.fix(paths, findings) 19 + print(f"# applied {applied} fix(es)", file=sys.stderr) 20 + non_healthy = [f for f in findings if f.kind != check_mod.Kind.ACTIVE] 21 + return 1 if non_healthy and not args.fix else 0 22 + 23 + 24 + def main(argv: list[str] | None = None) -> int: 25 + parser = argparse.ArgumentParser( 26 + prog="pm", 27 + description="Project manager: pool of git worktrees bound to named projects via symlinks.", 28 + ) 29 + subparsers = parser.add_subparsers(dest="group", required=True) 30 + 31 + project_cli.add_subparser(subparsers) 32 + pool_cli.add_subparser(subparsers) 33 + 34 + check = subparsers.add_parser("check", help="verify invariants across pool and projects") 35 + check.add_argument("--fix", action="store_true", help="apply safe reconciliations") 36 + check.set_defaults(func=_cmd_check) 37 + 38 + args = parser.parse_args(argv) 39 + return args.func(args) 40 + 41 + 42 + if __name__ == "__main__": 43 + sys.exit(main())
+47
src/project_manager/config.py
··· 1 + import os 2 + import tomllib 3 + from pathlib import Path 4 + 5 + from project_manager.paths import Paths 6 + 7 + DEFAULT_REPOS = "~/.repos" 8 + DEFAULT_WORKTREES = "~/.worktrees" 9 + DEFAULT_PROJECTS = "~/.projects" 10 + 11 + 12 + def _expand(value: str) -> Path: 13 + return Path(os.path.expandvars(value)).expanduser() 14 + 15 + 16 + def _resolve_config_path() -> Path | None: 17 + if explicit := os.environ.get("PM_CONFIG"): 18 + return Path(explicit) 19 + if xdg := os.environ.get("XDG_CONFIG_HOME"): 20 + candidate = Path(xdg) / "pm" / "config.toml" 21 + if candidate.exists(): 22 + return candidate 23 + candidate = Path.home() / ".config" / "pm" / "config.toml" 24 + if candidate.exists(): 25 + return candidate 26 + return None 27 + 28 + 29 + def load() -> Paths: 30 + config_path = _resolve_config_path() 31 + repos = DEFAULT_REPOS 32 + worktrees = DEFAULT_WORKTREES 33 + projects = DEFAULT_PROJECTS 34 + 35 + if config_path is not None: 36 + with config_path.open("rb") as f: 37 + data = tomllib.load(f) 38 + paths_section = data.get("paths", {}) 39 + repos = paths_section.get("repos", repos) 40 + worktrees = paths_section.get("worktrees", worktrees) 41 + projects = paths_section.get("projects", projects) 42 + 43 + return Paths( 44 + repos=_expand(repos), 45 + worktrees=_expand(worktrees), 46 + projects=_expand(projects), 47 + )
+27
src/project_manager/paths.py
··· 1 + from dataclasses import dataclass 2 + from pathlib import Path 3 + 4 + 5 + @dataclass(frozen=True) 6 + class Paths: 7 + repos: Path 8 + worktrees: Path 9 + projects: Path 10 + 11 + def repo(self, name: str) -> Path: 12 + return self.repos / name 13 + 14 + def pool(self, repo: str) -> Path: 15 + return self.worktrees / repo 16 + 17 + def slot(self, repo: str, uuid: str) -> Path: 18 + return self.worktrees / repo / uuid 19 + 20 + def owner(self, repo: str, uuid: str) -> Path: 21 + return self.worktrees / repo / uuid / ".owner" 22 + 23 + def project(self, name: str) -> Path: 24 + return self.projects / name 25 + 26 + def forward(self, project: str, repo: str) -> Path: 27 + return self.projects / project / repo
src/project_manager/pool/__init__.py

This is a binary file and will not be displayed.

+40
src/project_manager/pool/add.py
··· 1 + import subprocess 2 + import uuid as uuid_mod 3 + 4 + from project_manager.paths import Paths 5 + from project_manager.pool import worktree as wt 6 + from project_manager.pool.slot import Slot 7 + 8 + 9 + def add(paths: Paths, repo: str) -> Slot: 10 + """Mint a new UUID slot under the pool for `repo`. 11 + 12 + 1. git worktree add --detach <slot> <default-branch> 13 + 2. init_submodules (--reference sharing) 14 + 3. copy untracked CLAUDE.md / CLAUDE.local.md 15 + 4. run init.sh if present 16 + """ 17 + main_repo = paths.repo(repo) 18 + if not (main_repo / ".git").exists(): 19 + raise wt.GitError(f"no git repo at {main_repo}") 20 + 21 + pool_dir = paths.pool(repo) 22 + pool_dir.mkdir(parents=True, exist_ok=True) 23 + 24 + u = str(uuid_mod.uuid4()) 25 + slot_path = paths.slot(repo, u) 26 + 27 + branch = wt.default_branch(main_repo) 28 + wt.worktree_add_detached(main_repo, slot_path, branch) 29 + try: 30 + wt.init_submodules(main_repo, slot_path) 31 + wt.copy_claude_files(main_repo, slot_path) 32 + wt.run_init_script(main_repo, slot_path) 33 + except BaseException: 34 + subprocess.run( 35 + ["git", "-C", str(main_repo), "worktree", "remove", "--force", str(slot_path)], 36 + check=False, 37 + ) 38 + raise 39 + 40 + return Slot(repo=repo, uuid=u, path=slot_path)
+39
src/project_manager/pool/cli.py
··· 1 + import argparse 2 + import sys 3 + 4 + from project_manager import config 5 + from project_manager.pool import add as add_mod 6 + from project_manager.pool import ls as ls_mod 7 + from project_manager.pool import worktree as wt 8 + 9 + 10 + def _cmd_add(args: argparse.Namespace) -> int: 11 + paths = config.load() 12 + try: 13 + slot = add_mod.add(paths, args.repo) 14 + except wt.GitError as e: 15 + print(f"pm: {e}", file=sys.stderr) 16 + return 2 17 + print(f"{slot.repo}\t{slot.uuid}\t{slot.path}") 18 + return 0 19 + 20 + 21 + def _cmd_ls(args: argparse.Namespace) -> int: 22 + paths = config.load() 23 + rows = ls_mod.ls(paths, args.repo) 24 + for row in rows: 25 + print(f"{row.repo}\t{row.uuid}\t{row.status}") 26 + return 0 27 + 28 + 29 + def add_subparser(subparsers: argparse._SubParsersAction) -> None: 30 + pool = subparsers.add_parser("pool", help="manage worktree pool") 31 + sub = pool.add_subparsers(dest="cmd", required=True) 32 + 33 + ls = sub.add_parser("ls", help="list pool slots with claim status") 34 + ls.add_argument("repo", nargs="?", default=None) 35 + ls.set_defaults(func=_cmd_ls) 36 + 37 + add = sub.add_parser("add", help="mint a new pool slot") 38 + add.add_argument("repo") 39 + add.set_defaults(func=_cmd_add)
+32
src/project_manager/pool/ls.py
··· 1 + from dataclasses import dataclass 2 + 3 + from project_manager.paths import Paths 4 + from project_manager.pool import slot as slot_mod 5 + 6 + 7 + @dataclass(frozen=True) 8 + class PoolRow: 9 + repo: str 10 + uuid: str 11 + status: str # "FREE" or project name 12 + 13 + 14 + def ls(paths: Paths, repo: str | None) -> list[PoolRow]: 15 + repos: list[str] 16 + if repo is not None: 17 + repos = [repo] 18 + else: 19 + if not paths.worktrees.is_dir(): 20 + return [] 21 + repos = sorted( 22 + entry.name for entry in paths.worktrees.iterdir() if entry.is_dir() 23 + ) 24 + 25 + rows: list[PoolRow] = [] 26 + for r in repos: 27 + for s in slot_mod.list_slots(paths, r): 28 + owner = s.owner_target() 29 + # owner points at <projects>/<project>/<repo>; parent.name is the project 30 + status = "FREE" if owner is None else owner.parent.name 31 + rows.append(PoolRow(repo=r, uuid=s.uuid, status=status)) 32 + return rows
+67
src/project_manager/pool/slot.py
··· 1 + import contextlib 2 + from dataclasses import dataclass 3 + from pathlib import Path 4 + 5 + from project_manager.paths import Paths 6 + 7 + OWNER_FILENAME = ".owner" 8 + 9 + 10 + class SlotBusyError(Exception): 11 + """Another claimer won the race to create .owner.""" 12 + 13 + 14 + class PoolExhaustedError(Exception): 15 + """No free slots under the pool.""" 16 + 17 + 18 + @dataclass(frozen=True) 19 + class Slot: 20 + repo: str 21 + uuid: str 22 + path: Path 23 + 24 + @property 25 + def owner_path(self) -> Path: 26 + return self.path / OWNER_FILENAME 27 + 28 + def owner_target(self) -> Path | None: 29 + if not self.owner_path.is_symlink(): 30 + return None 31 + return self.owner_path.readlink() 32 + 33 + def is_free(self) -> bool: 34 + return not self.owner_path.is_symlink() 35 + 36 + 37 + def list_slots(paths: Paths, repo: str) -> list[Slot]: 38 + pool_dir = paths.pool(repo) 39 + if not pool_dir.is_dir(): 40 + return [] 41 + slots: list[Slot] = [] 42 + for entry in sorted(pool_dir.iterdir()): 43 + if not entry.is_dir(): 44 + continue 45 + slots.append(Slot(repo=repo, uuid=entry.name, path=entry)) 46 + return slots 47 + 48 + 49 + def free_slots(paths: Paths, repo: str) -> list[Slot]: 50 + return [s for s in list_slots(paths, repo) if s.is_free()] 51 + 52 + 53 + def claim(slot: Slot, forward_link: Path) -> None: 54 + """Atomically create slot/.owner -> forward_link. 55 + 56 + Raises SlotBusyError if another claimer already created .owner. 57 + """ 58 + try: 59 + slot.owner_path.symlink_to(forward_link) 60 + except FileExistsError as e: 61 + raise SlotBusyError(f"slot {slot.repo}/{slot.uuid} is already claimed") from e 62 + 63 + 64 + def release(slot: Slot) -> None: 65 + """Remove slot/.owner. Idempotent.""" 66 + with contextlib.suppress(FileNotFoundError): 67 + slot.owner_path.unlink()
+125
src/project_manager/pool/worktree.py
··· 1 + import contextlib 2 + import shutil 3 + import subprocess 4 + from pathlib import Path 5 + 6 + 7 + class GitError(Exception): 8 + pass 9 + 10 + 11 + def _git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: 12 + result = subprocess.run( 13 + ["git", "-C", str(repo), *args], 14 + capture_output=True, 15 + text=True, 16 + check=False, 17 + ) 18 + if check and result.returncode != 0: 19 + raise GitError( 20 + f"git -C {repo} {' '.join(args)} failed ({result.returncode}):\n{result.stderr}" 21 + ) 22 + return result 23 + 24 + 25 + def default_branch(repo: Path) -> str: 26 + """Best-effort default branch detection. 27 + 28 + origin/HEAD → current branch → error. 29 + """ 30 + result = _git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", check=False) 31 + if result.returncode == 0: 32 + ref = result.stdout.strip() 33 + if ref.startswith("refs/remotes/origin/"): 34 + return ref[len("refs/remotes/origin/") :] 35 + result = _git(repo, "branch", "--show-current", check=False) 36 + if result.returncode == 0 and result.stdout.strip(): 37 + return result.stdout.strip() 38 + raise GitError(f"could not determine default branch for {repo}") 39 + 40 + 41 + def worktree_add_detached(repo: Path, slot: Path, branch: str) -> None: 42 + _git(repo, "worktree", "add", "--detach", str(slot), branch) 43 + 44 + 45 + # git ls-tree -r HEAD lines are "<mode> <type> <sha>\t<path>" — 4 whitespace-separated fields. 46 + _LS_TREE_FIELDS = 4 47 + _SUBMODULE_MODE = "160000" 48 + 49 + 50 + def _submodule_entries(worktree: Path) -> list[tuple[str, str]]: 51 + """Return [(commit, path), ...] for every submodule pinned at HEAD.""" 52 + result = _git(worktree, "ls-tree", "-r", "HEAD") 53 + entries: list[tuple[str, str]] = [] 54 + for line in result.stdout.splitlines(): 55 + parts = line.split(None, 3) 56 + if len(parts) < _LS_TREE_FIELDS: 57 + continue 58 + mode, _obj_type, sha, path = parts 59 + if mode == _SUBMODULE_MODE: 60 + entries.append((sha, path)) 61 + return entries 62 + 63 + 64 + def init_submodules(main_repo: Path, worktree: Path) -> None: 65 + """For each submodule pinned at HEAD in the worktree: 66 + 67 + git clone --reference <main_repo>/<path> --no-checkout <url> <worktree>/<path> 68 + git -C <worktree>/<path> checkout <sha> 69 + 70 + Skips if main repo has not initialized the submodule. 71 + """ 72 + entries = _submodule_entries(worktree) 73 + for sha, path in entries: 74 + main_sub = main_repo / path 75 + worktree_sub = worktree / path 76 + if not (main_sub / ".git").exists(): 77 + raise GitError( 78 + f"submodule '{path}' is not initialized in main repo {main_repo}; " 79 + f"run `git -C {main_repo} submodule update --init --recursive` first" 80 + ) 81 + url = _git(main_sub, "remote", "get-url", "origin").stdout.strip() 82 + worktree_sub.parent.mkdir(parents=True, exist_ok=True) 83 + if worktree_sub.is_dir(): 84 + with contextlib.suppress(OSError): 85 + worktree_sub.rmdir() 86 + _git( 87 + worktree, 88 + "clone", 89 + "--reference", 90 + str(main_sub), 91 + "--no-checkout", 92 + url, 93 + str(worktree_sub), 94 + ) 95 + checkout = _git(worktree_sub, "checkout", "--quiet", sha, check=False) 96 + if checkout.returncode != 0: 97 + # try fetching the specific commit 98 + _git(worktree_sub, "fetch", "origin", sha, "--depth=1", check=False) 99 + _git(worktree_sub, "checkout", "--quiet", sha) 100 + 101 + 102 + def copy_claude_files(main_repo: Path, worktree: Path) -> None: 103 + """Copy untracked CLAUDE.md / CLAUDE.local.md from main repo into the worktree.""" 104 + for name in ("CLAUDE.md", "CLAUDE.local.md"): 105 + src = main_repo / name 106 + if not src.is_file(): 107 + continue 108 + tracked = _git(main_repo, "ls-files", "--error-unmatch", name, check=False) 109 + if tracked.returncode == 0: 110 + continue # tracked; git worktree add already put a copy in place 111 + shutil.copy2(src, worktree / name) 112 + 113 + 114 + def run_init_script(main_repo: Path, worktree: Path) -> None: 115 + """If init.sh exists as an untracked file in main_repo, copy it; then run it if executable.""" 116 + init_src = main_repo / "init.sh" 117 + init_dst = worktree / "init.sh" 118 + if init_src.is_file() and not init_dst.exists(): 119 + tracked = _git(main_repo, "ls-files", "--error-unmatch", "init.sh", check=False) 120 + if tracked.returncode != 0: 121 + shutil.copy2(init_src, init_dst) 122 + if not init_dst.is_file(): 123 + return 124 + cmd = [str(init_dst)] if init_dst.stat().st_mode & 0o111 else ["bash", str(init_dst)] 125 + subprocess.run(cmd, cwd=worktree, check=False)
src/project_manager/project/__init__.py

This is a binary file and will not be displayed.

+73
src/project_manager/project/cli.py
··· 1 + import argparse 2 + import sys 3 + 4 + from project_manager import config 5 + from project_manager.project import delete as delete_mod 6 + from project_manager.project import ls as ls_mod 7 + from project_manager.project import new as new_mod 8 + from project_manager.project import release as release_mod 9 + from project_manager.project.errors import ProjectError 10 + 11 + 12 + def _cmd_new(args: argparse.Namespace) -> int: 13 + paths = config.load() 14 + repos = [r.strip() for r in args.repos.split(",") if r.strip()] 15 + try: 16 + claimed = new_mod.new(paths, args.name, repos) 17 + except ProjectError as e: 18 + print(f"pm: {e}", file=sys.stderr) 19 + return 2 20 + for repo, slot in claimed: 21 + print(f"{repo}\t{slot.path}") 22 + return 0 23 + 24 + 25 + def _cmd_release(args: argparse.Namespace) -> int: 26 + paths = config.load() 27 + try: 28 + released = release_mod.release(paths, args.name) 29 + except ProjectError as e: 30 + print(f"pm: {e}", file=sys.stderr) 31 + return 2 32 + for slot in released: 33 + print(f"{slot.repo}\t{slot.path}") 34 + return 0 35 + 36 + 37 + def _cmd_delete(args: argparse.Namespace) -> int: 38 + paths = config.load() 39 + try: 40 + delete_mod.delete(paths, args.name) 41 + except ProjectError as e: 42 + print(f"pm: {e}", file=sys.stderr) 43 + return 2 44 + return 0 45 + 46 + 47 + def _cmd_ls(_: argparse.Namespace) -> int: 48 + paths = config.load() 49 + rows = ls_mod.ls(paths) 50 + for row in rows: 51 + print(f"{row.project}\t{row.repo}\t{row.uuid}\t{row.status}") 52 + return 0 53 + 54 + 55 + def add_subparser(subparsers: argparse._SubParsersAction) -> None: 56 + project = subparsers.add_parser("project", help="manage projects") 57 + sub = project.add_subparsers(dest="cmd", required=True) 58 + 59 + new = sub.add_parser("new", help="claim a slot per repo for a new project") 60 + new.add_argument("name") 61 + new.add_argument("--repos", required=True, help="comma-separated repo names") 62 + new.set_defaults(func=_cmd_new) 63 + 64 + release = sub.add_parser("release", help="release ownership (keep forward symlinks)") 65 + release.add_argument("name") 66 + release.set_defaults(func=_cmd_release) 67 + 68 + delete = sub.add_parser("delete", help="release + remove forward symlinks + rmdir") 69 + delete.add_argument("name") 70 + delete.set_defaults(func=_cmd_delete) 71 + 72 + ls = sub.add_parser("ls", help="list projects") 73 + ls.set_defaults(func=_cmd_ls)
+51
src/project_manager/project/delete.py
··· 1 + import contextlib 2 + from pathlib import Path 3 + 4 + from project_manager.paths import Paths 5 + from project_manager.project.errors import ProjectError 6 + from project_manager.project.release import release 7 + 8 + 9 + def _is_pm_symlink(entry: Path, worktrees_root: Path) -> bool: 10 + if not entry.is_symlink(): 11 + return False 12 + target = entry.readlink() 13 + if not target.is_absolute(): 14 + return False 15 + try: 16 + target.relative_to(worktrees_root) 17 + except ValueError: 18 + return False 19 + return True 20 + 21 + 22 + def delete(paths: Paths, project: str) -> None: 23 + """Auto-release + remove forward symlinks + rmdir. 24 + 25 + Errors if the project dir contains anything other than pm-managed symlinks. 26 + """ 27 + project_dir = paths.project(project) 28 + if not project_dir.is_dir(): 29 + raise ProjectError(f"project '{project}' does not exist") 30 + 31 + extras = [ 32 + str(entry) 33 + for entry in project_dir.iterdir() 34 + if not _is_pm_symlink(entry, paths.worktrees) 35 + ] 36 + if extras: 37 + raise ProjectError( 38 + f"project '{project}' contains non-pm entries — remove them manually:\n " 39 + + "\n ".join(extras) 40 + ) 41 + 42 + release(paths, project) 43 + 44 + for entry in list(project_dir.iterdir()): 45 + with contextlib.suppress(FileNotFoundError): 46 + entry.unlink() 47 + 48 + try: 49 + project_dir.rmdir() 50 + except OSError as e: 51 + raise ProjectError(f"could not rmdir {project_dir}: {e}") from e
+2
src/project_manager/project/errors.py
··· 1 + class ProjectError(Exception): 2 + """A project-level operation failed."""
+47
src/project_manager/project/ls.py
··· 1 + from dataclasses import dataclass 2 + from pathlib import Path 3 + 4 + from project_manager.paths import Paths 5 + from project_manager.pool.slot import Slot 6 + 7 + 8 + @dataclass(frozen=True) 9 + class ProjectRow: 10 + project: str 11 + repo: str 12 + uuid: str 13 + status: str # "active" | "detached" | "stale" | "broken" 14 + 15 + 16 + def _status(entry: Path, target: Path, repo: str, uuid: str) -> str: 17 + if not target.is_dir(): 18 + return "broken" 19 + s = Slot(repo=repo, uuid=uuid, path=target) 20 + owner = s.owner_target() 21 + if owner is None: 22 + return "detached" 23 + if owner == entry: 24 + return "active" 25 + return "stale" 26 + 27 + 28 + def ls(paths: Paths) -> list[ProjectRow]: 29 + if not paths.projects.is_dir(): 30 + return [] 31 + rows: list[ProjectRow] = [] 32 + for project_dir in sorted(paths.projects.iterdir()): 33 + if not project_dir.is_dir(): 34 + continue 35 + for entry in sorted(project_dir.iterdir()): 36 + if not entry.is_symlink(): 37 + continue 38 + target = entry.readlink() 39 + rows.append( 40 + ProjectRow( 41 + project=project_dir.name, 42 + repo=entry.name, 43 + uuid=target.name, 44 + status=_status(entry, target, entry.name, target.name), 45 + ) 46 + ) 47 + return rows
+71
src/project_manager/project/new.py
··· 1 + import contextlib 2 + from pathlib import Path 3 + 4 + from project_manager.paths import Paths 5 + from project_manager.pool import slot as slot_mod 6 + from project_manager.pool.slot import PoolExhaustedError, Slot, SlotBusyError 7 + from project_manager.project.errors import ProjectError 8 + 9 + 10 + def _claim_any_free(paths: Paths, repo: str, forward: Path, retries: int = 3) -> Slot: 11 + for _ in range(retries): 12 + free = slot_mod.free_slots(paths, repo) 13 + if not free: 14 + raise PoolExhaustedError( 15 + f"no free slots for repo '{repo}' — run `pm pool add {repo}` or release a project" 16 + ) 17 + for s in free: 18 + try: 19 + slot_mod.claim(s, forward) 20 + except SlotBusyError: 21 + continue 22 + else: 23 + return s 24 + raise SlotBusyError(f"lost {retries} claim races for repo '{repo}'") 25 + 26 + 27 + def _claim_one_repo( 28 + paths: Paths, project: str, repo: str 29 + ) -> tuple[Slot, Path]: 30 + forward = paths.forward(project, repo) 31 + if forward.is_symlink() or forward.exists(): 32 + raise ProjectError(f"{forward} already exists") 33 + slot = _claim_any_free(paths, repo, forward) 34 + try: 35 + forward.symlink_to(slot.path) 36 + except BaseException: 37 + slot_mod.release(slot) 38 + raise 39 + return slot, forward 40 + 41 + 42 + def new(paths: Paths, project: str, repos: list[str]) -> list[tuple[str, Slot]]: 43 + """Claim a slot for each repo and create forward symlinks. 44 + 45 + Best-effort rollback on any failure. 46 + Returns [(repo, slot), ...] on success. 47 + """ 48 + if not repos: 49 + raise ProjectError("must specify at least one repo") 50 + 51 + project_dir = paths.project(project) 52 + created_project_dir = False 53 + if not project_dir.exists(): 54 + project_dir.mkdir(parents=True) 55 + created_project_dir = True 56 + 57 + claimed: list[tuple[str, Slot, Path]] = [] 58 + try: 59 + for repo in repos: 60 + slot, forward = _claim_one_repo(paths, project, repo) 61 + claimed.append((repo, slot, forward)) 62 + except BaseException: 63 + for _repo, s, f in reversed(claimed): 64 + with contextlib.suppress(FileNotFoundError): 65 + f.unlink() 66 + slot_mod.release(s) 67 + if created_project_dir: 68 + with contextlib.suppress(OSError): 69 + project_dir.rmdir() 70 + raise 71 + return [(r, s) for r, s, _ in claimed]
+29
src/project_manager/project/release.py
··· 1 + from project_manager.paths import Paths 2 + from project_manager.pool import slot as slot_mod 3 + from project_manager.pool.slot import Slot 4 + from project_manager.project.errors import ProjectError 5 + 6 + 7 + def release(paths: Paths, project: str) -> list[Slot]: 8 + """Drop .owner for each slot currently owned by this project. 9 + 10 + Forward symlinks are left in place. 11 + Returns the list of slots that were released. 12 + """ 13 + project_dir = paths.project(project) 14 + if not project_dir.is_dir(): 15 + raise ProjectError(f"project '{project}' does not exist") 16 + 17 + released: list[Slot] = [] 18 + for entry in sorted(project_dir.iterdir()): 19 + if not entry.is_symlink(): 20 + continue 21 + target = entry.readlink() 22 + if not target.is_dir(): 23 + continue 24 + s = Slot(repo=entry.name, uuid=target.name, path=target) 25 + owner = s.owner_target() 26 + if owner is not None and owner == entry: 27 + slot_mod.release(s) 28 + released.append(s) 29 + return released
tests/__init__.py

This is a binary file and will not be displayed.

+26
tests/conftest.py
··· 1 + from pathlib import Path 2 + 3 + import pytest 4 + 5 + from project_manager import config 6 + from project_manager.paths import Paths 7 + 8 + 9 + @pytest.fixture 10 + def pm_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Paths: 11 + repos = tmp_path / "repos" 12 + worktrees = tmp_path / "worktrees" 13 + projects = tmp_path / "projects" 14 + repos.mkdir() 15 + worktrees.mkdir() 16 + projects.mkdir() 17 + 18 + cfg = tmp_path / "pm.toml" 19 + cfg.write_text( 20 + "[paths]\n" 21 + f'repos = "{repos}"\n' 22 + f'worktrees = "{worktrees}"\n' 23 + f'projects = "{projects}"\n' 24 + ) 25 + monkeypatch.setenv("PM_CONFIG", str(cfg)) 26 + return config.load()
+92
tests/test_check.py
··· 1 + import shutil 2 + 3 + from project_manager import check 4 + from project_manager.paths import Paths 5 + from project_manager.pool import slot as slot_mod 6 + from project_manager.project import new as new_mod 7 + from project_manager.project import release as release_mod 8 + 9 + 10 + def _mk_pool(paths: Paths, repo: str, uuids: list[str]) -> None: 11 + (paths.worktrees / repo).mkdir() 12 + for u in uuids: 13 + (paths.worktrees / repo / u).mkdir() 14 + 15 + 16 + def test_healthy_shows_active(pm_env: Paths) -> None: 17 + _mk_pool(pm_env, "foo", ["a"]) 18 + new_mod.new(pm_env, "demo", ["foo"]) 19 + findings = check.check(pm_env) 20 + kinds = [f.kind for f in findings] 21 + assert check.Kind.ACTIVE in kinds 22 + assert check.Kind.ORPHAN not in kinds 23 + assert check.Kind.BROKEN not in kinds 24 + 25 + 26 + def test_orphan_detected_when_forward_rmd(pm_env: Paths) -> None: 27 + _mk_pool(pm_env, "foo", ["a"]) 28 + new_mod.new(pm_env, "demo", ["foo"]) 29 + (pm_env.projects / "demo" / "foo").unlink() 30 + findings = check.check(pm_env) 31 + orphans = [f for f in findings if f.kind == check.Kind.ORPHAN] 32 + assert len(orphans) == 1 33 + 34 + 35 + def test_fix_orphan(pm_env: Paths) -> None: 36 + _mk_pool(pm_env, "foo", ["a"]) 37 + new_mod.new(pm_env, "demo", ["foo"]) 38 + (pm_env.projects / "demo" / "foo").unlink() 39 + applied = check.fix(pm_env, check.check(pm_env)) 40 + assert applied == 1 41 + assert not (pm_env.worktrees / "foo" / "a" / ".owner").exists() 42 + assert check.check(pm_env) == [] 43 + 44 + 45 + def test_detached_after_release(pm_env: Paths) -> None: 46 + _mk_pool(pm_env, "foo", ["a"]) 47 + new_mod.new(pm_env, "demo", ["foo"]) 48 + release_mod.release(pm_env, "demo") 49 + findings = check.check(pm_env) 50 + assert any(f.kind == check.Kind.DETACHED for f in findings) 51 + # fix should not touch detached 52 + applied = check.fix(pm_env, findings) 53 + assert applied == 0 54 + assert (pm_env.projects / "demo" / "foo").is_symlink() 55 + 56 + 57 + def test_stale_detected(pm_env: Paths) -> None: 58 + _mk_pool(pm_env, "foo", ["a"]) 59 + new_mod.new(pm_env, "demo", ["foo"]) 60 + release_mod.release(pm_env, "demo") 61 + new_mod.new(pm_env, "other", ["foo"]) 62 + findings = check.check(pm_env) 63 + stale = [f for f in findings if f.kind == check.Kind.STALE] 64 + assert len(stale) == 1 65 + 66 + 67 + def test_broken_forward(pm_env: Paths) -> None: 68 + _mk_pool(pm_env, "foo", ["a"]) 69 + new_mod.new(pm_env, "demo", ["foo"]) 70 + # delete the slot entirely, leaving forward dangling 71 + shutil.rmtree(pm_env.worktrees / "foo" / "a") 72 + findings = check.check(pm_env) 73 + broken = [f for f in findings if f.kind == check.Kind.BROKEN] 74 + assert len(broken) == 1 75 + applied = check.fix(pm_env, findings) 76 + assert applied == 1 77 + assert not (pm_env.projects / "demo" / "foo").exists() 78 + 79 + 80 + def test_crash_mid_claim_orphan(pm_env: Paths) -> None: 81 + """Simulate a crash after .owner created but before forward symlink.""" 82 + _mk_pool(pm_env, "foo", ["a"]) 83 + (pm_env.projects / "demo").mkdir() 84 + slot = slot_mod.list_slots(pm_env, "foo")[0] 85 + slot_mod.claim(slot, pm_env.forward("demo", "foo")) 86 + # crash here — no forward symlink 87 + 88 + findings = check.check(pm_env) 89 + orphans = [f for f in findings if f.kind == check.Kind.ORPHAN] 90 + assert len(orphans) == 1 91 + check.fix(pm_env, findings) 92 + assert not slot.owner_path.exists()
+33
tests/test_config.py
··· 1 + from pathlib import Path 2 + 3 + import pytest 4 + 5 + from project_manager import config 6 + 7 + 8 + def test_defaults_when_no_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: 9 + monkeypatch.delenv("PM_CONFIG", raising=False) 10 + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) 11 + monkeypatch.setenv("HOME", str(tmp_path)) 12 + paths = config.load() 13 + assert paths.repos == tmp_path / ".repos" 14 + assert paths.worktrees == tmp_path / ".worktrees" 15 + assert paths.projects == tmp_path / ".projects" 16 + 17 + 18 + def test_pm_config_overrides(pm_env) -> None: 19 + assert pm_env.repos.is_dir() 20 + assert pm_env.worktrees.is_dir() 21 + assert pm_env.projects.is_dir() 22 + 23 + 24 + def test_xdg_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: 25 + monkeypatch.delenv("PM_CONFIG", raising=False) 26 + xdg = tmp_path / "xdg" 27 + (xdg / "pm").mkdir(parents=True) 28 + (xdg / "pm" / "config.toml").write_text( 29 + f'[paths]\nrepos = "{tmp_path}/r"\nworktrees = "{tmp_path}/w"\nprojects = "{tmp_path}/p"\n' 30 + ) 31 + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg)) 32 + paths = config.load() 33 + assert paths.repos == tmp_path / "r"
+98
tests/test_pool.py
··· 1 + import subprocess 2 + from pathlib import Path 3 + 4 + import pytest 5 + 6 + from project_manager.paths import Paths 7 + from project_manager.pool import add as add_mod 8 + from project_manager.pool import ls as ls_mod 9 + from project_manager.pool import worktree as wt_mod 10 + from project_manager.project import new as new_mod 11 + 12 + 13 + def _init_repo(path: Path, branch: str = "main") -> None: 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 + ) 18 + 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 + ) 22 + (path / "README.md").write_text("# test\n") 23 + subprocess.run(["git", "-C", str(path), "add", "README.md"], check=True) 24 + subprocess.run( 25 + ["git", "-C", str(path), "-c", "core.hooksPath=/dev/null", "commit", "-q", "-m", "init"], 26 + check=True, 27 + ) 28 + 29 + 30 + def test_default_branch_from_current(pm_env: Paths) -> None: 31 + repo = pm_env.repo("foo") 32 + repo.mkdir() 33 + _init_repo(repo, branch="main") 34 + assert wt_mod.default_branch(repo) == "main" 35 + 36 + 37 + def test_pool_add_creates_detached_worktree(pm_env: Paths) -> None: 38 + repo = pm_env.repo("foo") 39 + repo.mkdir() 40 + _init_repo(repo, branch="main") 41 + 42 + slot = add_mod.add(pm_env, "foo") 43 + assert slot.path.is_dir() 44 + assert (slot.path / ".git").exists() 45 + assert (slot.path / "README.md").is_file() 46 + 47 + # HEAD is detached 48 + result = subprocess.run( 49 + ["git", "-C", str(slot.path), "symbolic-ref", "-q", "HEAD"], 50 + capture_output=True, 51 + text=True, 52 + check=False, 53 + ) 54 + assert result.returncode != 0 # detached 55 + 56 + 57 + def test_pool_ls_free_and_claimed(pm_env: Paths) -> None: 58 + repo = pm_env.repo("foo") 59 + repo.mkdir() 60 + _init_repo(repo, branch="main") 61 + add_mod.add(pm_env, "foo") 62 + add_mod.add(pm_env, "foo") 63 + 64 + rows_before = ls_mod.ls(pm_env, "foo") 65 + assert len(rows_before) == 2 66 + assert all(r.status == "FREE" for r in rows_before) 67 + 68 + new_mod.new(pm_env, "demo", ["foo"]) 69 + rows_after = ls_mod.ls(pm_env, "foo") 70 + statuses = sorted(r.status for r in rows_after) 71 + assert statuses == ["FREE", "demo"] 72 + 73 + 74 + def test_pool_ls_all_repos(pm_env: Paths) -> None: 75 + for name in ["aa", "bb"]: 76 + repo = pm_env.repo(name) 77 + repo.mkdir() 78 + _init_repo(repo) 79 + add_mod.add(pm_env, name) 80 + 81 + rows = ls_mod.ls(pm_env, None) 82 + repos = sorted({r.repo for r in rows}) 83 + assert repos == ["aa", "bb"] 84 + 85 + 86 + def test_pool_add_copies_untracked_claude_md(pm_env: Paths) -> None: 87 + repo = pm_env.repo("foo") 88 + repo.mkdir() 89 + _init_repo(repo) 90 + (repo / "CLAUDE.md").write_text("# guidance\n") # untracked 91 + 92 + slot = add_mod.add(pm_env, "foo") 93 + assert (slot.path / "CLAUDE.md").read_text() == "# guidance\n" 94 + 95 + 96 + def test_pool_add_errors_on_missing_repo(pm_env: Paths) -> None: 97 + with pytest.raises(wt_mod.GitError): 98 + add_mod.add(pm_env, "nonexistent")
+128
tests/test_project.py
··· 1 + import pytest 2 + 3 + from project_manager.paths import Paths 4 + from project_manager.pool.slot import PoolExhaustedError 5 + from project_manager.project import delete as delete_mod 6 + from project_manager.project import ls as ls_mod 7 + from project_manager.project import new as new_mod 8 + from project_manager.project import release as release_mod 9 + from project_manager.project.errors import ProjectError 10 + 11 + 12 + def _mk_pool(paths: Paths, repo: str, uuids: list[str]) -> None: 13 + (paths.worktrees / repo).mkdir() 14 + for u in uuids: 15 + (paths.worktrees / repo / u).mkdir() 16 + 17 + 18 + def test_new_single_repo(pm_env: Paths) -> None: 19 + _mk_pool(pm_env, "foo", ["a", "b"]) 20 + claimed = new_mod.new(pm_env, "demo", ["foo"]) 21 + assert [repo for repo, _ in claimed] == ["foo"] 22 + assert (pm_env.projects / "demo" / "foo").is_symlink() 23 + assert (pm_env.worktrees / "foo" / "a" / ".owner").is_symlink() 24 + 25 + 26 + def test_new_multi_repo(pm_env: Paths) -> None: 27 + _mk_pool(pm_env, "foo", ["a"]) 28 + _mk_pool(pm_env, "bar", ["x"]) 29 + new_mod.new(pm_env, "demo", ["foo", "bar"]) 30 + assert (pm_env.projects / "demo" / "foo").is_symlink() 31 + assert (pm_env.projects / "demo" / "bar").is_symlink() 32 + 33 + 34 + def test_new_rolls_back_when_second_repo_exhausted(pm_env: Paths) -> None: 35 + _mk_pool(pm_env, "foo", ["a"]) 36 + _mk_pool(pm_env, "bar", []) # pool exists but no slots 37 + with pytest.raises(PoolExhaustedError): 38 + new_mod.new(pm_env, "demo", ["foo", "bar"]) 39 + # foo's slot must be free again 40 + assert not (pm_env.worktrees / "foo" / "a" / ".owner").exists() 41 + # forward symlinks gone 42 + assert not (pm_env.projects / "demo").exists() 43 + 44 + 45 + def test_new_fails_if_project_repo_link_exists(pm_env: Paths) -> None: 46 + _mk_pool(pm_env, "foo", ["a"]) 47 + new_mod.new(pm_env, "demo", ["foo"]) 48 + # second call to new for same project should fail (forward exists) 49 + _mk_pool(pm_env, "bar", ["x"]) 50 + with pytest.raises(ProjectError): 51 + new_mod.new(pm_env, "demo", ["foo"]) 52 + 53 + 54 + def test_release_keeps_forward_symlinks(pm_env: Paths) -> None: 55 + _mk_pool(pm_env, "foo", ["a"]) 56 + new_mod.new(pm_env, "demo", ["foo"]) 57 + release_mod.release(pm_env, "demo") 58 + assert (pm_env.projects / "demo" / "foo").is_symlink() # forward still here 59 + assert not (pm_env.worktrees / "foo" / "a" / ".owner").exists() # .owner gone 60 + 61 + 62 + def test_release_skips_stale_forward(pm_env: Paths) -> None: 63 + _mk_pool(pm_env, "foo", ["a"]) 64 + new_mod.new(pm_env, "demo", ["foo"]) 65 + release_mod.release(pm_env, "demo") 66 + # another project claims the same slot 67 + new_mod.new(pm_env, "other", ["foo"]) 68 + # demo's forward still points at the slot but .owner now points at other's forward 69 + # releasing demo a second time must NOT remove other's .owner 70 + release_mod.release(pm_env, "demo") 71 + assert (pm_env.worktrees / "foo" / "a" / ".owner").is_symlink() 72 + 73 + 74 + def test_delete_happy_path(pm_env: Paths) -> None: 75 + _mk_pool(pm_env, "foo", ["a"]) 76 + new_mod.new(pm_env, "demo", ["foo"]) 77 + delete_mod.delete(pm_env, "demo") 78 + assert not (pm_env.projects / "demo").exists() 79 + assert not (pm_env.worktrees / "foo" / "a" / ".owner").exists() 80 + 81 + 82 + def test_delete_refuses_extra_files(pm_env: Paths) -> None: 83 + _mk_pool(pm_env, "foo", ["a"]) 84 + new_mod.new(pm_env, "demo", ["foo"]) 85 + (pm_env.projects / "demo" / "notes.txt").write_text("hi") 86 + with pytest.raises(ProjectError, match="non-pm entries"): 87 + delete_mod.delete(pm_env, "demo") 88 + assert (pm_env.projects / "demo").is_dir() 89 + assert (pm_env.worktrees / "foo" / "a" / ".owner").is_symlink() 90 + 91 + 92 + def test_delete_refuses_non_pm_symlink(pm_env: Paths, tmp_path) -> None: 93 + _mk_pool(pm_env, "foo", ["a"]) 94 + new_mod.new(pm_env, "demo", ["foo"]) 95 + elsewhere = tmp_path / "elsewhere" 96 + elsewhere.mkdir() 97 + (pm_env.projects / "demo" / "stray").symlink_to(elsewhere) 98 + with pytest.raises(ProjectError): 99 + delete_mod.delete(pm_env, "demo") 100 + 101 + 102 + def test_ls_status_active(pm_env: Paths) -> None: 103 + _mk_pool(pm_env, "foo", ["a"]) 104 + new_mod.new(pm_env, "demo", ["foo"]) 105 + rows = ls_mod.ls(pm_env) 106 + assert len(rows) == 1 107 + assert rows[0].status == "active" 108 + assert rows[0].project == "demo" 109 + assert rows[0].repo == "foo" 110 + 111 + 112 + def test_ls_status_detached_after_release(pm_env: Paths) -> None: 113 + _mk_pool(pm_env, "foo", ["a"]) 114 + new_mod.new(pm_env, "demo", ["foo"]) 115 + release_mod.release(pm_env, "demo") 116 + rows = ls_mod.ls(pm_env) 117 + assert rows[0].status == "detached" 118 + 119 + 120 + def test_ls_status_stale(pm_env: Paths) -> None: 121 + _mk_pool(pm_env, "foo", ["a"]) 122 + new_mod.new(pm_env, "demo", ["foo"]) 123 + release_mod.release(pm_env, "demo") 124 + new_mod.new(pm_env, "other", ["foo"]) 125 + rows = ls_mod.ls(pm_env) 126 + rows_by_project = {r.project: r for r in rows} 127 + assert rows_by_project["demo"].status == "stale" 128 + assert rows_by_project["other"].status == "active"
+100
tests/test_slot.py
··· 1 + import os 2 + from pathlib import Path 3 + 4 + import pytest 5 + 6 + from project_manager.paths import Paths 7 + from project_manager.pool import slot as slot_mod 8 + from project_manager.pool.slot import Slot, SlotBusyError 9 + 10 + 11 + def _mk_pool(paths: Paths, repo: str, uuids: list[str]) -> list[Slot]: 12 + (paths.worktrees / repo).mkdir() 13 + for u in uuids: 14 + (paths.worktrees / repo / u).mkdir() 15 + return slot_mod.list_slots(paths, repo) 16 + 17 + 18 + def test_list_empty(pm_env: Paths) -> None: 19 + assert slot_mod.list_slots(pm_env, "foo") == [] 20 + 21 + 22 + def test_list_and_free(pm_env: Paths) -> None: 23 + slots = _mk_pool(pm_env, "foo", ["a", "b"]) 24 + assert [s.uuid for s in slots] == ["a", "b"] 25 + assert all(s.is_free() for s in slots) 26 + assert slot_mod.free_slots(pm_env, "foo") == slots 27 + 28 + 29 + def test_claim_release_roundtrip(pm_env: Paths) -> None: 30 + slots = _mk_pool(pm_env, "foo", ["a"]) 31 + s = slots[0] 32 + forward = pm_env.forward("demo", "foo") 33 + slot_mod.claim(s, forward) 34 + assert not s.is_free() 35 + assert s.owner_target() == forward 36 + slot_mod.release(s) 37 + assert s.is_free() 38 + 39 + 40 + def test_double_claim_raises(pm_env: Paths) -> None: 41 + slots = _mk_pool(pm_env, "foo", ["a"]) 42 + s = slots[0] 43 + slot_mod.claim(s, pm_env.forward("demo", "foo")) 44 + with pytest.raises(SlotBusyError): 45 + slot_mod.claim(s, pm_env.forward("other", "foo")) 46 + 47 + 48 + def test_release_idempotent(pm_env: Paths) -> None: 49 + slots = _mk_pool(pm_env, "foo", ["a"]) 50 + slot_mod.release(slots[0]) 51 + slot_mod.release(slots[0]) 52 + 53 + 54 + def test_concurrent_claim_exactly_one_wins(pm_env: Paths) -> None: 55 + slots = _mk_pool(pm_env, "foo", ["a"]) 56 + s = slots[0] 57 + 58 + n = 8 59 + pipes = [os.pipe() for _ in range(n)] 60 + pids: list[int] = [] 61 + for i in range(n): 62 + pid = os.fork() 63 + if pid == 0: 64 + for j, (r, w) in enumerate(pipes): 65 + if j != i: 66 + os.close(r) 67 + os.close(w) 68 + r, w = pipes[i] 69 + os.close(r) 70 + try: 71 + slot_mod.claim(s, pm_env.forward(f"p{i}", "foo")) 72 + os.write(w, b"W") 73 + except SlotBusyError: 74 + os.write(w, b"L") 75 + except Exception as e: # noqa: BLE001 — child reports any failure via pipe 76 + os.write(w, f"E{e}".encode()) 77 + os.close(w) 78 + os._exit(0) 79 + pids.append(pid) 80 + 81 + for pid in pids: 82 + os.waitpid(pid, 0) 83 + 84 + results = [] 85 + for r, w in pipes: 86 + os.close(w) 87 + results.append(os.read(r, 128)) 88 + os.close(r) 89 + 90 + wins = sum(1 for r in results if r == b"W") 91 + losses = sum(1 for r in results if r == b"L") 92 + errors = [r for r in results if r not in (b"W", b"L")] 93 + 94 + assert errors == [], f"unexpected errors: {errors}" 95 + assert wins == 1, f"expected 1 winner, got {wins} (results={results})" 96 + assert losses == n - 1 97 + 98 + assert s.owner_target() is not None 99 + winner_idx = results.index(b"W") 100 + assert s.owner_target() == Path(str(pm_env.forward(f"p{winner_idx}", "foo")))
+134
uv.lock
··· 1 + version = 1 2 + revision = 3 3 + requires-python = ">=3.11" 4 + 5 + [[package]] 6 + name = "colorama" 7 + version = "0.4.6" 8 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 9 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 10 + wheels = [ 11 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 12 + ] 13 + 14 + [[package]] 15 + name = "iniconfig" 16 + version = "2.3.0" 17 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 18 + sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } 19 + wheels = [ 20 + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, 21 + ] 22 + 23 + [[package]] 24 + name = "packaging" 25 + version = "26.1" 26 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 27 + sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } 28 + wheels = [ 29 + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, 30 + ] 31 + 32 + [[package]] 33 + name = "pluggy" 34 + version = "1.6.0" 35 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 36 + sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } 37 + wheels = [ 38 + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, 39 + ] 40 + 41 + [[package]] 42 + name = "project-manager" 43 + version = "0.1.0" 44 + source = { editable = "." } 45 + 46 + [package.dev-dependencies] 47 + dev = [ 48 + { name = "pytest" }, 49 + { name = "ruff" }, 50 + { name = "ty" }, 51 + ] 52 + 53 + [package.metadata] 54 + 55 + [package.metadata.requires-dev] 56 + dev = [ 57 + { name = "pytest", specifier = ">=8.0" }, 58 + { name = "ruff", specifier = ">=0.8" }, 59 + { name = "ty", specifier = ">=0.0.1a1" }, 60 + ] 61 + 62 + [[package]] 63 + name = "pygments" 64 + version = "2.20.0" 65 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 66 + sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } 67 + wheels = [ 68 + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, 69 + ] 70 + 71 + [[package]] 72 + name = "pytest" 73 + version = "9.0.3" 74 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 75 + dependencies = [ 76 + { name = "colorama", marker = "sys_platform == 'win32'" }, 77 + { name = "iniconfig" }, 78 + { name = "packaging" }, 79 + { name = "pluggy" }, 80 + { name = "pygments" }, 81 + ] 82 + sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } 83 + wheels = [ 84 + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, 85 + ] 86 + 87 + [[package]] 88 + name = "ruff" 89 + version = "0.15.10" 90 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 91 + sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" } 92 + wheels = [ 93 + { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" }, 94 + { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" }, 95 + { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" }, 96 + { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" }, 97 + { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" }, 98 + { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" }, 99 + { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" }, 100 + { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" }, 101 + { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" }, 102 + { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" }, 103 + { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" }, 104 + { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" }, 105 + { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" }, 106 + { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" }, 107 + { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" }, 108 + { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" }, 109 + { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, 110 + ] 111 + 112 + [[package]] 113 + name = "ty" 114 + version = "0.0.31" 115 + source = { registry = "https://pypi-proxy.dev.databricks.com/simple/" } 116 + sdist = { url = "https://files.pythonhosted.org/packages/31/cc/5ea5d3a72216c8c2bf77d83066dd4f3553532d0aacc03d4a8397dd9845e1/ty-0.0.31.tar.gz", hash = "sha256:4a4094292d9671caf3b510c7edf36991acd9c962bb5d97205374ffed9f541c45", size = 5516619, upload-time = "2026-04-15T15:47:59.87Z" } 117 + wheels = [ 118 + { url = "https://files.pythonhosted.org/packages/b0/10/ea805cbbd75d5d50792551a2b383de8521eeab0c44f38c73e12819ced65e/ty-0.0.31-py3-none-linux_armv6l.whl", hash = "sha256:761651dc17ad7bc0abfc1b04b3f0e84df263ed435d34f29760b3da739ab02d35", size = 10834749, upload-time = "2026-04-15T15:48:14.877Z" }, 119 + { url = "https://files.pythonhosted.org/packages/d9/4c/fabf951850401d24d36b21bced088a366c6827e1c37dab4523afff84c4b2/ty-0.0.31-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c529922395a07231c27488f0290651e05d27d149f7e0aa807678f1f7e9c58a5e", size = 10626012, upload-time = "2026-04-15T15:48:22.554Z" }, 120 + { url = "https://files.pythonhosted.org/packages/04/b0/4a5aff88d2544f19514a59c8f693d63144aa7307fe2ee5df608333ab5460/ty-0.0.31-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f345df2b87d747859e72c2cbc9be607ea1bbc8bc93dd32fa3d03ea091cb4fee", size = 10075790, upload-time = "2026-04-15T15:47:46.959Z" }, 121 + { url = "https://files.pythonhosted.org/packages/d5/73/9d4dcad12cd4e85274014f2c0510ef93f590b2a1e5148de3a9f276098dad/ty-0.0.31-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4b207eddcfbafd376132689d3435b14efcb531289cb59cd961c6a611133bd54", size = 10590286, upload-time = "2026-04-15T15:48:06.222Z" }, 122 + { url = "https://files.pythonhosted.org/packages/47/45/fe40adde18692359ded174ae7ddbfac056e876eb0f43b65be74fde7f6072/ty-0.0.31-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:663778b220f357067488ce68bfc52335ccbd161549776f70dcbde6bbde82f77a", size = 10623824, upload-time = "2026-04-15T15:48:12.965Z" }, 123 + { url = "https://files.pythonhosted.org/packages/2e/e8/0ffa2e09b548e6daa9ebc368d68b767dc2405ca4cbeadb7ede0e2cb21059/ty-0.0.31-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3506cfe87dfade0fb2960dd4fffd4fd8089003587b3445c0a1a295c9d83764fb", size = 11156864, upload-time = "2026-04-15T15:48:08.473Z" }, 124 + { url = "https://files.pythonhosted.org/packages/08/e9/fd44c2075115d569593ee9473d7e2a38b750fd7e783421c95eb528c15df5/ty-0.0.31-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b3f3d8492f08e81916026354c1d1599e9ddfa1241804141a74d5662fc710085", size = 11696401, upload-time = "2026-04-15T15:48:17.355Z" }, 125 + { url = "https://files.pythonhosted.org/packages/4e/50/35aad8eadf964d23e2a4faa5b38a206aa85c78833c8ce335dddd2c34ba63/ty-0.0.31-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a97de32ee6a619393a4c495e056a1c547de7877510f3152e61345c71d774d2d0", size = 11374903, upload-time = "2026-04-15T15:47:55.893Z" }, 126 + { url = "https://files.pythonhosted.org/packages/c8/37/01eccd25d23f5aaa7f7ff1a87b5b215469f6b202cf689a1812b71c1e7f6b/ty-0.0.31-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c906354ce441e342646582bc9b8f48a676f79f3d061e25de15ff870e015ca14e", size = 11206624, upload-time = "2026-04-15T15:47:51.778Z" }, 127 + { url = "https://files.pythonhosted.org/packages/f4/70/baad2914cb097453f127a221f8addb2b41926098059cd773c75e6a662fc4/ty-0.0.31-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:275bb7c82afcbf89fe2dbef1b2692f2bc98451f1ee2c8eb809ddd91317822388", size = 10575089, upload-time = "2026-04-15T15:47:49.448Z" }, 128 + { url = "https://files.pythonhosted.org/packages/83/12/bae3a7bba2e785eb72ce00f9da70eedcb8c5e8299efecbd16e6e436abd82/ty-0.0.31-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:405da247027c6efd1e264886b6ac4a86ab3a4f09200b02e33630efe85f119e53", size = 10642315, upload-time = "2026-04-15T15:48:19.661Z" }, 129 + { url = "https://files.pythonhosted.org/packages/93/9e/cad04d5d839bc60355cea98c7e09d724ea65f47184def0fae8b90dc54591/ty-0.0.31-py3-none-musllinux_1_2_i686.whl", hash = "sha256:54d9835608eed196853d6643f645c50ce83bcc7fe546cdb3e210c1bcf7c58c09", size = 10834473, upload-time = "2026-04-15T15:48:02.091Z" }, 130 + { url = "https://files.pythonhosted.org/packages/e3/ba/84112d280182d37690d3d2b4018b2667e42bc281585e607015635310016a/ty-0.0.31-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ee11be9b07e8c0c6b455ff075a0abe4f194de9476f57624db98eec9df618355", size = 11315785, upload-time = "2026-04-15T15:48:10.754Z" }, 131 + { url = "https://files.pythonhosted.org/packages/50/9f/ac42dc223d7e0950e97a1854567a8b3e7fe09ad7375adbf91bfb43290482/ty-0.0.31-py3-none-win32.whl", hash = "sha256:7286587aacf3eef0956062d6492b893b02f82b0f22c5e230008e13ff0d216a8b", size = 10187657, upload-time = "2026-04-15T15:48:04.264Z" }, 132 + { url = "https://files.pythonhosted.org/packages/75/3e/57ba7ea7ecb2f4751644ba91756e2be70e33ef5952c0c41a256a0e4c2437/ty-0.0.31-py3-none-win_amd64.whl", hash = "sha256:81134e25d2a2562ab372f24de8f9bd05034d27d30377a5d7540f259791c6234c", size = 11205258, upload-time = "2026-04-15T15:47:53.759Z" }, 133 + { url = "https://files.pythonhosted.org/packages/88/39/bca669095ccf0a400af941fdf741578d4c2d6719f1b7f10e6dbec10aa862/ty-0.0.31-py3-none-win_arm64.whl", hash = "sha256:e9cb15fad26545c6a608f40f227af3a5513cb376998ca6feddd47ca7d93ffafa", size = 10590392, upload-time = "2026-04-15T15:47:57.968Z" }, 134 + ]