Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/arbor
Original file line number Diff line number Diff line change
Expand Up @@ -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" "$@"
6 changes: 3 additions & 3 deletions packages/arbor/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`
Expand Down
122 changes: 111 additions & 11 deletions packages/arbor/arbor.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,34 +201,40 @@ 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()
if not config:
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
Expand All @@ -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
Expand All @@ -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"))

Expand All @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions packages/arbor/tests/test_arbor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 42 additions & 0 deletions packages/bash/arbor.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Arbor shell integration for Bash
# This allows 'arbor cd <name>' 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
}
25 changes: 21 additions & 4 deletions packages/zsh/arbor.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading