---
last_audited: "2026-02-16 00:00 PT"
audit_result: clean
title: Erk Architecture Patterns
read_when:
  - "understanding erk architecture"
  - "implementing dry-run patterns"
  - "regenerating context after os.chdir"
  - "detecting root worktree"
  - "detecting worktree location"
  - "adding composing template methods to ABC"
tripwires:
  - action: "passing dry_run boolean flags through business logic function parameters"
    warning: "Use dependency injection with DryRunGit/DryRunGitHub wrappers for multi-step workflows. Simple CLI preview flags at the command level are acceptable for single-action commands."
  - action: "calling os.chdir() in erk code"
    warning: "After os.chdir(), regenerate context using regenerate_context(ctx). Stale ctx.cwd causes FileNotFoundError."
    pattern: "os\\.chdir\\("
  - action: "importing time module or calling time.sleep() or datetime.now()"
    warning: "Use context.time.sleep() and context.time.now() for testability. Direct time.sleep() makes tests slow and datetime.now() makes tests non-deterministic."
    pattern: "\\bimport time\\b|time\\.sleep\\(|datetime\\.now\\("
  - action: "implementing CLI flags that affect post-mutation behavior"
    warning: "Validate flag preconditions BEFORE any mutations. Example: `--up` in `erk land` checks for child branches before merging PR. This prevents partial state (PR merged, worktree deleted, but no valid navigation target)."
  - action: "comparing worktree path to repo_root to detect root worktree"
    warning: "Use WorktreeInfo.is_root instead of path comparison. Path comparison fails when running from within a non-root worktree because ctx.cwd resolves differently."
  - action: "detecting current worktree using path comparisons on cwd"
    warning: "Use git.get_repository_root(cwd) to get the worktree root, then match exactly against known paths. Path comparisons with .exists()/.resolve()/is_relative_to() are fragile."
  - action: "checking isinstance(ctx.graphite, GraphiteDisabled) inline in command code"
    warning: "Use BranchManager abstraction instead. Add a method to BranchManager ABC that handles both Graphite and Git paths. This centralizes the branching logic and enables testing with FakeBranchManager."
    pattern: "isinstance\\(.*GraphiteDisabled\\)"
  - action: 'using os.environ.get("CLAUDE_CODE_SESSION_ID") in erk code'
    warning: "Erk code NEVER has access to this environment variable. Session IDs must be passed via --session-id CLI flags. Hooks receive session ID via stdin JSON, not environment variables."
    pattern: "os\\.environ.*CLAUDE_CODE_SESSION_ID"
  - action: "injecting Time dependency into gateway real.py for lock-waiting or retry logic"
    warning: "Accept optional Time in __init__ with default to RealTime(). Use injected dependency in methods. This enables testing with FakeTime without blocking. See packages/erk-shared/src/erk_shared/gateway/git/lock.py for pattern."
  - action: "adding file I/O, network calls, or subprocess invocations to a class __init__"
    warning: "Load `dignified-python` skill first. Class __init__ should be lightweight (just data assignment). Heavy operations belong in static factory methods like `from_config_path()` or `load()`. This enables direct instantiation in tests without I/O setup."
  - action: "calling ctx.git mutation methods (create_branch, delete_branch, checkout_branch, checkout_detached, create_tracking_branch)"
    warning: "Use ctx.branch_manager instead. Branch mutation methods are in GitBranchOps sub-gateway, accessible only through BranchManager. Query methods (get_current_branch, list_local_branches, etc.) remain on ctx.git."
  - action: "calling ctx.graphite mutation methods (track_branch, delete_branch, submit_branch)"
    warning: "Use ctx.branch_manager instead. Branch mutation methods are in GraphiteBranchOps sub-gateway, accessible only through BranchManager. Query methods (is_branch_tracked, get_parent_branch, etc.) remain on ctx.graphite."
  - action: "calling GraphiteBranchManager.create_branch() without explicit checkout"
    warning: "GraphiteBranchManager.create_branch() restores the original branch after tracking. Always call branch_manager.checkout_branch() afterward if you need to be on the new branch."
  - action: "validating object fields at every callsite instead of at construction"
    warning: "Validate at the single construction point (factory/reader function). Callers should trust returned objects without re-validation. This is the construction boundary principle."
  - action: "adding new file format support without read-then-fallback"
    warning: "When adding new file formats, implement read-then-fallback: try new format first, fall back to old format transparently. See read_plan_ref() for the canonical pattern."
  - action: "adding a new field to ErkContext dataclass"
    warning: "Update ALL factory functions. Grep: `grep -r 'ErkContext(' packages/erk-shared/src/ src/erk/core/context.py` to find all construction sites. Missing a factory causes runtime errors or silent None values."
    score: 6
  - action: "adding LBYL guards that only check a subset of required fields"
    warning: "Functions with 'skip silently' contracts must validate ALL preconditions with LBYL guards. Checking one field (e.g., schema_version) is insufficient — use set operations to verify all required fields."
  - action: "calling .read_text() or .write_text() without encoding parameter"
    warning: "Always pass encoding='utf-8' to .read_text() and .write_text(). Python's default encoding varies by platform."
