Skip to content
Merged
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
28 changes: 28 additions & 0 deletions packages/arbor/GEMINI.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Arbor Development Context

Arbor is a specialized CLI tool for managing Git worktrees and tracking their associated GitHub Pull Request statuses.

## Core Concepts
- **Worktrees**: Arbor manages a dedicated directory (defaulting to a path set during `init`) where each branch/PR is checked out as a separate Git worktree.
- **Projects**: Local Git repositories that are imported into Arbor. Arbor tracks which worktree belongs to which project.
- **Metadata**: Arbor stores its own metadata about active worktrees in a `.arbor` subdirectory within the worktrees directory. This prevents pollution of the project's own Git state.
- **GitHub Integration**: Uses the `gh` CLI to fetch PR status (OPEN, MERGED, CLOSED).

## File Locations
- **Global Config**: `~/.arbor_config.json` stores the `worktrees_dir` and the map of imported `projects`.
- **Worktree Metadata**: `{worktrees_dir}/.arbor/{branch_name}.json` stores `WorktreeInfo` (JSON) including the repo name, branch, and cached PR details.

## Tech Stack
- **Python**: Core implementation in `arbor.py`.
- **Typer**: CLI interface.
- **Pydantic**: Configuration and metadata validation (v2).
- **Rich**: Terminal formatting and tables.
- **Git/GitHub CLIs**: Relies on external `git` and `gh` commands for operations.

## Key Workflows
- `arbor init <path>`: Sets the root for all managed worktrees.
- `arbor import <path>`: Registers a main repo as a project or an existing worktree as an Arbor-managed one.
- `arbor create <repo> <branch>`: Creates a new worktree in the worktrees root.
- `arbor status`: Refreshes PR status from `gh` and displays a table.
- `arbor cleanup`: Removes worktrees whose PRs have been merged.
- `arbor cd <name>`: Helper for shell integration to jump to a worktree directory.
158 changes: 121 additions & 37 deletions packages/arbor/arbor.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ def get_worktree_file(worktrees_dir: Path, name: str) -> Path:
file_path.parent.mkdir(parents=True, exist_ok=True)
return file_path

def get_context_dir(worktrees_dir: Path, repo_name: str, branch: str) -> Path:
# Use branch name directly, subdirectories will be created if branch has slashes
ctx_dir = get_arbor_dir(worktrees_dir) / "contexts" / repo_name / branch
ctx_dir.mkdir(parents=True, exist_ok=True)
return ctx_dir

def find_project_by_path(config: Config, path: Path) -> Optional[str]:
target = path.resolve()
for name, p in config.projects.items():
Expand Down Expand Up @@ -77,6 +83,87 @@ def get_git_info(path: Path):
except subprocess.CalledProcessError:
return None, None

def is_git_dirty(path: Path) -> bool:
res = subprocess.run(
["git", "-C", str(path), "status", "--porcelain"],
capture_output=True, text=True, check=True
)
return bool(res.stdout.strip())

def ensure_git_exclude(common_git_dir: Path):
exclude_file = common_git_dir / "info" / "exclude"
exclude_file.parent.mkdir(parents=True, exist_ok=True)

if not exclude_file.exists():
exclude_file.touch()

content = exclude_file.read_text()
if "GEMINI.md" not in content:
with exclude_file.open("a") as f:
if content and not content.endswith("\n"):
f.write("\n")
f.write("GEMINI.md\n")

def setup_gemini_context(config: Config, repo_name: str, branch: str, worktree_path: Path):
# 1. Provision Shadow Context
ctx_dir = get_context_dir(config.worktrees_dir, repo_name, branch)
shadow_file = ctx_dir / "GEMINI.md"
if not shadow_file.exists():
shadow_file.touch()

# 2. Inject via Symlink
target_link = worktree_path / "GEMINI.md"
if target_link.exists() or target_link.is_symlink():
target_link.unlink()
target_link.symlink_to(shadow_file)

# 3. "Invisible" Ignore
common_dir, _ = get_git_info(worktree_path)
if common_dir:
ensure_git_exclude(common_dir)

def _create_worktree(config: Config, repo_name: str, branch: str, repo_path: Path):
worktree_path = config.worktrees_dir / branch
if worktree_path.exists():
console.print(f"[red]Worktree directory {worktree_path} already exists.[/red]")
raise typer.Exit(1)

