|
|
|
@@ -1,190 +1,265 @@
|
|
|
|
|
# Git Worktree Sandbox
|
|
|
|
|
# Git Worktree Sandbox Module
|
|
|
|
|
|
|
|
|
|
**Package:** `cleveragents.infrastructure.sandbox.git_worktree`
|
|
|
|
|
**Introduced:** Unreleased (issue #4454)
|
|
|
|
|
**Package:** `cleveragents.infrastructure.sandbox`
|
|
|
|
|
**Introduced:** v3.5.0 (PR #5998)
|
|
|
|
|
|
|
|
|
|
The git worktree sandbox isolates plan modifications in a dedicated git
|
|
|
|
|
branch and worktree. On commit, changes are merged back to the original
|
|
|
|
|
branch via `git merge`. On rollback, the worktree is discarded entirely.
|
|
|
|
|
This replaces the previous flat `shutil.copy2` apply strategy for
|
|
|
|
|
git-checkout resources.
|
|
|
|
|
The git worktree sandbox provides isolated, branch-based staging for plan
|
|
|
|
|
modifications against git-checkout resources. Changes are committed to a
|
|
|
|
|
dedicated branch inside a temporary worktree and merged back to the original
|
|
|
|
|
branch only when the plan is applied — leaving the working tree untouched
|
|
|
|
|
until the operator explicitly approves.
|
|
|
|
|
|
|
|
|
|
For the sandbox protocol interface, see
|
|
|
|
|
[`docs/api/core.md`](../api/core.md).
|
|
|
|
|
For the plan lifecycle that invokes the sandbox, see
|
|
|
|
|
[`docs/architecture.md`](../architecture.md#plan-lifecycle).
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Purpose
|
|
|
|
|
|
|
|
|
|
When a plan applies changes to a git-checkout resource, the changes must
|
|
|
|
|
be isolated from the working tree until the user approves them. The git
|
|
|
|
|
worktree sandbox achieves this by:
|
|
|
|
|
When a plan executes against a `git-checkout` resource, the LLM writes
|
|
|
|
|
files to an isolated git worktree rather than directly to the project
|
|
|
|
|
directory. This guarantees that:
|
|
|
|
|
|
|
|
|
|
1. Creating a new branch `cleveragents/plan-<plan_id>` from the current HEAD.
|
|
|
|
|
2. Creating a temporary git worktree at a system temp directory.
|
|
|
|
|
3. Letting the actor write changes inside the worktree.
|
|
|
|
|
4. On commit: staging all changes, committing them in the worktree, then
|
|
|
|
|
merging the sandbox branch back into the original branch.
|
|
|
|
|
5. On rollback: resetting the worktree (and optionally the original branch)
|
|
|
|
|
to the base commit and discarding the worktree.
|
|
|
|
|
- The original working tree is never modified until `plan apply` is run.
|
|
|
|
|
- Rollback is a single `git reset --hard` — no file-by-file undo.
|
|
|
|
|
- The diff between the base commit and the sandbox branch is always
|
|
|
|
|
available via `plan diff`.
|
|
|
|
|
- Concurrent plans on the same repository do not interfere with each other.
|
|
|
|
|
|
|
|
|
|
Non-git projects fall back to the original flat file copy strategy.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## `GitWorktreeSandbox`
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
|
|
|
|
|
|
|
|
|
|
sandbox = GitWorktreeSandbox(
|
|
|
|
|
resource_id="01HZ...",
|
|
|
|
|
original_path="/path/to/repo",
|
|
|
|
|
)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Implements the `Sandbox` protocol.
|
|
|
|
|
|
|
|
|
|
**Constructor parameters:**
|
|
|
|
|
|
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|
|
|
|-----------|------|---------|-------------|
|
|
|
|
|
| `resource_id` | `str` | — | Identifier of the resource being sandboxed |
|
|
|
|
|
| `original_path` | `str` | — | Path to the git repository root |
|
|
|
|
|
| `git_timeout` | `int` | `30` | Timeout in seconds for git commands |
|
|
|
|
|
Non-git projects fall back to the original flat `shutil.copy2` strategy.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Lifecycle
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
PENDING ──► create() ──► CREATED ──► get_path() ──► ACTIVE
|
|
|
|
|
│
|
|
|
|
|
commit() ─┤─► COMMITTED ──► cleanup() ──► CLEANED_UP
|
|
|
|
|
│
|
|
|
|
|
rollback() ─┴─► ROLLED_BACK ──► cleanup() ──► CLEANED_UP
|
|
|
|
|
GitWorktreeSandbox(resource_id, original_path)
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
sandbox.create(plan_id) → CREATED
|
|
|
|
|
│ Creates branch cleveragents/plan-<plan_id>
|
|
|
|
|
│ Creates temporary worktree at /tmp/ca-sandbox-<plan_id>-*/
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
sandbox.get_path("src/x.py") → ACTIVE
|
|
|
|
|
│ Translates resource-relative path to worktree-absolute path
|
|
|
|
|
│ Actor writes files here
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
sandbox.commit("message") → COMMITTED
|
|
|
|
|
│ git add -A && git commit in worktree
|
|
|
|
|
│ git merge <branch> into original branch
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
sandbox.cleanup() → CLEANED_UP
|
|
|
|
|
git worktree remove --force
|
|
|
|
|
git branch -D cleveragents/plan-<plan_id>
|
|
|
|
|
git worktree prune
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### `create(plan_id) → SandboxContext`
|
|
|
|
|
At any point before `commit()`, calling `rollback()` resets the worktree
|
|
|
|
|
to the base commit and transitions to `ROLLED_BACK`. After `commit()`,
|
|
|
|
|
`rollback()` also resets the original branch to the pre-merge commit,
|
|
|
|
|
fully undoing the merge.
|
|
|
|
|
|
|
|
|
|
Creates the worktree and branch.
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Key Classes
|
|
|
|
|
|
|
|
|
|
### `GitWorktreeSandbox`
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
ctx = sandbox.create(plan_id="plan-01HZ...")
|
|
|
|
|
# ctx.sandbox_path — path to the worktree
|
|
|
|
|
# ctx.metadata["branch"] — e.g. "cleveragents/plan-plan-01HZ..."
|
|
|
|
|
# ctx.metadata["base_commit"] — HEAD SHA at creation time
|
|
|
|
|
```
|
|
|
|
|
from cleveragents.infrastructure.sandbox.git_worktree import GitWorktreeSandbox
|
|
|
|
|
|
|
|
|
|
Raises `SandboxCreationError` if the path is not a git repository root or
|
|
|
|
|
if `git worktree add` fails.
|
|
|
|
|
|
|
|
|
|
### `get_path(resource_path) → str`
|
|
|
|
|
|
|
|
|
|
Translates a resource-relative path to an absolute path inside the
|
|
|
|
|
worktree. Rejects `..` path traversal attempts.
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
sandbox = GitWorktreeSandbox(
|
|
|
|
|
resource_id="my-repo",
|
|
|
|
|
original_path="/path/to/project",
|
|
|
|
|
git_timeout=30, # optional; default 30 s
|
|
|
|
|
)
|
|
|
|
|
ctx = sandbox.create(plan_id="01JXYZ")
|
|
|
|
|
worktree_file = sandbox.get_path("src/main.py")
|
|
|
|
|
# → "/tmp/ca-sandbox-plan-01HZ.../src/main.py"
|
|
|
|
|
# ... actor writes to worktree_file ...
|
|
|
|
|
result = sandbox.commit("feat: implement feature X")
|
|
|
|
|
sandbox.cleanup()
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### `commit(message=None) → CommitResult`
|
|
|
|
|
| Parameter | Type | Description |
|
|
|
|
|
|-----------|------|-------------|
|
|
|
|
|
| `resource_id` | `str` | Identifier of the resource being sandboxed |
|
|
|
|
|
| `original_path` | `str` | Absolute path to the git repository root |
|
|
|
|
|
| `git_timeout` | `int` | Timeout in seconds for each git command (default: 30) |
|
|
|
|
|
|
|
|
|
|
Stages all changes in the worktree, commits them, then merges the sandbox
|
|
|
|
|
branch into the original branch.
|
|
|
|
|
### `SandboxContext`
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
result = sandbox.commit("feat: implement new feature")
|
|
|
|
|
# result.success — True on success
|
|
|
|
|
# result.commit_ref — SHA of the sandbox commit
|
|
|
|
|
# result.changed_files — list of modified file paths
|
|
|
|
|
# result.added_files — list of newly added file paths
|
|
|
|
|
# result.deleted_files — list of deleted file paths
|
|
|
|
|
```
|
|
|
|
|
Returned by `create()`. Contains:
|
|
|
|
|
|
|
|
|
|
If there are no staged changes, returns a `CommitResult` with empty file
|
|
|
|
|
lists and no new commit.
|
|
|
|
|
| Attribute | Type | Description |
|
|
|
|
|
|-----------|------|-------------|
|
|
|
|
|
| `sandbox_id` | `str` | ULID uniquely identifying this sandbox instance |
|
|
|
|
|
| `sandbox_path` | `str` | Absolute path to the worktree directory |
|
|
|
|
|
| `original_path` | `str` | Absolute path to the original repository |
|
|
|
|
|
| `resource_id` | `str` | Resource being sandboxed |
|
|
|
|
|
| `plan_id` | `str` | Plan that owns this sandbox |
|
|
|
|
|
| `created_at` | `datetime` | Creation timestamp |
|
|
|
|
|
| `metadata` | `dict` | Extra info: `strategy`, `branch`, `original_branch`, `base_commit`, `worktree_path` |
|
|
|
|
|
|
|
|
|
|
Raises `SandboxCommitError` if the commit or merge fails.
|
|
|
|
|
### `CommitResult`
|
|
|
|
|
|
|
|
|
|
### `rollback()`
|
|
|
|
|
Returned by `commit()`. Contains:
|
|
|
|
|
|
|
|
|
|
Discards all worktree changes.
|
|
|
|
|
| Attribute | Type | Description |
|
|
|
|
|
|-----------|------|-------------|
|
|
|
|
|
| `sandbox_id` | `str` | Sandbox identifier |
|
|
|
|
|
| `success` | `bool` | Whether the commit and merge succeeded |
|
|
|
|
|
| `commit_ref` | `str \| None` | SHA of the commit in the worktree branch |
|
|
|
|
|
| `changed_files` | `list[str]` | Modified files |
|
|
|
|
|
| `added_files` | `list[str]` | New files |
|
|
|
|
|
| `deleted_files` | `list[str]` | Deleted files |
|
|
|
|
|
| `error` | `str \| None` | Error message if `success=False` |
|
|
|
|
|
| `timestamp` | `datetime` | Commit timestamp |
|
|
|
|
|
|
|
|
|
|
- From `ACTIVE`: resets the worktree branch to the base commit.
|
|
|
|
|
- From `COMMITTED`: also resets the original branch to the pre-merge commit
|
|
|
|
|
(fully undoes the merge).
|
|
|
|
|
### `SandboxStatus`
|
|
|
|
|
|
|
|
|
|
!!! warning "Multi-worktree safety"
|
|
|
|
|
Rolling back from `COMMITTED` executes `git reset --hard` on the
|
|
|
|
|
original branch. If other git worktrees or external processes are
|
|
|
|
|
tracking the same branch, they will be affected.
|
|
|
|
|
|
|
|
|
|
### `cleanup()`
|
|
|
|
|
|
|
|
|
|
Removes the worktree directory and deletes the sandbox branch. Idempotent
|
|
|
|
|
— safe to call multiple times.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Apply Summary
|
|
|
|
|
|
|
|
|
|
When `plan apply` uses the git worktree strategy, the CLI displays a
|
|
|
|
|
spec-aligned Apply Summary:
|
|
|
|
|
Lifecycle state machine:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
╭─ Apply Summary ──────────────────────────────────────╮
|
|
|
|
|
│ Plan ID: plan-01HZ... │
|
|
|
|
|
│ Artifacts: 3 files changed │
|
|
|
|
|
│ Insertions: +142 │
|
|
|
|
|
│ Deletions: -17 │
|
|
|
|
|
│ Project: local/my-project │
|
|
|
|
|
│ Timestamp: 2026-04-09 17:30:00 │
|
|
|
|
|
╰──────────────────────────────────────────────────────╯
|
|
|
|
|
╭─ Sandbox Cleanup ────────────────────────────────────╮
|
|
|
|
|
│ ✓ Worktree removed │
|
|
|
|
|
│ ✓ Branch deleted │
|
|
|
|
|
╰──────────────────────────────────────────────────────╯
|
|
|
|
|
✓ OK Changes applied
|
|
|
|
|
PENDING → CREATED → ACTIVE → COMMITTED → CLEANED_UP
|
|
|
|
|
↘ ROLLED_BACK ↗
|
|
|
|
|
↘ ERRORED
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Error Types
|
|
|
|
|
|
|
|
|
|
| Exception | When Raised |
|
|
|
|
|
|-----------|-------------|
|
|
|
|
|
| `SandboxCreationError` | `git worktree add` fails or path is not a git root |
|
|
|
|
|
| `SandboxCommitError` | `git commit` or `git merge` fails |
|
|
|
|
|
| `SandboxRollbackError` | `git reset --hard` fails during rollback |
|
|
|
|
|
| `SandboxStateError` | Method called in an invalid lifecycle state |
|
|
|
|
|
|
|
|
|
|
All exceptions are importable from
|
|
|
|
|
`cleveragents.infrastructure.sandbox.protocol`.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Branch Naming
|
|
|
|
|
|
|
|
|
|
Branch names are sanitised from the plan ID:
|
|
|
|
|
The sandbox branch is named `cleveragents/plan-<sanitised_plan_id>`.
|
|
|
|
|
The sanitiser replaces any character outside `[a-zA-Z0-9/_.-]` with a
|
|
|
|
|
hyphen and strips leading/trailing hyphens. For example:
|
|
|
|
|
|
|
|
|
|
- Only alphanumeric characters, hyphens, underscores, slashes, and dots
|
|
|
|
|
are kept.
|
|
|
|
|
- Runs of disallowed characters are collapsed into a single hyphen.
|
|
|
|
|
- The final branch name has the form `cleveragents/plan-<sanitised_plan_id>`.
|
|
|
|
|
| Plan ID | Branch |
|
|
|
|
|
|---------|--------|
|
|
|
|
|
| `01JXYZ` | `cleveragents/plan-01JXYZ` |
|
|
|
|
|
| `plan with spaces` | `cleveragents/plan-plan-with-spaces` |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Fallback for Non-Git Projects
|
|
|
|
|
## Error Handling
|
|
|
|
|
|
|
|
|
|
`PlanLifecycleService` detects whether the resource is a git-checkout type.
|
|
|
|
|
If not, it falls back to the original flat file copy strategy
|
|
|
|
|
(`shutil.copy2`). The git worktree sandbox is only used for resources
|
|
|
|
|
where `resource_type_name` is `git-checkout` or `git`.
|
|
|
|
|
| Exception | When raised |
|
|
|
|
|
|-----------|-------------|
|
|
|
|
|
| `SandboxCreationError` | `git worktree add` fails or path is not a git root |
|
|
|
|
|
| `SandboxCommitError` | `git commit` or `git merge` fails |
|
|
|
|
|
| `SandboxRollbackError` | `git reset --hard` fails during rollback |
|
|
|
|
|
| `SandboxStateError` | Method called in an invalid lifecycle state |
|
|
|
|
|
| `ValueError` | Empty `resource_id`, `original_path`, or `plan_id`; path traversal attempt; `git_timeout <= 0` |
|
|
|
|
|
|
|
|
|
|
All git commands are wrapped with a configurable timeout.
|
|
|
|
|
`subprocess.TimeoutExpired` is caught and re-raised as the appropriate
|
|
|
|
|
`Sandbox*Error`.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Related
|
|
|
|
|
## Rollback Behaviour
|
|
|
|
|
|
|
|
|
|
- [Architecture — Plan Lifecycle](../architecture.md#plan-lifecycle)
|
|
|
|
|
- [ADR-015 Sandbox & Checkpoint](../adr/ADR-015-sandbox-and-checkpoint.md)
|
|
|
|
|
- [ADR-038 Cross-Mechanism Sandbox Coordination](../adr/ADR-038-cross-mechanism-sandbox-coordination.md)
|
|
|
|
|
- [`cleveragents.infrastructure.sandbox`](../api/core.md)
|
|
|
|
|
### Before `commit()` (ACTIVE → ROLLED_BACK)
|
|
|
|
|
|
|
|
|
|
Resets the worktree branch to the base commit and runs `git clean -fd` to
|
|
|
|
|
remove untracked files. The original branch is untouched.
|
|
|
|
|
|
|
|
|
|
### After `commit()` (COMMITTED → ROLLED_BACK)
|
|
|
|
|
|
|
|
|
|
1. Resets the **original branch** to the pre-merge commit (`git reset --hard <pre_merge_sha>`), undoing the merge.
|
|
|
|
|
2. Resets the **worktree branch** to the base commit.
|
|
|
|
|
|
|
|
|
|
> **Warning:** Rolling back from `COMMITTED` executes `git reset --hard`
|
|
|
|
|
> on the original branch. If other git worktrees or external processes
|
|
|
|
|
> track the same branch, they will be affected.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Integration with Plan Lifecycle
|
|
|
|
|
|
|
|
|
|
`PlanLifecycleService` creates a `GitWorktreeSandbox` automatically when
|
|
|
|
|
the plan's linked resource is a `git-checkout` type:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
# Pseudocode — see PlanLifecycleService.execute_plan()
|
|
|
|
|
if resource.type == "git-checkout":
|
|
|
|
|
sandbox = GitWorktreeSandbox(resource.id, resource.path)
|
|
|
|
|
ctx = sandbox.create(plan.id)
|
|
|
|
|
# Actor writes to ctx.sandbox_path
|
|
|
|
|
result = sandbox.commit(f"plan({plan.id}): apply changes")
|
|
|
|
|
sandbox.cleanup()
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The Apply Summary printed to the terminal includes:
|
|
|
|
|
|
|
|
|
|
- Plan ID
|
|
|
|
|
- Artifacts (added/changed/deleted file counts)
|
|
|
|
|
- Insertions and deletions (from `git diff --stat`)
|
|
|
|
|
- Project name and timestamp
|
|
|
|
|
- Sandbox cleanup confirmation
|
|
|
|
|
- `✓ OK Changes applied` footer
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Apply Summary Output
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
╭─ Apply Summary ──────────────────────────────────────────────────────────╮
|
|
|
|
|
│ Plan ID : 01JXYZ │
|
|
|
|
|
│ Project : my-project │
|
|
|
|
|
│ Timestamp : 2026-04-09 14:33:34 │
|
|
|
|
|
│ Artifacts : 3 added, 2 changed, 0 deleted │
|
|
|
|
|
│ Diff : +127 / -43 │
|
|
|
|
|
╰───────────────────────────────────────────────────────────────────────────╯
|
|
|
|
|
|
|
|
|
|
╭─ Sandbox Cleanup ─────────────────────────────────────────────────────────╮
|
|
|
|
|
│ ✓ Worktree removed │
|
|
|
|
|
│ ✓ Branch cleveragents/plan-01JXYZ deleted │
|
|
|
|
|
╰───────────────────────────────────────────────────────────────────────────╯
|
|
|
|
|
|
|
|
|
|
✓ OK Changes applied
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Testing
|
|
|
|
|
|
|
|
|
|
The git worktree sandbox is covered by BDD scenarios in the following
|
|
|
|
|
Behave feature files:
|
|
|
|
|
|
|
|
|
|
- `features/git_worktree_sandbox.feature` — core lifecycle, path resolution,
|
|
|
|
|
commit, rollback, cleanup, and error handling
|
|
|
|
|
- `features/git_worktree_apply.feature` — end-to-end apply flow via
|
|
|
|
|
`PlanLifecycleService`
|
|
|
|
|
- `features/git_worktree_coverage_boost.feature` — additional edge-case
|
|
|
|
|
coverage for branch naming, timeout handling, and concurrent sandboxes
|
|
|
|
|
|
|
|
|
|
Example scenario from `features/git_worktree_sandbox.feature`:
|
|
|
|
|
|
|
|
|
|
```gherkin
|
|
|
|
|
Scenario: Create a git worktree sandbox
|
|
|
|
|
When a gwt sandbox is created for plan "plan-001"
|
|
|
|
|
Then the gwt sandbox should be in the "created" state
|
|
|
|
|
And the gwt sandbox context should reference plan "plan-001"
|
|
|
|
|
And the gwt sandbox context should have strategy metadata "git_worktree"
|
|
|
|
|
And the gwt sandbox worktree path should exist
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Run the BDD suite with:
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
nox -s unit_tests
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Related Documentation
|
|
|
|
|
|
|
|
|
|
- [Architecture Overview](../architecture.md) — plan lifecycle and sandbox role
|
|
|
|
|
- [ADR-015 Sandbox and Checkpoint](../adr/ADR-015-sandbox-and-checkpoint.md) — design rationale
|
|
|
|
|
- [Shell Safety Module](shell-safety.md) — another infrastructure safety component
|
|
|
|
|
- [Invariant Reconciliation Module](invariant-reconciliation.md) — phase-transition gating
|
|
|
|
|