---

# Erk Architecture Patterns

This document describes the core architectural patterns specific to the erk codebase.

## Dry-Run Patterns

This codebase has two distinct dry-run patterns, each appropriate in different contexts.

### Pattern 1: Dependency Injection (for complex workflows)

**Use DryRun wrappers** when the command executes multiple operations through gateways:

**MUST**: Use DryRun wrappers for multi-step workflows
**MUST NOT**: Pass dry_run flags through business logic functions
**SHOULD**: Inject DryRunGit/DryRunGitHub at the context creation level

```python
# WRONG: Passing dry_run flag through business logic
def execute_plan(plan, git, dry_run=False):
    if not dry_run:
        git.add_worktree(...)

# CORRECT: Rely on injected integration implementation
def execute_plan(plan, git):
    # Always execute - behavior depends on git implementation
    git.add_worktree(...)  # DryRunGit does nothing, RealGit executes

# At the context creation level:
if dry_run:
    git = DryRunGit(real_git)  # or PrintingGit(DryRunGit(...))
else:
    git = real_git  # or PrintingGit(real_git)
```

### Pattern 2: CLI Preview Flag (for simple commands)

**Use a simple `--dry-run` CLI flag** when the command has a single, clear action point:

```python
# ACCEPTABLE: Simple CLI preview for single-action commands
@click.command()
@click.option("--dry-run", is_flag=True, help="Show what would be done")
def slot_repair(ctx: ErkContext, dry_run: bool) -> None:
    # ... compute what needs to be repaired ...

    if dry_run:
        user_output("[DRY RUN] Would remove 3 stale assignments")
    else:
        ctx.repo_state_store.save_pool_state(path, new_state)
        user_output("Removed 3 stale assignments")
```

**When to use CLI preview flag:**

- Command has a single mutating operation (e.g., save to file, delete record)
- The "dry-run" behavior is just "don't execute the final step"
- No complex multi-step workflows with rollback concerns

**When to use dependency injection:**

- Command performs multiple operations that should all be dry-run
- Operations span multiple gateways (git + github + filesystem)
- You need consistent dry-run behavior across the entire operation

### Rationale

- **DI pattern**: Keeps business logic pure, enables testing, handles complex workflows
- **CLI flag**: Simpler for straightforward "preview before commit" UX
- Both are valid - choose based on complexity of the command

## Context Regeneration

**When to regenerate context:**

After filesystem mutations that invalidate `ctx.cwd`:

- After `os.chdir()` calls
- After worktree removal (if removed current directory)
- After switching repositories

### How to Regenerate

Use `regenerate_context()` from `erk.core.context`:

```python
from erk.core.context import regenerate_context

# After os.chdir()
os.chdir(new_directory)
ctx = regenerate_context(ctx)

# After worktree removal
if removed_current_worktree:
    os.chdir(safe_directory)
    ctx = regenerate_context(ctx)
```

### Why Regenerate

- `ctx.cwd` is captured once at CLI entry point
- After `os.chdir()`, `ctx.cwd` becomes stale
- Stale `ctx.cwd` causes `FileNotFoundError` in operations that use it
- Regeneration creates NEW context with fresh `cwd` and `trunk_branch`

## Subprocess Execution Wrappers

Erk uses a two-layer pattern for subprocess execution to provide consistent error handling:

- **Integration layer**: `run_subprocess_with_context()` - Raises RuntimeError for business logic
- **CLI layer**: `run_with_error_reporting()` - Prints user-friendly message and raises SystemExit

**Full guide**: See [subprocess-wrappers.md](subprocess-wrappers.md) for complete documentation and examples.

## Time Abstraction for Testing

**NEVER import `time` module or `datetime.now()` directly. ALWAYS use `context.time` abstraction.**

**MUST**: Use `context.time.sleep()` instead of `time.sleep()`
**MUST**: Use `context.time.now()` instead of `datetime.now()`
**MUST**: Inject Time dependency through ErkContext
**SHOULD**: Use FakeTime in tests to avoid actual sleeping and for deterministic timestamps

### Wrong Patterns

```python
# WRONG: Direct time.sleep() import
import time

def retry_operation(attempt: int) -> None:
    delay = 2.0 ** attempt
    time.sleep(delay)  # Tests will actually sleep!
```

```python
# WRONG: Direct datetime.now() import
from datetime import UTC, datetime

def track_event(issue_number: int) -> None:
    timestamp = datetime.now(UTC).isoformat()  # Non-deterministic in tests!
    # ... use timestamp
```

### Correct Patterns

```python
# CORRECT: Use context.time.sleep()
def retry_operation(context: ErkContext, attempt: int) -> None:
    delay = 2.0 ** attempt
    context.time.sleep(delay)  # Fast in tests with FakeTime

# At CLI entry point, RealTime is injected
# In tests, FakeTime is injected
```

```python
# CORRECT: Use context.time.now()
from datetime import UTC

def track_event(context: ErkContext, issue_number: int) -> None:
    timestamp = context.time.now().replace(tzinfo=UTC).isoformat()  # Deterministic in tests!
    # ... use timestamp
```

### Implementations

