From ae1459ed650f469127f61d16bdbe2ad8343976ab Mon Sep 17 00:00:00 2001 From: Alex Stephen Date: Tue, 27 Jan 2026 11:47:01 -0800 Subject: [PATCH] More arbor changes --- bin/arbor | 2 +- packages/arbor/AGENTS.md | 6 +- packages/arbor/arbor.py | 122 ++++++++++++++++++++++++++--- packages/arbor/tests/test_arbor.py | 18 +++++ packages/bash/arbor.bash | 42 ++++++++++ packages/zsh/arbor.zsh | 25 +++++- 6 files changed, 196 insertions(+), 19 deletions(-) create mode 100644 packages/bash/arbor.bash diff --git a/bin/arbor b/bin/arbor index bbf4afe..f6b175e 100755 --- a/bin/arbor +++ b/bin/arbor @@ -13,4 +13,4 @@ if ! command -v uv &> /dev/null; then fi # Run arbor -uv run --project "$ARBOR_DIR" "$ARBOR_DIR/arbor.py" "$@" +uv run --quiet --project "$ARBOR_DIR" "$ARBOR_DIR/arbor.py" "$@" diff --git a/packages/arbor/AGENTS.md b/packages/arbor/AGENTS.md index ade22e5..04df14d 100644 --- a/packages/arbor/AGENTS.md +++ b/packages/arbor/AGENTS.md @@ -12,7 +12,7 @@ Arbor is a specialized CLI tool for managing Git worktrees and tracking their as ## Capabilities - **Worktree Management**: Create and delete Git worktrees with ease. -- **Status Tracking**: Unified view of PR status across multiple projects and worktrees. +- **List & Status Tracking**: Unified view of PR status across multiple projects and worktrees. - **Cleanup**: Bulk removal of stale worktrees. ## Commands @@ -35,10 +35,10 @@ Creates a new worktree for a registered project on a specific branch. If the bra arbor create my-project feature/cool-thing ``` -### `status` +### `list` Displays a table of all active worktrees, their associated project, branch, PR number, and current GitHub status. ```bash -arbor status +arbor list ``` ### `cleanup` diff --git a/packages/arbor/arbor.py b/packages/arbor/arbor.py index 4c7e19c..c77cb2d 100644 --- a/packages/arbor/arbor.py +++ b/packages/arbor/arbor.py @@ -201,23 +201,26 @@ def create(repo_name: str, branch: str): def get_gh_pr_status(repo_path: Path, branch: str) -> tuple[Optional[int], Optional[str]]: """Get PR number and status using 'gh' CLI.""" + if not branch or branch == "HEAD": + return None, None + try: - # Check if there is a PR for this branch + # Search for PRs matching this head branch result = subprocess.run( - ["gh", "pr", "view", branch, "--json", "number,state"], + ["gh", "pr", "list", "--head", branch, "--state", "all", "--limit", "1", "--json", "number,state"], cwd=repo_path, capture_output=True, text=True ) if result.returncode == 0: data = json.loads(result.stdout) - return data["number"], data["state"] + if data and len(data) > 0: + return data[0]["number"], data[0]["state"] except Exception: pass return None, None @app.command("cd") -@app.command("c", hidden=True) def cd_command(name: str): """Print the path to a worktree for shell integration.""" config = get_config() @@ -225,10 +228,13 @@ def cd_command(name: str): print("Arbor not initialized. Run 'arbor init' first.", file=sys.stderr) raise typer.Exit(1) + # Sync first to ensure we have the latest metadata + sync(quiet=True) + # Try direct path first worktree_path = config.worktrees_dir / name if worktree_path.exists() and (worktree_path / ".git").exists(): - print(worktree_path) + print(str(worktree_path)) return # Try searching metadata @@ -237,7 +243,7 @@ def cd_command(name: str): meta_file = arbor_dir / f"{name}.json" if meta_file.exists(): info = WorktreeInfo.model_validate_json(meta_file.read_text()) - print(config.worktrees_dir / info.name) + print(str(config.worktrees_dir / info.name)) return # Recursive search @@ -247,25 +253,119 @@ def cd_command(name: str): # or the full relative path name in metadata if f.stem == name: info = WorktreeInfo.model_validate_json(f.read_text()) - print(config.worktrees_dir / info.name) + print(str(config.worktrees_dir / info.name)) return info = WorktreeInfo.model_validate_json(f.read_text()) if info.name == name: - print(config.worktrees_dir / info.name) + print(str(config.worktrees_dir / info.name)) return print(f"Worktree '{name}' not found.", file=sys.stderr) raise typer.Exit(1) +@app.command("c", hidden=True) +def c_alias(name: str): + """Alias for cd.""" + cd_command(name) + @app.command() -def status(): - """Show the status of all worktrees and their PRs.""" +def sync(quiet: bool = typer.Option(False, "--quiet", "-q")): + """Sync worktrees from git into arbor metadata and remove stale metadata.""" + config = get_config() + if not config: + if not quiet: + console.print("[red]Arbor not initialized. Run 'arbor init' first.[/red]") + return + + synced = 0 + tracked_names = set() + + for repo_name, repo_path in config.projects.items(): + if not repo_path.exists(): + continue + + try: + result = subprocess.run( + ["git", "-C", str(repo_path), "worktree", "list", "--porcelain"], + capture_output=True, text=True, check=True + ) + + current_worktree = None + for line in result.stdout.splitlines(): + if line.startswith("worktree "): + current_worktree = Path(line[9:]).resolve() + elif (line.startswith("branch ") or line == "detached") and current_worktree: + branch = "HEAD" + if line.startswith("branch "): + branch = line[7:] + if branch.startswith("refs/heads/"): + branch = branch[11:] + + try: + rel_path = current_worktree.relative_to(config.worktrees_dir) + worktree_name = str(rel_path) + tracked_names.add(worktree_name) + + meta_file = get_worktree_file(config.worktrees_dir, worktree_name) + if not meta_file.exists(): + info = WorktreeInfo(name=worktree_name, repo_name=repo_name, branch=branch) + meta_file.write_text(info.model_dump_json(indent=2)) + synced += 1 + except ValueError: + pass + except Exception as e: + if not quiet: + console.print(f"[yellow]Failed to sync worktrees for {repo_name}: {e}[/yellow]") + + # Remove stale metadata + arbor_dir = get_arbor_dir(config.worktrees_dir) + removed = 0 + for f in arbor_dir.glob("**/*.json"): + # Relativize to arbor_dir and remove .json extension + meta_name = str(f.relative_to(arbor_dir).with_suffix("")) + if meta_name not in tracked_names: + if not (config.worktrees_dir / meta_name).exists(): + f.unlink() + removed += 1 + + if not quiet: + if synced > 0 or removed > 0: + console.print(f"[green]Synced: added {synced}, removed {removed} stale metadata files.[/green]") + else: + console.print("Already in sync.") + +@app.command("list") +def list_command(): + """List all worktree names.""" + config = get_config() + if not config: + console.print("[red]Arbor not initialized. Run 'arbor init' first.[/red]") + raise typer.Exit(1) + + sync(quiet=True) + arbor_dir = get_arbor_dir(config.worktrees_dir) + json_files = list(arbor_dir.glob("**/*.json")) + + if not json_files: + console.print("No worktrees found.") + return + + for f in sorted(json_files): + info = WorktreeInfo.model_validate_json(f.read_text()) + console.print(info.name) + +@app.command("status") +def status_command(): + """Show the detailed status of all worktrees and their PRs.""" config = get_config() if not config: console.print("[red]Arbor not initialized. Run 'arbor init' first.[/red]") raise typer.Exit(1) + # Auto-sync first + sync(quiet=True) + arbor_dir = get_arbor_dir(config.worktrees_dir) json_files = list(arbor_dir.glob("**/*.json")) @@ -280,7 +380,7 @@ def status(): table.add_column("PR", style="blue") table.add_column("Status", style="yellow") - for f in json_files: + for f in sorted(json_files, key=lambda x: x.name): info = WorktreeInfo.model_validate_json(f.read_text()) repo_path = config.projects.get(info.repo_name) diff --git a/packages/arbor/tests/test_arbor.py b/packages/arbor/tests/test_arbor.py index 18d250e..d14df48 100644 --- a/packages/arbor/tests/test_arbor.py +++ b/packages/arbor/tests/test_arbor.py @@ -126,3 +126,21 @@ def test_import_worktree(temp_arbor_env): result = runner.invoke(app, ["cd", "my-worktree"]) assert result.exit_code == 0 assert result.stdout.strip() == str(wt_path.resolve()) + +def test_list_command(temp_arbor_env): + worktrees_dir = temp_arbor_env["worktrees_dir"] + runner.invoke(app, ["init", str(worktrees_dir)]) + + # Run list (should be empty but succeed) + result = runner.invoke(app, ["list"]) + assert result.exit_code == 0 + assert "No worktrees found" in result.stdout + +def test_status_alias(temp_arbor_env): + worktrees_dir = temp_arbor_env["worktrees_dir"] + runner.invoke(app, ["init", str(worktrees_dir)]) + + # Run status alias + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0 + assert "No worktrees found" in result.stdout diff --git a/packages/bash/arbor.bash b/packages/bash/arbor.bash new file mode 100644 index 0000000..74490bc --- /dev/null +++ b/packages/bash/arbor.bash @@ -0,0 +1,42 @@ +# Arbor shell integration for Bash +# This allows 'arbor cd ' to actually change your directory. + +arbor() { + if [[ "$1" == "cd" || "$1" == "c" ]]; then + if [[ -n "$2" ]]; then + # Try to get the path from arbor + local target + # We use 'command arbor' to bypass this function and call the actual binary + # Use the absolute path if DOTFILES is set to be extra safe + if [[ -n "$DOTFILES" ]]; then + target=$("$DOTFILES/bin/arbor" cd "$2" 2>/dev/null) + else + target=$(command arbor cd "$2" 2>/dev/null) + fi + + if [[ -n "$target" ]]; then + cd "$target" + return $? + else + # If arbor cd fails, fall back to normal execution to show error + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" + else + command arbor "$@" + fi + fi + else + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" + else + command arbor "$@" + fi + fi + else + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" + else + command arbor "$@" + fi + fi +} diff --git a/packages/zsh/arbor.zsh b/packages/zsh/arbor.zsh index 31a3225..aa9eb72 100644 --- a/packages/zsh/arbor.zsh +++ b/packages/zsh/arbor.zsh @@ -6,19 +6,36 @@ function arbor() { if [[ -n "$2" ]]; then # Try to get the path from arbor local target - # We use 'command arbor' to bypass this function and call the actual binary - target=$(command arbor cd "$2" 2>/dev/null) + # Use the absolute path if DOTFILES is set to be extra safe + if [[ -n "$DOTFILES" ]]; then + target=$("$DOTFILES/bin/arbor" cd "$2" 2>/dev/null) + else + target=$(command arbor cd "$2" 2>/dev/null) + fi + if [[ -n "$target" ]]; then cd "$target" return $? else # If arbor cd fails, fall back to normal execution to show error + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" + else + command arbor "$@" + fi + fi + else + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" + else command arbor "$@" fi + fi + else + if [[ -n "$DOTFILES" ]]; then + "$DOTFILES/bin/arbor" "$@" else command arbor "$@" fi - else - command arbor "$@" fi }