console.print(f"Creating worktree for [blue]{repo_name}[/blue] on branch [yellow]{branch}[/yellow]...")

try:
# Check if branch exists
result = subprocess.run(
["git", "-C", str(repo_path), "rev-parse", "--verify", branch],
capture_output=True,
text=True
)

cmd = ["git", "-C", str(repo_path), "worktree", "add", str(worktree_path)]
if result.returncode != 0:
console.print(f"Branch [yellow]{branch}[/yellow] does not exist. Creating it.")
cmd.append("-b")

cmd.append(branch)

subprocess.run(
cmd,
check=True,
capture_output=True,
text=True
)

# Setup Gemini Context
setup_gemini_context(config, repo_name, branch, worktree_path)

# Save metadata
info = WorktreeInfo(name=branch, repo_name=repo_name, branch=branch)
get_worktree_file(config.worktrees_dir, branch).write_text(info.model_dump_json(indent=2))

console.print(f"[green]Worktree created at {worktree_path}[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]Failed to create worktree: {e.stderr}[/red]")
raise typer.Exit(1)

@app.command()
def init(worktrees_dir: str):
"""Initialize arbor with a worktrees directory."""
Expand Down Expand Up @@ -111,10 +198,33 @@ def import_command(

if main_repo_path == toplevel:
# It's a main repository, import as project

if is_git_dirty(toplevel):
console.print("[red]Repo has uncommitted changes. Please commit or stash them first.[/red]")
raise typer.Exit(1)

repo_name = name or toplevel.name
config.projects[repo_name] = toplevel
save_config(config)
console.print(f"[green]Imported project [bold]{repo_name}[/bold] from {toplevel}[/green]")

# Check current branch and convert to worktree if applicable
res = subprocess.run(
["git", "-C", str(toplevel), "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, check=True
)
current_branch = res.stdout.strip()

if current_branch != "HEAD":
console.print(f"Converting current branch [yellow]{current_branch}[/yellow] into a worktree...")
subprocess.run(["git", "-C", str(toplevel), "checkout", "--detach"], check=True)
try:
_create_worktree(config, repo_name, current_branch, toplevel)
except Exception:
console.print(f"[red]Failed to create worktree for {current_branch}. Restoring checkout...[/red]")
subprocess.run(["git", "-C", str(toplevel), "checkout", current_branch], check=False)
raise

else:
# It's a worktree
# Verify it's inside the worktrees_dir
Expand All @@ -136,6 +246,9 @@ def import_command(
capture_output=True, text=True, check=True
).stdout.strip()

# Setup Gemini Context
setup_gemini_context(config, repo_name, branch, toplevel)

# We use the relative path from worktrees_dir as the worktree name
worktree_name = str(toplevel.relative_to(config.worktrees_dir))
info = WorktreeInfo(name=worktree_name, repo_name=repo_name, branch=branch)
Expand All @@ -161,43 +274,7 @@ def create(repo_name: str, branch: str):
console.print(f"[red]Repo path {repo_path} for {repo_name} no longer exists.[/red]")
raise typer.Exit(1)

worktree_path = config.worktrees_dir / branch
if worktree_path.exists():
console.print(f"[red]Worktree directory {worktree_path} already exists.[/red]")
raise typer.Exit(1)

console.print(f"Creating worktree for [blue]{repo_name}[/blue] on branch [yellow]{branch}[/yellow]...")

try:
# Check if branch exists
result = subprocess.run(
["git", "-C", str(repo_path), "rev-parse", "--verify", branch],
capture_output=True,
text=True
)

cmd = ["git", "-C", str(repo_path), "worktree", "add", str(worktree_path)]
if result.returncode != 0:
console.print(f"Branch [yellow]{branch}[/yellow] does not exist. Creating it.")
cmd.append("-b")

cmd.append(branch)

subprocess.run(
cmd,
check=True,
capture_output=True,
text=True
)

# Save metadata
info = WorktreeInfo(name=branch, repo_name=repo_name, branch=branch)
get_worktree_file(config.worktrees_dir, branch).write_text(info.model_dump_json(indent=2))

console.print(f"[green]Worktree created at {worktree_path}[/green]")
except subprocess.CalledProcessError as e:
console.print(f"[red]Failed to create worktree: {e.stderr}[/red]")
raise typer.Exit(1)
_create_worktree(config, repo_name, branch, repo_path)

def get_gh_pr_status(repo_path: Path, branch: str) -> tuple[Optional[int], Optional[str]]:
"""Get PR number and status using 'gh' CLI."""
Expand Down Expand Up @@ -349,6 +426,13 @@ def cleanup():
capture_output=True,
text=True
)

# Cleanup Shadow Context
ctx_dir = get_context_dir(config.worktrees_dir, info.repo_name, info.branch)
if ctx_dir.exists():
import shutil
shutil.rmtree(ctx_dir)

# Remove metadata file
f.unlink()
cleaned += 1
Expand Down
77 changes: 77 additions & 0 deletions packages/arbor/tests/test_arbor.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,80 @@ 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_import_dirty_repo_fails(temp_arbor_env):
runner.invoke(app, ["init", str(temp_arbor_env["worktrees_dir"])])

repo_path = temp_arbor_env["tmp_path"] / "dirty-repo"
repo_path.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True)
(repo_path / "file.txt").write_text("v1")
subprocess.run(["git", "add", "."], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo_path, check=True)

# Make dirty
(repo_path / "file.txt").write_text("v2")

result = runner.invoke(app, ["import", str(repo_path)])
assert result.exit_code == 1
assert "Repo has uncommitted changes" in result.stdout

def test_import_converts_branch_to_worktree(temp_arbor_env):
worktrees_dir = temp_arbor_env["worktrees_dir"]
runner.invoke(app, ["init", str(worktrees_dir)])

repo_path = temp_arbor_env["tmp_path"] / "branch-repo"
repo_path.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True)
(repo_path / "file.txt").write_text("v1")
subprocess.run(["git", "add", "."], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo_path, check=True)

# Create feature branch
subprocess.run(["git", "checkout", "-b", "feature-x"], cwd=repo_path, check=True)

result = runner.invoke(app, ["import", str(repo_path), "--name", "my-proj"])
assert result.exit_code == 0
assert "Converting current branch feature-x into a worktree" in result.stdout
assert "Imported project my-proj" in result.stdout

# Verify worktree created
wt_path = worktrees_dir / "feature-x"
assert wt_path.exists()
assert (wt_path / "file.txt").exists()

# Verify main repo is detached
res = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path, capture_output=True, text=True)
assert res.stdout.strip() == "HEAD"

def test_gemini_sidecar_context(temp_arbor_env):
worktrees_dir = temp_arbor_env["worktrees_dir"]
runner.invoke(app, ["init", str(worktrees_dir)])

repo_path = temp_arbor_env["tmp_path"] / "gemini-repo"
repo_path.mkdir()
subprocess.run(["git", "init", "-b", "main"], cwd=repo_path, check=True)
(repo_path / "file.txt").write_text("v1")
subprocess.run(["git", "add", "."], cwd=repo_path, check=True)
subprocess.run(["git", "commit", "-m", "init"], cwd=repo_path, check=True)

runner.invoke(app, ["import", str(repo_path), "--name", "my-gemini-proj"])
runner.invoke(app, ["create", "my-gemini-proj", "feature-gemini"])

wt_path = worktrees_dir / "feature-gemini"
assert wt_path.exists()

# Check symlink
gemini_link = wt_path / "GEMINI.md"
assert gemini_link.is_symlink()

# Check shadow file
shadow_path = worktrees_dir / ".arbor" / "contexts" / "my-gemini-proj" / "feature-gemini" / "GEMINI.md"
assert shadow_path.exists()
assert gemini_link.resolve() == shadow_path.resolve()

# Check git exclude
res = subprocess.run(["git", "-C", str(repo_path), "rev-parse", "--git-common-dir"], capture_output=True, text=True)
common_dir = (repo_path / res.stdout.strip()).resolve()
exclude_file = common_dir / "info" / "exclude"
assert "GEMINI.md" in exclude_file.read_text()
2 changes: 1 addition & 1 deletion packages/mise/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# core tools
go = "latest"
node = "latest"
python = "latest"
python = "3.14"
rust = "latest"
java = "openjdk-21"

Expand Down
Loading