<!-- Source: packages/erk-shared/src/erk_shared/gateway/time/real.py -->

**Production (RealTime)**: See `RealTime` class in `packages/erk-shared/src/erk_shared/gateway/time/real.py`

Usage example:

```python
from erk_shared.gateway.time.real import RealTime

time = RealTime()
time.sleep(2.0)  # Actually sleeps for 2 seconds
```

<!-- Source: packages/erk-shared/src/erk_shared/gateway/time/fake.py -->

**Testing (FakeTime)**: See `FakeTime` class in `packages/erk-shared/src/erk_shared/gateway/time/fake.py`

Usage example:

```python
from erk_shared.gateway.time.fake import FakeTime

fake_time = FakeTime()
fake_time.sleep(2.0)  # Returns immediately, tracks call

# Assert sleep was called with expected duration
assert fake_time.sleep_calls == [2.0]
```

### Real-World Examples

**Timestamp injection via `now_iso` parameter**: Functions that need timestamps accept `now_iso: str` instead of calling `datetime.now()` directly. This makes tests deterministic:

```python
# Production: caller provides current time
create_impl_context(..., now_iso=datetime.now(UTC).isoformat())

# Tests: caller provides fixed time
FAKE_NOW_ISO = "2026-01-15T10:00:00+00:00"
create_impl_context(..., now_iso=FAKE_NOW_ISO)
```

See `packages/erk-shared/src/erk_shared/impl_context.py` for the canonical `now_iso` pattern.

**Retry with exponential backoff**: Use `context.time.sleep(delay)` in retry loops for instant test execution. See `src/erk/cli/commands/land_cmd.py` and `src/erk/cli/commands/land_pipeline.py` for production patterns.

**GitHub API stabilization**:

```python
# Wait for GitHub to recalculate merge status after base update
ctx.time.sleep(2.0)  # Instant in tests, real wait in production
```

### Testing Benefits

**Without Time abstraction**:

```python
def test_retry_logic():
    # This test takes 6+ seconds to run!
    retry_operation(max_attempts=3, delay=2.0)
    assert operation_succeeded
```

**With Time abstraction**:

```python
def test_retry_logic():
    fake_time = FakeTime()
    ctx = ErkContext.minimal(git=FakeGit(...), cwd=Path("<temp-dir>"))
    ctx = dataclasses.replace(ctx, time=fake_time)

    retry_operation(ctx, max_attempts=3, delay=2.0)

    # Test completes instantly!
    assert fake_time.sleep_calls == [2.0, 4.0, 8.0]
    assert operation_succeeded
```

### Interface

<!-- Source: packages/erk-shared/src/erk_shared/gateway/time/abc.py -->

The `Time` ABC defines abstract methods for time operations including `sleep()` and `now()`. See `packages/erk-shared/src/erk_shared/gateway/time/abc.py` for the canonical interface definition.

Implementations:

- **RealTime**: Uses actual `time.sleep()` and `datetime.now()` (see `packages/erk-shared/src/erk_shared/gateway/time/real.py`)
- **FakeTime**: Returns immediately, tracks calls for test assertions (see `packages/erk-shared/src/erk_shared/gateway/time/fake.py`)

### When to Use

Use `context.time.sleep()` for:

- Retry delays and exponential backoff
- API rate limiting delays
- Waiting for external system stabilization (GitHub API, CI systems)
- Polling intervals

Use `context.time.now()` for:

- Timestamps in metadata (plan-header fields, tracking comments)
- Event logging and audit trails
- Any code that records "when" something happened

### Migration Path

If you find code using `time.sleep()`:

1. **Add Time parameter**: Add `context: ErkContext` parameter (or just `time: Time`)
2. **Replace call**: Change `time.sleep(n)` to `context.time.sleep(n)`
3. **Update tests**: Use `FakeTime` and verify `sleep_calls`

If you find code using `datetime.now()`:

1. **Add Time parameter**: Add `context: ErkContext` parameter (or just `time: Time`)
2. **Replace call**: Change `datetime.now(UTC)` to `context.time.now().replace(tzinfo=UTC)`
3. **Update tests**: Use `FakeTime` for deterministic timestamps

### Rationale

- **Fast tests**: Tests complete instantly instead of waiting for actual sleep
- **Deterministic timestamps**: Tests can assert exact timestamp values instead of "roughly now"
- **Observable**: Track exact sleep durations and timestamps in tests
- **Dependency injection**: Follows erk's DI pattern for all integrations
- **Consistent**: Same pattern as Git, GitHub, Graphite abstractions

## UTC Timestamp Idiom

When generating ISO timestamps for metadata, always use the full idiom:

```python
queued_at = ctx.time.now().replace(tzinfo=UTC).isoformat()
```

**Why `.replace(tzinfo=UTC)` instead of `datetime.now(UTC)`:**

- `ctx.time.now()` returns a naive datetime (for testability via FakeTime)
- Callers add timezone info explicitly before serialization
- Never bypass `ctx.time` with `datetime.now(UTC)` — it breaks test determinism

<!-- Source: src/erk/cli/commands/one_shot_dispatch.py, dispatch_one_shot -->

