feat(context): centralize RepoContext API for git operations (#1102)

Centralizes repository context resolution via RepoContext API, fixing bugs where git commands run in the wrong repo when BEADS_DIR points elsewhere or in worktree scenarios.
This commit is contained in:
Peter Chanthamynavong
2026-01-15 07:55:08 -08:00
committed by GitHub
parent 159114563b
commit 0a48519561
33 changed files with 3211 additions and 327 deletions

256
docs/REPO_CONTEXT.md Normal file
View File

@@ -0,0 +1,256 @@
# Repository Context
This document explains how beads resolves repository context when commands run from
different directories than where `.beads/` lives.
## Problem
Git commands must run in the correct repository, but users may invoke `bd` from:
- A different repository (using `BEADS_DIR` environment variable)
- A git worktree (separate working directory, shared `.beads/`)
- A subdirectory within the repository
Without centralized handling, each command must implement its own path resolution,
leading to bugs when assumptions don't match reality.
## Solution: RepoContext API
The `RepoContext` API provides a single source of truth for repository resolution:
```go
import "github.com/steveyegge/beads/internal/beads"
rc, err := beads.GetRepoContext()
if err != nil {
return err
}
// Run git in beads repository (not CWD)
cmd := rc.GitCmd(ctx, "status")
output, err := cmd.Output()
```
## When to Use Each Method
| Method | Use Case | Example |
|--------|----------|---------|
| `GitCmd()` | Git commands for beads operations | `git add .beads/`, `git push` |
| `GitCmdCWD()` | Git commands for user's working repo | `git status` (show user's changes) |
| `RelPath()` | Convert absolute path to repo-relative | Display paths in output |
### GitCmd() vs GitCmdCWD()
The distinction matters when `BEADS_DIR` points to a different repository:
```go
rc, _ := beads.GetRepoContext()
// GitCmd: runs in the beads repository
// Use for: committing .beads/, pushing/pulling beads data
cmd := rc.GitCmd(ctx, "add", ".beads/issues.jsonl")
// GitCmdCWD: runs in user's current repository
// Use for: checking user's uncommitted changes, status display
cmd := rc.GitCmdCWD(ctx, "status", "--porcelain")
```
## Scenarios
### Normal Repository
CWD is inside the repository containing `.beads/`:
```
/project/
├── .beads/
├── src/
└── README.md
$ cd /project/src
$ bd sync
# GitCmd() runs in /project (correct)
```
### BEADS_DIR Redirect
User is in one repository but managing beads in another:
```
$ cd /repo-a # Has uncommitted changes
$ export BEADS_DIR=/repo-b/.beads
$ bd sync
# GitCmd() runs in /repo-b (correct, not /repo-a)
```
This pattern is common for:
- Fork contribution tracking (your tracker in separate repo)
- Shared team databases
- Monorepo setups
### Git Worktree
User is in a worktree but `.beads/` lives in main repository:
```
/project/ # Main repo
├── .beads/
├── .worktrees/
│ └── feature-branch/ # Worktree (CWD)
└── src/
$ cd /project/.worktrees/feature-branch
$ bd sync
# GitCmd() runs in /project (main repo, where .beads lives)
```
### Combined: Worktree + Redirect
Both worktree and BEADS_DIR can be active simultaneously:
```
$ cd /repo-a/.worktrees/branch-x
$ export BEADS_DIR=/repo-b/.beads
$ bd sync
# GitCmd() runs in /repo-b (BEADS_DIR takes precedence)
```
## RepoContext Fields
| Field | Description |
|-------|-------------|
| `BeadsDir` | Actual `.beads/` directory (after following redirects) |
| `RepoRoot` | Repository root containing `BeadsDir` |
| `CWDRepoRoot` | Repository root containing user's CWD (may differ) |
| `IsRedirected` | True if BEADS_DIR points to different repo than CWD |
| `IsWorktree` | True if CWD is in a git worktree |
## Security
### Git Hooks Disabled
`GitCmd()` disables git hooks and templates to prevent code execution in
potentially malicious repositories:
```go
cmd.Env = append(os.Environ(),
"GIT_HOOKS_PATH=", // Disable hooks
"GIT_TEMPLATE_DIR=", // Disable templates
)
```
This protects against scenarios where `BEADS_DIR` points to an untrusted
repository that contains malicious `.git/hooks/` scripts.
### Path Boundary Validation
`GetRepoContext()` validates that `BEADS_DIR` does not point to sensitive
system directories:
- `/etc`, `/usr`, `/var`, `/root` (Unix system directories)
- `/System`, `/Library` (macOS system directories)
- Other users' home directories
Temporary directories (e.g., `/var/folders` on macOS) are explicitly allowed
for test environments.
## Daemon Handling
### CLI vs Daemon Context
For CLI commands, `GetRepoContext()` caches the result via `sync.Once` because:
- CWD doesn't change during command execution
- BEADS_DIR doesn't change during command execution
- Repeated filesystem access would be wasteful
For the daemon (long-running process), this caching is inappropriate:
- User may create new worktrees
- BEADS_DIR may change via direnv
- Multiple workspaces may be active simultaneously
### Workspace-Specific API
The daemon uses `GetRepoContextForWorkspace()` for fresh resolution:
```go
// For daemon: fresh resolution per-operation (no caching)
rc, err := beads.GetRepoContextForWorkspace(workspacePath)
// Validation hook for detecting stale contexts
if err := rc.Validate(); err != nil {
// Context is stale, need fresh resolution
}
```
This function:
- Does NOT cache results
- Does NOT respect BEADS_DIR (workspace path is explicit)
- Resolves worktree relationships correctly
- Validates that paths still exist
## Migration Guide
### Before (scattered resolution)
```go
func doGitOperation(ctx context.Context) error {
// Each function resolved paths differently
beadsDir := beads.FindBeadsDir()
redirectInfo := beads.GetRedirectInfo()
var repoRoot string
if redirectInfo.IsRedirected {
repoRoot = filepath.Dir(beadsDir)
} else {
repoRoot = getRepoRootForWorktree(ctx)
}
cmd := exec.CommandContext(ctx, "git", "-C", repoRoot, "status")
// ...
}
```
### After (centralized)
```go
func doGitOperation(ctx context.Context) error {
rc, err := beads.GetRepoContext()
if err != nil {
return err
}
cmd := rc.GitCmd(ctx, "status")
// ...
}
```
### Key Changes for Contributors
1. **Replace direct exec.Command**: Use `rc.GitCmd()` or `rc.GitCmdCWD()`
2. **Remove manual path resolution**: RepoContext handles all scenarios
3. **Clear caches in tests**: Call `beads.ResetCaches()` in test cleanup
## Testing
Tests use `beads.ResetCaches()` to clear cached context between test cases:
```go
func TestSomething(t *testing.T) {
t.Cleanup(func() {
beads.ResetCaches()
git.ResetCaches()
})
// Test code...
}
```
## Related Documentation
- [WORKTREES.md](WORKTREES.md) - Git worktree integration
- [ROUTING.md](ROUTING.md) - Multi-repository routing
- [CONFIG.md](CONFIG.md) - BEADS_DIR and environment variables
- [DAEMON.md](DAEMON.md) - Daemon architecture and workspace handling
## Implementation Notes
- Result is cached via `sync.Once` for CLI efficiency
- CWD and BEADS_DIR don't change during command execution
- Uses `cmd.Dir` pattern (not `-C` flag) for Go-idiomatic execution
- Security mitigations implemented for git hooks and path traversal

View File

@@ -521,6 +521,7 @@ No daemon conflicts, no branch confusion - all worktrees see the same issues bec
## See Also
- [REPO_CONTEXT.md](REPO_CONTEXT.md) - RepoContext API for contributors
- [GIT_INTEGRATION.md](GIT_INTEGRATION.md) - General git integration guide
- [AGENTS.md](../AGENTS.md) - Agent usage instructions
- [README.md](../README.md) - Main project documentation