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:
committed by
GitHub
parent
159114563b
commit
0a48519561
256
docs/REPO_CONTEXT.md
Normal file
256
docs/REPO_CONTEXT.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user