**Source:** See `dispatch_one_shot()` in `src/erk/cli/commands/one_shot_dispatch.py`.

## ctx.plan_backend

- `ctx.plan_backend` — The PlanBackend ABC interface. Use for all plan operations including `update_metadata()`, `post_event()`, `create_plan()`, `get_plan()`, `list_plans()`

After PR #8210, the `PlanStore` ABC was consolidated into `PlanBackend`. There is no separate read-side interface.

<!-- Source: packages/erk-shared/src/erk_shared/plan_store/backend.py, PlanBackend -->

**Source:** See `PlanBackend` in `packages/erk-shared/src/erk_shared/plan_store/backend.py`.

## TUI Exit-with-Command Pattern

_Note: This pattern has been deprecated. The `exit_command` attribute no longer exists in `ErkDashApp`. See `src/erk/tui/app.py` for current TUI architecture._

## Gateway Directory Structure

Erk uses a consistent directory structure for all gateways (git, github, graphite, etc). Each gateway follows the ABC/Real/Fake/DryRun pattern.

### Standard Directory Layout

```
packages/erk-shared/src/erk_shared/
└── gateway/                           # All gateways live here
    ├── git/                           # Core git gateway
    │   ├── __init__.py
    │   ├── abc.py                     # ABC interface definition
    │   ├── real.py                    # Production implementation
    │   ├── fake.py                    # In-memory test implementation
    │   ├── dry_run.py                 # No-op wrapper for dry-run mode
    │   ├── lock.py                    # Git lock handling
    │   └── printing.py                # Wrapper that logs operations
    ├── github/                        # GitHub API gateway
    │   ├── __init__.py
    │   ├── abc.py
    │   ├── real.py
    │   ├── fake.py
    │   └── dry_run.py
    ├── graphite/                      # Graphite stack operations
    │   ├── __init__.py
    │   ├── abc.py
    │   ├── real.py
    │   ├── fake.py
    │   ├── disabled.py
    │   └── dry_run.py
    ├── branch_manager/                # Branch workflow abstraction
    │   ├── __init__.py
    │   ├── abc.py
    │   ├── git.py
    │   ├── graphite.py
    │   └── fake.py
    └── time/                          # Time abstraction
        ├── __init__.py
        ├── abc.py
        ├── real.py
        └── fake.py
```

### Standard Files

**`abc.py`** - Interface definition:

```python
from abc import ABC, abstractmethod

class MyGateway(ABC):
    """Abstract interface for MyGateway operations."""

    @abstractmethod
    def do_operation(self, arg: str) -> str:
        """Perform operation with given argument."""
        ...
```

**`real.py`** - Production implementation:

```python
import subprocess
from erk_shared.my_gateway.abc import MyGateway

class RealMyGateway(MyGateway):
    """Production implementation using subprocess."""

    def do_operation(self, arg: str) -> str:
        result = subprocess.run(
            ["my-cli", "operation", arg],
            capture_output=True,
            text=True,
            check=True,
        )
        return result.stdout.strip()
```

**`fake.py`** - Test implementation:

```python
from erk_shared.my_gateway.abc import MyGateway

class FakeMyGateway(MyGateway):
    """In-memory fake for testing."""

    def __init__(self) -> None:
        self.operations_called: list[str] = []

    def do_operation(self, arg: str) -> str:
        self.operations_called.append(arg)
        return f"fake-result-{arg}"
```

**`dry_run.py`** - No-op wrapper (optional):

```python
from erk_shared.my_gateway.abc import MyGateway

class DryRunMyGateway(MyGateway):
    """No-op wrapper that tracks but doesn't execute operations."""

    def __init__(self, delegate: MyGateway) -> None:
        self.delegate = delegate
        self.operations: list[str] = []

    def do_operation(self, arg: str) -> str:
        self.operations.append(f"do_operation({arg})")
        return ""  # No-op, return empty or default value
```

**`__init__.py`** - Lightweight docstring (no re-exports):

```python
"""MyGateway operations.

Import from submodules:
- abc: MyGateway
- real: RealMyGateway
"""
```

Note: Erk gateway `__init__.py` files do NOT re-export classes. Consumers import directly from the submodule files (e.g., `from erk_shared.gateway.git.abc import Git`).

### Gateway Locations

All gateways live under `packages/erk-shared/src/erk_shared/gateway/`:

- `gateway/git/` - Git operations
- `gateway/github/` - GitHub API
- `gateway/graphite/` - Graphite stack operations
- `gateway/branch_manager/` - Branch workflow abstraction (Graphite vs Git)
- `gateway/time/` - Time abstraction for testing

### When to Create a New Gateway

**Create a new gateway when:**

- Wrapping external CLI tool (git, gh, gt)
- Wrapping external API (GitHub REST API)
- Abstracting system operations (time, filesystem)
- Isolating subprocess calls for testing

**Extend existing gateway when:**

- Adding method to existing tool (e.g., new git operation)
- Enhancing existing functionality
- Operation fits natural domain of existing gateway

### Import Pattern

**Import from submodule files directly:**

