Skip to content

Commit a1f247e

Browse files
committed
Replace git worktrees with local clones for session isolation
Session isolation now uses git clone --local instead of worktrees. Clones stored in ~/.clave/repos/ instead of project .clave/wt/. Renamed CreateWorktree to CloneRepo and updated GitManager methods: createWorktree → cloneLocal, removeWorktree → removeClone, mergeAndCleanWorktree → mergeAndCleanClone. Updated SessionContext properties: worktree_path → clone_path, worktree_branch → clone_branch.
1 parent 4a57bca commit a1f247e

File tree

11 files changed

+121
-106
lines changed

11 files changed

+121
-106
lines changed

CLAUDE.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
44

55
## Project Overview
66

7-
Clave is a Laravel Zero CLI that spins up ephemeral Ubuntu VMs via [Tart](https://tart.run/) for isolated Claude Code sessions against Laravel projects. Run `clave` from within a Laravel project's git repo — it handles worktree creation, VM lifecycle, networking, and teardown automatically. Multiple simultaneous sessions work naturally with unique worktrees, VMs, and ports.
7+
Clave is a Laravel Zero CLI that spins up ephemeral Ubuntu VMs via [Tart](https://tart.run/) for isolated Claude Code sessions against Laravel projects. Run `clave` from within a Laravel project's git repo — it handles repo cloning, VM lifecycle, networking, and teardown automatically. Multiple simultaneous sessions work naturally with unique clones, VMs, and ports.
88

99
## Commands
1010

@@ -36,7 +36,7 @@ php clave app:build
3636

3737
A `SessionContext` DTO flows through pipeline stages in `DefaultCommand`:
3838

39-
`CreateWorktree → CloneVm → BootVm → RunClaudeCode`
39+
`CloneRepo → CloneVm → BootVm → RunClaudeCode`
4040

4141
Each stage populates fields on `SessionContext` and calls `$next()`. Teardown runs via `SessionTeardown` in both a `finally` block and SIGINT/SIGTERM signal handler.
4242

@@ -45,17 +45,17 @@ Future stages (not yet implemented): `DiscoverGateway → CreateSshTunnel → Co
4545
### Key Services (all registered as singletons)
4646

4747
- **TartManager** — wraps the `tart` CLI for VM clone/run/stop/delete/ip
48-
- **GitManager**worktree create/remove/merge, .gitignore management
48+
- **GitManager**local clone/remove/merge for session isolation
4949
- **SshExecutor** — SSH command execution, tunnels, interactive TTY sessions
5050
- **HerdManager** — Herd Pro proxy/unproxy (future use)
5151
- **SessionTeardown** — reverses each pipeline stage on exit
5252
- **ProvisioningPipeline** — generates bash provisioning script for base VM setup
5353

5454
### DTOs
5555

56-
- **SessionContext** — mutable pipeline state (session_id, vm_name, vm_ip, worktree_path, etc.)
56+
- **SessionContext** — mutable pipeline state (session_id, vm_name, vm_ip, clone_path, etc.)
5757
- **ServiceConfig** — readonly database/Redis config
58-
- **OnExit** — enum (Keep, Merge, Discard) for worktree handling on session end
58+
- **OnExit** — enum (Keep, Merge, Discard) for clone handling on session end
5959

6060
## Conventions
6161

PLAN.md

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ A Laravel Zero CLI that spins up ephemeral Ubuntu VMs via Tart for isolated Clau
1515
- [x] `ProvisionCommand` + `ProvisioningPipeline` — build base image (PHP 8.4, nginx, Node 22, Claude Code)
1616
- [x] `AuthManager` + `AuthCommand` — API key + OAuth token support with local storage
1717
- [x] Preflight pipeline: `ValidateProject → GetGitBranch → EnsureVmExists → CheckClaudeAuthentication → SaveSession`
18-
- [x] Session pipeline: `CreateWorktree → CloneVm → BootVm → RunClaudeCode`
18+
- [x] Session pipeline: `CloneRepo → CloneVm → BootVm → RunClaudeCode`
1919
- [x] `SessionTeardown` — VM stop/delete, worktree prompt, Herd unproxy, tunnel kill, session record cleanup
2020
- [x] Signal handling via `$this->trap()` (SIGINT/SIGTERM)
2121
- [x] Sessions SQLite table + `Session` model
@@ -208,8 +208,8 @@ class SessionContext
208208
public ?string $base_branch = null,
209209
public ?string $vm_name = null,
210210
public ?string $vm_ip = null,
211-
public ?string $worktree_path = null,
212-
public ?string $worktree_branch = null,
211+
public ?string $clone_path = null,
212+
public ?string $clone_branch = null,
213213
public ?string $proxy_name = null,
214214
public ?int $tunnel_port = null,
215215
public ?InvokedProcess $tunnel_process = null,
@@ -296,31 +296,31 @@ ValidateProject → GetGitBranch → EnsureVmExists → CheckClaudeAuthenticatio
296296
Label: "Starting session..."
297297

298298
```
299-
CreateWorktree → CloneVm → BootVm → RunClaudeCode
299+
CloneRepo → CloneVm → BootVm → RunClaudeCode
300300
```
301301

302302
Future stages to be inserted between `BootVm` and `RunClaudeCode`:
303303
```
304304
DiscoverGateway → CreateSshTunnel → ConfigureHerdProxy → BootstrapLaravel
305305
```
306306

307-
#### CreateWorktree
307+
#### CloneRepo
308308

309309
```php
310-
class CreateWorktree implements Step, ProgressAware
310+
class CloneRepo implements Step, ProgressAware
311311
{
312312
use AcceptsProgress;
313313

314314
public function handle(SessionContext $context, Closure $next): mixed
315315
{
316316
$branch = "clave/s-{$context->session_id}";
317-
$worktree_path = "{$context->project_dir}/.clave/wt/s-{$context->session_id}";
317+
$clone_path = "{$context->project_dir}/.clave/wt/s-{$context->session_id}";
318318

319319
$this->git->ensureIgnored($context->project_dir, '.clave/');
320-
$this->git->createWorktree($context->project_dir, $worktree_path, $branch);
320+
$this->git->cloneLocal($context->project_dir, $clone_path, $branch);
321321

322-
$context->worktree_path = $worktree_path;
323-
$context->worktree_branch = $branch;
322+
$context->clone_path = $clone_path;
323+
$context->clone_branch = $branch;
324324

325325
return $next($context);
326326
}
@@ -364,7 +364,7 @@ class BootVm implements Step, ProgressAware
364364

365365
public function handle(SessionContext $context, Closure $next): mixed
366366
{
367-
$mount_path = $context->worktree_path ?? $context->project_dir;
367+
$mount_path = $context->clone_path ?? $context->project_dir;
368368

369369
$this->tart->runBackground($context->vm_name, [
370370
'project' => $mount_path,
@@ -495,11 +495,11 @@ class SessionTeardown
495495
$this->killTunnel($context); // Stop SSH tunnel if running
496496
$this->stopVm($context); // tart stop
497497
$this->deleteVm($context); // tart delete
498-
$this->handleWorktree($context); // Prompt: keep/merge/discard
498+
$this->handleClone($context); // Prompt: keep/merge/discard
499499
$this->deleteSession($context); // Remove Session record
500500
}
501501

502-
protected function handleWorktree(SessionContext $context): void
502+
protected function handleClone(SessionContext $context): void
503503
{
504504
$action = $context->on_exit;
505505

@@ -511,8 +511,8 @@ class SessionTeardown
511511
));
512512

513513
match ($action) {
514-
OnExit::Merge => $this->git->mergeAndCleanWorktree(...),
515-
OnExit::Discard => $this->git->removeWorktree(...),
514+
OnExit::Merge => $this->git->mergeAndCleanClone(...),
515+
OnExit::Discard => $this->git->removeClone(...),
516516
default => null, // keep
517517
};
518518
}
@@ -697,9 +697,9 @@ class GitManager
697697
{
698698
public function isRepo(string $path): bool;
699699
public function currentBranch(string $path): string;
700-
public function createWorktree(string $repo_path, string $worktree_path, string $branch): void;
701-
public function removeWorktree(string $repo_path, string $worktree_path): void;
702-
public function mergeAndCleanWorktree(string $repo_path, string $worktree_path, string $branch, string $target): void;
700+
public function cloneLocal(string $repo_path, string $clone_path, string $branch): void;
701+
public function removeClone(string $repo_path, string $clone_path): void;
702+
public function mergeAndCleanClone(string $repo_path, string $clone_path, string $branch, string $target): void;
703703
public function ensureIgnored(string $repo_path, string $pattern): void;
704704
}
705705
```
@@ -820,7 +820,7 @@ clave/
820820
│ │ │ ├── EnsureVmExists.php
821821
│ │ │ ├── CheckClaudeAuthentication.php
822822
│ │ │ ├── SaveSession.php
823-
│ │ │ ├── CreateWorktree.php
823+
│ │ │ ├── CloneRepo.php
824824
│ │ │ ├── CloneVm.php
825825
│ │ │ ├── BootVm.php
826826
│ │ │ └── RunClaudeCode.php
@@ -873,7 +873,7 @@ Proves the core lifecycle: boot a VM, get a Claude Code session, tear it down.
873873
6. `ProvisionCommand` + `ProvisioningPipeline` — build base image (PHP 8.4/nginx/Node 22/Claude Code)
874874
7. `SessionPipeline` base class with progress tracking
875875
8. Preflight pipeline: `ValidateProject → GetGitBranch → EnsureVmExists → CheckClaudeAuthentication → SaveSession`
876-
9. Session pipeline: `CreateWorktree → CloneVm → BootVm → RunClaudeCode`
876+
9. Session pipeline: `CloneRepo → CloneVm → BootVm → RunClaudeCode`
877877
10. `SessionTeardown` — cleanup VM, worktree prompt, session record
878878
11. Signal handling via `$this->trap()` (SIGINT/SIGTERM)
879879
12. Sessions SQLite table + model

app/Data/OnExit.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ enum OnExit: string
1616
public function label(): string
1717
{
1818
return match ($this) {
19-
OnExit::Keep => 'Keep worktree',
19+
OnExit::Keep => 'Keep clone',
2020
OnExit::Merge => 'Merge and clean up',
2121
OnExit::Discard => 'Discard changes',
2222
default => Str::headline($this->name),

app/Data/SessionContext.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ class SessionContext
1515

1616
public ?string $vm_ip = null;
1717

18-
public ?string $worktree_path = null;
18+
public ?string $clone_path = null;
1919

20-
public ?string $worktree_branch = null;
20+
public ?string $clone_branch = null;
2121

2222
public ?string $proxy_name = null;
2323

app/Pipelines/ClaudeCodePipeline.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
use App\Pipelines\Steps\BootVm;
66
use App\Pipelines\Steps\CloneVm;
7-
use App\Pipelines\Steps\CreateWorktree;
7+
use App\Pipelines\Steps\CloneRepo;
88
use App\Pipelines\Steps\RunClaudeCode;
99

1010
class ClaudeCodePipeline extends SessionPipeline
@@ -17,7 +17,7 @@ protected function label(): string
1717
protected function steps(): array
1818
{
1919
return [
20-
CreateWorktree::class,
20+
CloneRepo::class,
2121
CloneVm::class,
2222
BootVm::class,
2323
RunClaudeCode::class,

app/Pipelines/Steps/BootVm.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public function __construct(
2222

2323
public function handle(SessionContext $context, Closure $next): mixed
2424
{
25-
$mount_path = $context->worktree_path ?? $context->project_dir;
25+
$mount_path = $context->clone_path ?? $context->project_dir;
2626

2727
$this->hint("Booting VM '{$context->vm_name}'...");
2828
$this->tart->runBackground($context->vm_name, [$mount_path]);

app/Pipelines/Steps/CloneRepo.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Pipelines\Steps;
4+
5+
use App\Data\SessionContext;
6+
use App\Support\GitManager;
7+
use Closure;
8+
9+
class CloneRepo implements Step, ProgressAware
10+
{
11+
use AcceptsProgress;
12+
13+
public function __construct(protected GitManager $git)
14+
{
15+
}
16+
17+
public function handle(SessionContext $context, Closure $next): mixed
18+
{
19+
$clone_branch = "clave/s-{$context->session_id}";
20+
$clone_path = $this->cloneBasePath().'/s-'.$context->session_id;
21+
22+
$this->hint('Cloning repo for session...');
23+
$this->git->cloneLocal($context->project_dir, $clone_path, $context->base_branch, $clone_branch);
24+
25+
$context->clone_path = $clone_path;
26+
$context->clone_branch = $clone_branch;
27+
28+
return $next($context);
29+
}
30+
31+
protected function cloneBasePath(): string
32+
{
33+
return ($_SERVER['HOME'] ?? getenv('HOME')).'/.clave/repos';
34+
}
35+
}

app/Pipelines/Steps/CreateWorktree.php

Lines changed: 0 additions & 32 deletions
This file was deleted.

app/Support/GitManager.php

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -23,52 +23,65 @@ public function currentBranch(string $path): string
2323
);
2424
}
2525

26-
public function createWorktree(string $repo_path, string $worktree_path, string $branch): mixed
26+
public function cloneLocal(string $repo_path, string $clone_path, string $base_branch, string $clone_branch): mixed
2727
{
28-
$escaped_branch = escapeshellarg($branch);
29-
$escaped_path = escapeshellarg($worktree_path);
28+
$escaped_path = escapeshellarg($clone_path);
29+
$escaped_base = escapeshellarg($base_branch);
30+
$escaped_clone = escapeshellarg($clone_branch);
31+
32+
Process::path($repo_path)
33+
->run("git clone --local --branch {$escaped_base} . {$escaped_path}")
34+
->throw();
3035

31-
return Process::path($repo_path)
32-
->run("git worktree add -b {$escaped_branch} {$escaped_path}")
36+
return Process::path($clone_path)
37+
->run("git checkout -b {$escaped_clone}")
3338
->throw();
3439
}
3540

36-
public function removeWorktree(string $repo_path, string $worktree_path): mixed
41+
public function removeClone(string $clone_path): void
3742
{
38-
$escaped_path = escapeshellarg($worktree_path);
43+
if (is_dir($clone_path)) {
44+
Process::run('rm -rf '.escapeshellarg($clone_path));
45+
}
46+
}
47+
48+
public function commitAllChanges(string $clone_path, string $message): bool
49+
{
50+
Process::path($clone_path)->run('git add -A');
51+
52+
$status = Process::path($clone_path)->run('git status --porcelain');
53+
54+
if (trim($status->output()) === '') {
55+
return false;
56+
}
3957

40-
return Process::path($repo_path)
41-
->run("git worktree remove --force {$escaped_path}");
58+
Process::path($clone_path)
59+
->run('git commit -m '.escapeshellarg($message))
60+
->throw();
61+
62+
return true;
4263
}
4364

44-
public function mergeAndCleanWorktree(string $repo_path, string $worktree_path, string $branch, string $base_branch): void
65+
public function mergeAndCleanClone(string $repo_path, string $clone_path, string $clone_branch, string $base_branch): void
4566
{
46-
$escaped_branch = escapeshellarg($branch);
4767
$escaped_base = escapeshellarg($base_branch);
68+
$escaped_clone_path = escapeshellarg($clone_path);
69+
$escaped_clone_branch = escapeshellarg($clone_branch);
70+
71+
$this->commitAllChanges($clone_path, 'WIP: auto-commit from clave session');
4872

4973
Process::path($repo_path)
50-
->run("git checkout {$escaped_base}")
74+
->run("git fetch {$escaped_clone_path} {$escaped_clone_branch}")
5175
->throw();
5276

5377
Process::path($repo_path)
54-
->run("git merge {$escaped_branch}")
78+
->run("git checkout {$escaped_base}")
5579
->throw();
5680

57-
$this->removeWorktree($repo_path, $worktree_path);
58-
5981
Process::path($repo_path)
60-
->run("git branch -d {$escaped_branch}");
61-
}
62-
63-
public function ensureIgnored(string $repo_path, string $pattern): void
64-
{
65-
$gitignore_path = $repo_path.'/.gitignore';
66-
$contents = file_exists($gitignore_path) ? file_get_contents($gitignore_path) : '';
67-
68-
if (str_contains($contents, $pattern)) {
69-
return;
70-
}
82+
->run('git merge FETCH_HEAD')
83+
->throw();
7184

72-
file_put_contents($gitignore_path, rtrim($contents)."\n{$pattern}\n");
85+
$this->removeClone($clone_path);
7386
}
7487
}

0 commit comments

Comments
 (0)