Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
# Ignore built binary
arbor
arbor.exe

# Ignore local workspace state (per-workspace runtime state, never committed)
.arbor.local
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,28 @@ arbor remove feature-my-feature # When done
| CLI commands | `internal/cli/` |
| Config management | `internal/config/` |
| Git operations | `internal/git/` |
| **Workspace abstraction** | `internal/workspace/` |
| Scaffold system | `internal/scaffold/` |
| Presets | `internal/presets/` |
| Utilities | `internal/utils/` |
| Entry point | `cmd/arbor/main.go` |
| Tests | Alongside implementation files (`*_test.go`) |
| Deployment plans | `.ai/plans/` |

### Workspace Architecture

Arbor supports two workspace modes, abstracted by `internal/workspace/`:

| | Worktree Mode | CoW Mode |
|-|---------------|----------|
| Project marker | `.bare/` | `.arbor/` |
| Main workspace | git worktree | normal clone |
| Feature workspaces | git worktrees | CoW clones (`cp -c` / `cp --reflink`) |
| Config field | `workspace_mode: worktree` (or absent) | `workspace_mode: cow` |
| Disk sharing | git object store | filesystem reflinks |

The `internal/workspace.Manager` type provides a mode-agnostic API used by all CLI commands. `FindProjectRoot()` searches for `.bare/` (worktree mode) or `.arbor/` (CoW mode) in parent directories.

### Config Files

| Config | Location | Purpose | Versioned? |
Expand Down Expand Up @@ -194,7 +209,7 @@ This approach ensures:

## Current Phase

**Phase 5: Distribution** - Complete
**Phase 6: Copy-on-Write Workspace Mode** - Complete

All phases 1-5 are complete. The project has:
- Core infrastructure (worktree management, config)
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.14.0] - 2026-04-01

### Added

- **Copy-on-write (CoW) workspace mode** - New alternative to git worktrees that uses filesystem-level cloning. On APFS (macOS) and Btrfs/XFS (Linux), new workspaces are created instantly and share disk blocks with the source until files are actually modified. Falls back to a regular copy on unsupported filesystems with a clear warning.
- **`workspace_mode` config field** - Set `workspace_mode: cow` or `workspace_mode: worktree` in `arbor.yaml` to declare the project's workspace strategy. Existing projects without this field continue using worktree mode unchanged.
- **`arbor init --mode` flag** - Choose workspace mode at init time with `--mode cow` or `--mode worktree`. When running interactively, Arbor prompts for the preferred mode with a description of each option.
- **`arbor switch` command** - Bidirectional conversion between workspace modes (`arbor switch cow` or `arbor switch worktree`). Lists feature workspaces that will be removed, prompts for confirmation, then converts the full project layout including `.bare`/`.arbor` directories and `arbor.yaml`. Supports `--force` to skip confirmation.
- **`internal/workspace` package** - New mode-agnostic abstraction layer (`Manager`, `FindProjectRoot`, `CreateWorkspace`, `RemoveWorkspace`, `ListWorkspaces`, `ListWorkspacesDetailed`) used by all CLI commands. Both modes share the same command surface.
- **CoW filesystem detection** - `workspace.DetectCowSupport()` probes the filesystem at init/switch time and warns if native CoW is unavailable, rather than failing silently.

### Changed

- **All commands are now workspace-mode-aware** - `arbor work`, `arbor remove`, `arbor list`, `arbor prune`, `arbor destroy`, `arbor sync`, and `arbor pull-config` work identically in both worktree and CoW mode.
- **Project detection supports both markers** - `FindProjectRoot()` searches parent directories for `.bare/` (worktree mode) or `.arbor/` (CoW mode). `.bare/` takes precedence when both are present for backward compatibility.
- **`arbor sync` uses workspace git directory** - In CoW mode (no bare repo), sync operations run against the current workspace's `.git` directory rather than a shared bare repo.

## [0.13.2] - 2026-03-03

### Changed
Expand Down Expand Up @@ -385,6 +402,7 @@ No changes in this release.
[0.4.2]: https://github.com/artisanexperiences/arbor/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/artisanexperiences/arbor/compare/v0.4.0...v0.4.1
[0.3.1]: https://github.com/artisanexperiences/arbor/compare/v0.3.0...v0.3.1
[0.14.0]: https://github.com/artisanexperiences/arbor/compare/v0.13.2...v0.14.0
[0.13.2]: https://github.com/artisanexperiences/arbor/compare/v0.13.1...v0.13.2
[0.13.1]: https://github.com/artisanexperiences/arbor/compare/v0.13.0...v0.13.1
[0.13.0]: https://github.com/artisanexperiences/arbor/compare/v0.12.0...v0.13.0
Expand Down
112 changes: 100 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,23 @@ go build -ldflags "-X main.Version=$VERSION -X main.Commit=$COMMIT -X main.Build
# Check arbor version
arbor version

# Initialise a new Laravel project
# Initialise a new Laravel project (choose worktree or copy-on-write mode interactively)
arbor init git@github.com:user/my-laravel-app.git