```python
# Import ABC from abc.py, implementations from their respective files
from erk_shared.gateway.git.abc import Git, WorktreeInfo
from erk_shared.gateway.git.real import RealGit
from erk_shared.gateway.git.fake import FakeGit
from erk_shared.gateway.github.abc import GitHub
```

Gateway `__init__.py` files do not re-export, so always import from the specific submodule.

### Testing All Four Layers

When adding a method to a gateway ABC:

1. **ABC**: Add abstract method signature
2. **Real**: Implement with subprocess/API calls
3. **Fake**: Implement with in-memory tracking
4. **DryRun**: Add no-op wrapper (if gateway has dry-run layer)

**Example workflow:**

```python
# 1. ABC - Add method signature
class Git(ABC):
    @abstractmethod
    def get_branch_upstream(self, path: Path, branch: str) -> str | None:
        """Get upstream tracking branch."""
        ...

# 2. Real - Implement with git CLI
class RealGit(Git):
    def get_branch_upstream(self, path: Path, branch: str) -> str | None:
        result = subprocess.run(
            ["git", "-C", str(path), "rev-parse", "--abbrev-ref", f"{branch}@{{u}}"],
            capture_output=True,
            text=True,
            check=False,
        )
        return result.stdout.strip() if result.returncode == 0 else None

# 3. Fake - Implement with in-memory state
class FakeGit(Git):
    def __init__(self) -> None:
        self.upstream_branches: dict[str, str] = {}

    def get_branch_upstream(self, path: Path, branch: str) -> str | None:
        return self.upstream_branches.get(branch)

# 4. DryRun - Add no-op wrapper
class DryRunGit(Git):
    def get_branch_upstream(self, path: Path, branch: str) -> str | None:
        # Delegate to wrapped implementation (doesn't execute git)
        return self.delegate.get_branch_upstream(path, branch)
```

### Migration Notes

If you find code that should be a gateway but isn't:

1. **Create gateway directory** with abc.py, real.py, fake.py
2. **Extract subprocess calls** into Real implementation
3. **Write Fake implementation** for testing
4. **Update consumers** to use injected dependency instead of direct calls
5. **Update tests** to use Fake instead of mocking subprocess

## Composing Template Method Pattern

The gateway ABCs use a variant of the **Template Method** pattern where concrete methods compose abstract methods to provide higher-level operations. Unlike classic Template Method (where subclasses override hooks to customize an algorithm), here the concrete methods are pure compositions that work identically for all implementations - no override points, just convenience wrappers.

### When to Use

Add a composing template method to an ABC when:

1. The logic only depends on abstract methods already in the ABC
2. There's no implementation-specific behavior (same logic for Real/Fake/DryRun)
3. The operation would otherwise be duplicated or require passing the gateway around

### Examples in Graphite ABC

| Method                       | Composes              | Purpose                                  |
| ---------------------------- | --------------------- | ---------------------------------------- |
| `get_parent_branch()`        | `get_all_branches()`  | Extract single parent from branch map    |
| `get_child_branches()`       | `get_all_branches()`  | Extract children list from branch map    |
| `find_ancestor_worktree()`   | `get_parent_branch()` | Walk parent chain to find worktree       |
| `squash_branch_idempotent()` | `squash_branch()`     | Add error handling for idempotent squash |

### Pattern Structure

```python
class Gateway(ABC):
    @abstractmethod
    def primitive_operation(self, ...) -> Result:
        """Subclasses must implement."""
        ...

    def composite_operation(self, ...) -> HigherLevelResult:
        """Concrete method - same for all implementations.

        Composes primitive_operation() to provide convenience API.
        All implementations (Real, Fake, DryRun) inherit this unchanged.
        """
        result = self.primitive_operation(...)
        # Additional logic using result
        return transformed_result
```

### Benefits

- **No duplication**: Logic defined once, inherited by all implementations
- **Automatic testing**: Works with FakeGraphite, no separate fake needed
- **Discoverability**: Related operations live together on the interface
- **Lighter dependencies**: Takes primitive args (Git, Path) not heavy context objects

### When NOT to Use

Don't add a composing template method when:

- The logic requires implementation-specific behavior
- The method would need overriding in some implementations
- The logic doesn't naturally belong to the gateway's domain

In those cases, keep the logic as a standalone function or in the calling module.

## Branch Context Detection

Commands that need to behave differently on trunk vs feature branches use the branch context pattern.

### The BranchContext Pattern

`BranchContext` is a frozen dataclass with three fields:

- `current_branch`: Name of the current branch
- `trunk_branch`: Name of the trunk branch (main or master)
- `is_on_trunk`: True if current branch is trunk

See `erk_shared/learn/extraction/types.py` for the dataclass definition.

### Helper Function

The `get_branch_context()` helper detects current branch, trunk branch (prefers 'main', falls back to 'master'), and whether current branch is trunk.

See `erk_shared/learn/extraction/session_discovery.py` for the canonical implementation.

### When to Use

Use branch context when command behavior differs based on trunk vs feature branch:

**Trunk branch behavior:**

- Baseline operations (e.g., "show all open feature branches")
- Repository-wide queries
- Stack management from trunk perspective

**Feature branch behavior:**

