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.
257 lines
6.7 KiB
Markdown
257 lines
6.7 KiB
Markdown
# 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
|