# Create a feature worktree
# Initialise with a specific workspace mode
arbor init git@github.com:user/my-laravel-app.git --mode cow
arbor init git@github.com:user/my-laravel-app.git --mode worktree

# Create a feature workspace
arbor work feature/user-auth

# Create a worktree from a specific base branch
# Create a workspace from a specific base branch
arbor work feature/user-auth -b develop

# Create a worktree without running scaffold steps
# Create a workspace without running scaffold steps
arbor work feature/user-auth --skip-scaffold

# Sync current worktree with upstream (defaults to main, uses rebase)
# Sync current workspace with upstream (defaults to main, uses rebase)
arbor sync

# Sync with a specific upstream branch
Expand All @@ -93,23 +97,27 @@ arbor sync --strategy merge
# Save sync settings to arbor.yaml for future use
arbor sync --upstream develop --strategy rebase --save

# List all worktrees with their status
# List all workspaces with their status
arbor list

# Remove a worktree when done
# Remove a workspace when done
arbor remove feature/user-auth

# Clean up merged worktrees
# Clean up merged workspaces
arbor prune

# Run scaffold steps on an existing worktree
# Switch workspace mode (worktree ↔ copy-on-write)
arbor switch cow
arbor switch worktree

# Run scaffold steps on an existing workspace
arbor scaffold main
arbor scaffold feature/user-auth

# Destroy the entire project (removes worktrees and bare repo)
# Destroy the entire project (removes all workspaces and project structure)
arbor destroy

# Pull updated config from the default branch worktree into the project root
# Pull updated config from the default branch workspace into the project root
arbor pull-config
```

Expand Down Expand Up @@ -194,6 +202,28 @@ The command resolves settings in this order:
- Detects and blocks if rebase or merge is already in progress
- Provides guidance when conflicts occur

### `arbor switch [MODE]`

Converts the project between **worktree** and **copy-on-write** modes. Feature workspaces are removed during the switch (they can be recreated with `arbor work <branch>`).

```bash
# Switch a worktree-mode project to CoW
arbor switch cow

# Switch a CoW project back to worktree mode
arbor switch worktree

# Skip confirmation prompts
arbor switch cow --force
```

**What happens during a switch:**
1. Lists any feature workspaces that will be removed
2. Prompts for confirmation (skip with `--force`)
3. Removes all feature workspaces
4. Converts the project structure (`.bare` ↔ `.arbor`)
5. Updates `arbor.yaml` with the new `workspace_mode`

### `arbor scaffold [PATH]`

Run scaffold steps for an existing worktree. This is useful when:
Expand Down Expand Up @@ -262,6 +292,64 @@ arbor scaffold main
arbor scaffold feature/my-feature
```

## Workspace Modes

Arbor supports two ways to manage parallel feature workspaces. Choose at `arbor init` time, or switch later with `arbor switch`.

### Worktree Mode (default)

The traditional mode. Uses a single bare git repository (`.bare/`) with linked git worktrees for each branch. The object store is shared, so additional workspaces cost only the working-tree files.

```
my-project/
.bare/ # Shared bare git repo
arbor.yaml
main/ # Default branch worktree
feature-foo/ # Feature branch worktree
```

### Copy-on-Write (CoW) Mode

Each workspace is a full, independent git clone. On filesystems that support copy-on-write (APFS on macOS, Btrfs/XFS on Linux), new workspaces are created using filesystem-level cloning — they take zero additional disk space until files are actually modified.

```
my-project/
.arbor/ # CoW project marker
arbor.yaml
main/ # Normal git clone (default branch)
feature-foo/ # CoW clone of main
```

**Benefits:**
- Near-instant workspace creation (cloning is a filesystem operation)
- Shared disk blocks for unmodified files (`vendor/`, `node_modules/`, source code)
- Fully independent git repos — no shared `.bare` lock contention
- Compatible with all git tools and IDEs (normal `.git/` directories)

**Requirements for full CoW benefits:**
- macOS with APFS filesystem (default since macOS High Sierra)
- Linux with Btrfs or XFS (with reflink support enabled)
- Falls back to regular copy on unsupported filesystems (works, just without disk savings)

```bash
# Choose CoW at init time
arbor init git@github.com:user/repo.git --mode cow

# Or switch an existing project
arbor switch cow # worktree → CoW
arbor switch worktree # CoW → worktree
```

> **Note:** `arbor switch` removes feature workspaces that cannot be migrated in-place. Recreate them with `arbor work <branch>` after switching.

### `workspace_mode` Config

The mode is stored in `arbor.yaml` at the project root:

```yaml
workspace_mode: cow # or "worktree" (default when absent)
```

## Configuration

Arbor uses a three-tier configuration system to separate team configuration from local state.
Expand All @@ -270,7 +358,7 @@ Arbor uses a three-tier configuration system to separate team configuration from

#### 1. Project Config (`<project-root>/arbor.yaml`)