- Feature-specific operations (e.g., "sessions for this feature")
- Branch-relative queries
- Single-branch workflows

### Example Usage

Commands typically use branch context to decide behavior:

- **On trunk**: Show all feature branches, repository-wide queries
- **On feature branch**: Show items for current feature only, branch-relative queries

See `src/erk/cli/commands/exec/scripts/list_sessions.py` for a real-world usage example.

### Testing Branch Context

```python
def test_command_on_trunk() -> None:
    """Test command behavior on trunk branch."""
    fake_git = FakeGit()
    fake_git.current_branch = "main"
    fake_git.branches = {"main": "abc123"}

    branch_ctx = get_branch_context(fake_git, Path("/repo"))

    assert branch_ctx.is_on_trunk is True
    assert branch_ctx.current_branch == "main"
    assert branch_ctx.trunk_branch == "main"

def test_command_on_feature() -> None:
    """Test command behavior on feature branch."""
    fake_git = FakeGit()
    fake_git.current_branch = "feature-123"
    fake_git.branches = {"main": "abc123", "feature-123": "def456"}

    branch_ctx = get_branch_context(fake_git, Path("/repo"))

    assert branch_ctx.is_on_trunk is False
    assert branch_ctx.current_branch == "feature-123"
    assert branch_ctx.trunk_branch == "main"
```

### Trunk Detection Logic

The helper implements this precedence:

1. **Check for 'main' branch** - Most common modern convention
2. **Fall back to 'master'** - Legacy convention
3. **Compare current branch** to detected trunk

This ensures correct detection regardless of repository trunk name.

## Current Worktree Detection

**ALWAYS use `git.get_repository_root(cwd)` to determine which worktree contains a path.**

### The Problem

Path-based detection using `.exists()`, `.resolve()`, and `.is_relative_to()` is fragile:

1. Symlinks can cause paths to resolve differently
2. Relative vs absolute paths may not match
3. Case sensitivity issues on some filesystems
4. Path normalization edge cases

### Wrong Pattern

```python
# WRONG: Path comparisons are fragile
def find_worktree_for_cwd(assignments: list[SlotAssignment], cwd: Path) -> SlotAssignment | None:
    for assignment in assignments:
        worktree_path = assignment.worktree_path
        # Fragile: exists() then resolve() then is_relative_to()
        if worktree_path.exists() and cwd.exists():
            if cwd.resolve().is_relative_to(worktree_path.resolve()):
                return assignment
    return None
```

### Correct Pattern

```python
# CORRECT: Use git to determine worktree root
def find_worktree_for_cwd(
    assignments: list[SlotAssignment], git: Git, cwd: Path
) -> SlotAssignment | None:
    # Git knows exactly which worktree contains this path
    worktree_root = git.get_repository_root(cwd)
    for assignment in assignments:
        # Exact match - no path gymnastics needed
        if assignment.worktree_path == worktree_root:
            return assignment
    return None
```

### Why Git-Based Detection Works

`git rev-parse --show-toplevel` (exposed via `git.get_repository_root()`) handles:

- Symlink resolution correctly
- Nested directory detection
- Cross-platform path normalization
- All edge cases git handles internally

### When to Use

Use git-based detection when you need to:

- Determine which worktree contains the current working directory
- Match paths against a list of known worktrees
- Validate that a path is within a specific repository

## Root Worktree Detection

**ALWAYS use `WorktreeInfo.is_root` instead of path comparison to identify the root worktree.**

### The Problem

When running from within a non-root worktree, path comparison fails because:

1. `ctx.cwd` resolves to the worktree path (e.g., `/Users/me/erks/repo/feature-branch`)
2. `repo_root` from git discovery resolves to the main repo (e.g., `/Users/me/repos/repo`)
3. Direct path comparison always returns False, even when you're in the root worktree

### Wrong Pattern

```python
# WRONG: Path comparison breaks from non-root worktrees
def is_root_worktree(worktree_path: Path, repo_root: Path) -> bool:
    return worktree_path.resolve() == repo_root.resolve()

# WRONG: Manual computation of is_root
is_root = worktree_path.resolve() == repo_root.resolve()
```

### Correct Pattern

```python
# CORRECT: Use WorktreeInfo.is_root from git worktree list
worktrees = ctx.git.list_worktrees(repo_root)
for wt in worktrees:
    if wt.is_root:
        # This is the root worktree
        handle_root_worktree(wt)
    else:
        # This is a feature worktree
        handle_feature_worktree(wt)
```

### How WorktreeInfo.is_root Works

`WorktreeInfo.is_root` is populated by `RealGit.list_worktrees()` when parsing `git worktree list --porcelain`:

- Git marks the root worktree with `branch refs/heads/<trunk>` where the `.git` directory lives
- The first worktree entry (before any blank line separator) is always the root
- `is_root=True` is set based on this git metadata, not path comparison

### When to Use

Use `wt.is_root` whenever you need to:

- Filter out the root worktree from worktree lists
- Identify which worktree is the main repository
- Determine special handling for root vs feature worktrees

### Examples in Codebase

```python
# From checkout.py - handling root worktree specially
if wt.is_root:
    # Root worktree navigation
    activate_root_repo(ctx, repo, script, command_name="checkout")

# From checkout_cmd.py - filtering worktrees
if not wt.is_root:
    # Only show non-root worktrees in picker
    worktree_options.append(wt)
```

## BranchManager for Graphite vs Git Operations

**ALWAYS use `BranchManager` abstraction when implementing operations that differ between Graphite and plain Git.**

### The Problem

When Graphite is disabled (`use_graphite=false` in config), commands that use Graphite operations fail. The common anti-pattern is checking `isinstance(ctx.graphite, GraphiteDisabled)` inline in each command:

```python
# WRONG: Inline GraphiteDisabled checks scattered throughout commands
def execute_quick_submit(ctx: ErkContext) -> None:
    if isinstance(ctx.graphite, GraphiteDisabled):
        # Git path
        ctx.git.push_to_remote(...)
    else:
        # Graphite path
        ctx.graphite.submit_stack(...)
```

This scatters Graphite vs Git branching logic throughout the codebase, making it harder to maintain and test.

### Correct Pattern: BranchManager Abstraction

`BranchManager` is an ABC that abstracts Graphite vs Git differences:

- **`GraphiteBranchManager`**: Uses Graphite operations
- **`GitBranchManager`**: Uses plain Git operations
- **`FakeBranchManager`**: For testing

```python
# CORRECT: Use BranchManager abstraction
def execute_quick_submit(ctx: ErkContext) -> None:
    # BranchManager handles the Graphite vs Git difference
    ctx.branch_manager.submit_branch(repo_root, branch)
```

### How BranchManager is Created

<!-- Source: packages/erk-shared/src/erk_shared/context/context.py -->

`ErkContext.branch_manager` property automatically selects the right implementation. See the `branch_manager` property in `packages/erk-shared/src/erk_shared/context/context.py` for the complete implementation including DryRun unwrapping logic.

### Adding New Operations to BranchManager

When you need an operation that differs between Graphite and Git:

1. **Add abstract method to `BranchManager` ABC** (`branch_manager/abc.py`)
2. **Implement in `GraphiteBranchManager`** (`branch_manager/graphite.py`)
3. **Implement in `GitBranchManager`** (`branch_manager/git.py`)
4. **Add to `FakeBranchManager`** (`branch_manager/fake.py`) for testing

### Existing BranchManager Methods

| Method                  | GraphiteBranchManager                   | GitBranchManager     |
| ----------------------- | --------------------------------------- | -------------------- |
| `create_branch()`       | `gt branch create`                      | `git checkout -b`    |
| `delete_branch()`       | `git branch -D` (with graphite cleanup) | `git branch -D`      |
| `submit_branch()`       | `gt submit --force --quiet`             | `git push -u origin` |
| `get_pr_for_branch()`   | GitHub API lookup                       | GitHub API lookup    |
| `is_graphite_managed()` | `True`                                  | `False`              |

### When to Use BranchManager vs Direct Gateway

**Use BranchManager when:**

- Operation differs between Graphite and Git modes
- Operation involves branch workflow (create, delete, submit, navigate)
- You would otherwise check `isinstance(ctx.graphite, GraphiteDisabled)`

**Use gateway directly when:**

- Operation is the same regardless of Graphite mode
- Operation is purely Git or purely GitHub (not workflow-dependent)
- Operation doesn't involve branch management

### Testing with FakeBranchManager

```python
def test_quick_submit_tracks_submission() -> None:
    fake_branch_manager = FakeBranchManager()
    ctx = ErkContext.for_test(branch_manager=fake_branch_manager)

    execute_quick_submit(ctx, repo_root, "feature-branch")

    assert fake_branch_manager.submitted_branches == ["feature-branch"]
```

### context.branch_manager Property

<!-- Source: packages/erk-shared/src/erk_shared/context/context.py -->

The `ErkContext.branch_manager` property provides automatic wrapper unwrapping for dry-run mode. See the complete implementation in `packages/erk-shared/src/erk_shared/context/context.py`.

This unwrapping is necessary because:

1. `DryRunGraphite` wraps a real `Graphite` implementation
2. `isinstance(ctx.graphite, GraphiteDisabled)` would return `False` for `DryRunGraphite`
3. Without unwrapping, commands in dry-run mode would incorrectly use `GraphiteBranchManager`

## Graceful Degradation Patterns

When operations have optional behavior based on availability of resources, use graceful degradation instead of hard failures.

### Pattern: Branch Lookup Fallback

When resolving a base branch for a PR, the submit pipeline gracefully handles missing parent plans by falling back to the trunk branch. If a parent branch is available, it is used as the base; otherwise, the trunk branch serves as the fallback.

<!-- Source: src/erk/cli/commands/pr/submit_pipeline.py, push_and_create_pr -->

### When to Use Graceful Degradation

- **Resource might not exist**: Parent plan may be deleted, branch may not be recorded
- **Feature is enhancement, not requirement**: Stacking on parent is nice, stacking on trunk still works
- **Failure doesn't corrupt state**: Using trunk as base doesn't break anything

### When NOT to Use