Located at the project root (alongside `.bare/`), this file contains:
Located at the project root (alongside `.bare/` or `.arbor/`), this file contains:
- Scaffold steps and cleanup steps
- Preset selection
- Tool configurations
Expand Down
72 changes: 50 additions & 22 deletions internal/cli/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,21 @@ import (
"github.com/artisanexperiences/arbor/internal/presets"
"github.com/artisanexperiences/arbor/internal/scaffold"
"github.com/artisanexperiences/arbor/internal/scaffold/steps"
"github.com/artisanexperiences/arbor/internal/workspace"
)

type ProjectContext struct {
CWD string
Mode string // workspace.ModeWorktree or workspace.ModeCow
BarePath string
ProjectPath string
Config *config.Config
DefaultBranch string

presetManager *presets.Manager
scaffoldManager *scaffold.ScaffoldManager
managersInit sync.Once
presetManager *presets.Manager
scaffoldManager *scaffold.ScaffoldManager
workspaceManager *workspace.Manager
managersInit sync.Once
}

func OpenProjectFromCWD() (*ProjectContext, error) {
Expand All @@ -32,62 +35,68 @@ func OpenProjectFromCWD() (*ProjectContext, error) {
return nil, fmt.Errorf("getting current directory: %w", err)
}

barePath, err := git.FindBarePath(cwd)
info, err := workspace.FindProjectRoot(cwd)
if err != nil {
return nil, fmt.Errorf("finding bare repository: %w", err)
return nil, fmt.Errorf("finding arbor project: %w", err)
}

projectPath := filepath.Dir(barePath)
projectPath := info.ProjectRoot
cfg, err := config.LoadProject(projectPath)
if err != nil {
return nil, fmt.Errorf("loading project config: %w", err)
}

defaultBranch := cfg.DefaultBranch
if defaultBranch == "" {
defaultBranch, _ = git.GetDefaultBranch(barePath)
switch info.Mode {
case workspace.ModeWorktree:
defaultBranch, _ = git.GetDefaultBranch(info.BarePath)
case workspace.ModeCow:
// Fall back to "main"; cfg.DefaultBranch from arbor.yaml takes precedence above.
}
if defaultBranch == "" {
defaultBranch = config.DefaultBranch
}
}

return &ProjectContext{
CWD: cwd,
BarePath: barePath,
Mode: info.Mode,
BarePath: info.BarePath,
ProjectPath: projectPath,
Config: cfg,
DefaultBranch: defaultBranch,
}, nil
}

func (pc *ProjectContext) IsInWorktree() bool {
// Check if .bare exists in parent hierarchy
barePath, err := git.FindBarePath(pc.CWD)
cwdAbs, err := filepath.Abs(pc.CWD)
if err != nil {
return false
}

// Check if CWD is inside a worktree directory (not the project root)
projectPath := filepath.Dir(barePath)

// If CWD equals project path, we're in the project root, not a worktree
cwdAbs, err := filepath.Abs(pc.CWD)
projectAbs, err := filepath.Abs(pc.ProjectPath)
if err != nil {
return false
}

projectAbs, err := filepath.Abs(projectPath)
if err != nil {
// At project root — not a workspace
if cwdAbs == projectAbs {
return false
}

// If we're in the project root or its direct child .bare, we're not in a worktree
if cwdAbs == projectAbs || cwdAbs == filepath.Join(projectAbs, ".bare") {
return false
// Inside the mode-specific internal directory — not a workspace
switch pc.Mode {
case workspace.ModeWorktree:
if cwdAbs == filepath.Join(projectAbs, ".bare") {
return false
}
case workspace.ModeCow:
if cwdAbs == filepath.Join(projectAbs, ".arbor") {
return false
}
}

// We're somewhere under the project root but not the root itself
// Check if we're actually in a worktree by seeing if CWD is within a worktree path
return true
}

Expand All @@ -112,6 +121,13 @@ func (pc *ProjectContext) ScaffoldManager() *scaffold.ScaffoldManager {
return pc.scaffoldManager
}

func (pc *ProjectContext) WorkspaceManager() *workspace.Manager {
pc.managersInit.Do(func() {
pc.initManagers()
})
return pc.workspaceManager
}

func (pc *ProjectContext) initManagers() {
// Create explicit step registry with default steps
stepRegistry := steps.NewRegistry()
Expand All @@ -121,4 +137,16 @@ func (pc *ProjectContext) initManagers() {
pc.presetManager = presets.NewManager()
pc.scaffoldManager = scaffold.NewScaffoldManagerWithRegistry(stepRegistry)
presets.RegisterAllWithScaffold(pc.scaffoldManager)

// Determine workspace mode from config, defaulting to worktree
wsMode := pc.Config.WorkspaceMode
if wsMode == "" {
wsMode = workspace.ModeWorktree
}

pc.workspaceManager = workspace.NewManager(&workspace.ProjectInfo{
Mode: wsMode,
ProjectRoot: pc.ProjectPath,
BarePath: pc.BarePath,
}, pc.DefaultBranch)
}
Loading
Loading