- **Failure indicates bug**: Missing required field should raise exception
- **Degraded behavior is confusing**: User expects A, silently gets B
- **State corruption possible**: Partial operation would leave invalid state

## Deferred Actions via Activation Scripts

When actions must execute **after** the user changes directory (e.g., deleting the current worktree), use the `post_cd_commands` pattern to embed shell commands in activation scripts.

### The Problem

Some operations cannot complete while the shell is inside the target directory:

- Deleting the current worktree fails because the directory is in use
- The user needs to navigate away first, then perform the cleanup

### The Pattern

Pass shell commands to `render_activation_script()` via the `post_cd_commands` parameter. These commands execute after the `cd` command completes.

```python
from erk.cli.activation import render_activation_script

def navigate_with_cleanup(target_worktree: Path, cleanup_commands: list[str]) -> str:
    return render_activation_script(
        worktree_path=target_worktree,
        target_subpath=None,
        post_cd_commands=cleanup_commands,
        final_message="echo 'Navigated with cleanup'",
        comment="# navigate-with-cleanup",
    )
```

### Real-World Example: `--delete-current` Flag

<!-- Source: src/erk/cli/commands/navigation_helpers.py -->

The `erk up` and `erk down` commands support `--delete-current` to navigate away then delete the current worktree. See `render_deferred_deletion_commands()` in `src/erk/cli/commands/navigation_helpers.py` for the implementation.

The activation script structure:

```bash
cd /path/to/target/worktree
# Post-cd commands execute here:
git worktree remove /path/to/old/worktree
git branch -D old-branch
```

### When to Use

Use deferred actions via `post_cd_commands` when:

- The action requires the shell to be outside a specific directory
- The action must happen after successful navigation
- The action involves cleanup of the source location

### Implementation Notes

- Commands are executed in sequence after `cd` succeeds
- Use `shlex.quote()` for paths to handle special characters safely
- Commands run in the target directory's context

## Idempotency Patterns

Erk uses two distinct idempotency patterns:

### Passive Idempotency

"If already done, skip silently."

```python
if ctx.graphite.is_branch_tracked(branch_name):
    return  # Already tracked, nothing to do
ctx.graphite.track_branch(branch_name, parent)
```

**Use when:** The previous operation's side effects are sufficient.

### Active Idempotency

"If already done, re-run safely."

```python
parent = ctx.branch_manager.get_parent_branch(repo.root, branch_name)
if parent is not None:  # Already tracked
    ctx.graphite.squash_branch_idempotent(repo.root, quiet=True)
```

**Use when:** Re-running provides value (e.g., sync pulls latest changes, restack updates base).

### Pattern Selection

| Scenario        | Pattern | Reason                       |
| --------------- | ------- | ---------------------------- |
| Branch tracking | Passive | Re-tracking has no benefit   |
| Remote sync     | Active  | New commits may exist        |
| Restack         | Active  | Parent may have changed      |
| Squash          | Active  | Additional commits may exist |

## Best-Effort Operations

For operations where failure is informational rather than critical, use the best-effort pattern: `try/except` with `logger.warning()`, not silent `pass`.

```python
# CORRECT: Best-effort with logging
try:
    github.update_pr_body(pr_number, updated_body)
except Exception:
    logger.warning("Failed to update PR body with workflow link")

# WRONG: Silent failure hides bugs
try:
    github.update_pr_body(pr_number, updated_body)
except Exception:
    pass
```

**When to use:** The operation enhances but doesn't block the workflow (e.g., adding a workflow run link to a stub PR body). The primary workflow should continue regardless.

**When NOT to use:** The operation is required for correctness (e.g., creating the issue, pushing the branch). Use standard error handling instead.

See [Stub PR Workflow Link](../pr-operations/stub-pr-workflow-link.md) for a real-world example of this pattern.

## Constructor Call Site Inlining

Extension to the single-use locals rule: when a constructor is called only once and its arguments are clear, inline the construction at the call site rather than assigning to a local variable first. This reduces scope pollution:

```python
# Prefer: inline construction
return PipelineResult(status="success", output=formatted)

# Avoid: unnecessary local
result = PipelineResult(status="success", output=formatted)
return result
```

## Hoist Scalar Derivation Above Branching Calls

When a scalar value (branch name, issue number) is needed by multiple code paths, compute it once at the top of the function before the branching logic. This prevents duplicated derivation in each branch:

```python
def process(ctx: ErkContext) -> None:
    # Hoist: compute once before branching
    branch = ctx.git.branch.get_current_branch(ctx.cwd)

    if condition_a:
        do_thing_a(branch)
    else:
        do_thing_b(branch)
```

## Always Get Current Branch Unconditionally at Function Top

Related to hoisting: when a function may need the current branch, always fetch it at the top of the function body. Don't defer the call to inside a conditional branch — this leads to duplicated calls or missed branches.

## Design Principles

These patterns reflect erk's core design principles:

1. **Dependency Injection over Configuration** - Behavior determined by what's injected, not flags
2. **Explicit Context Management** - Context must be regenerated when environment changes
3. **Layered Error Handling** - Different error handling at different architectural boundaries
4. **Testability First** - Patterns enable easy testing with fakes and mocks
