diff --git a/.gitignore b/.gitignore index e0e6d8f..caefb2a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ # Ignore built binary arbor arbor.exe + +# Ignore local workspace state (per-workspace runtime state, never committed) +.arbor.local diff --git a/AGENTS.md b/AGENTS.md index 0926c92..5c14725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ 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/` | @@ -44,6 +45,20 @@ arbor remove feature-my-feature # When done | 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? | @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf160f8..aebe87b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 99caea5..9a1f97f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 ``` @@ -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 `). + +```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: @@ -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 ` 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. @@ -270,7 +358,7 @@ Arbor uses a three-tier configuration system to separate team configuration from #### 1. Project Config (`/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 diff --git a/internal/cli/context.go b/internal/cli/context.go index ef310cf..465f8a9 100644 --- a/internal/cli/context.go +++ b/internal/cli/context.go @@ -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) { @@ -32,12 +35,12 @@ 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) @@ -45,7 +48,12 @@ func OpenProjectFromCWD() (*ProjectContext, error) { 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 } @@ -53,7 +61,8 @@ func OpenProjectFromCWD() (*ProjectContext, error) { return &ProjectContext{ CWD: cwd, - BarePath: barePath, + Mode: info.Mode, + BarePath: info.BarePath, ProjectPath: projectPath, Config: cfg, DefaultBranch: defaultBranch, @@ -61,33 +70,33 @@ func OpenProjectFromCWD() (*ProjectContext, error) { } 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 } @@ -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() @@ -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) } diff --git a/internal/cli/context_cow_test.go b/internal/cli/context_cow_test.go new file mode 100644 index 0000000..9b5b93e --- /dev/null +++ b/internal/cli/context_cow_test.go @@ -0,0 +1,243 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/artisanexperiences/arbor/internal/config" + "github.com/artisanexperiences/arbor/internal/workspace" +) + +// createTestCowProject creates a CoW-mode project in tmpDir: +// +// / +// .arbor/metadata.yaml +// arbor.yaml +// main/ (normal clone) +// +// Returns (projectRoot, mainWorkspacePath). +func createTestCowProjectForCLI(t *testing.T) (projectRoot, mainPath string) { + t.Helper() + + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + projectRoot = filepath.Join(tmpDir, "project") + mainPath = filepath.Join(projectRoot, "main") + + require.NoError(t, os.MkdirAll(sourceDir, 0755)) + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("test"), 0644)) + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "Initial commit"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + + // Clone as a normal repo + cmd := exec.Command("git", "clone", sourceDir, mainPath) + require.NoError(t, cmd.Run()) + + // Create .arbor/ marker + arborDir := filepath.Join(projectRoot, ".arbor") + require.NoError(t, os.MkdirAll(arborDir, 0755)) + metadata := "workspace_mode: cow\nremote_url: " + sourceDir + "\ndefault_branch: main\n" + require.NoError(t, os.WriteFile(filepath.Join(arborDir, "metadata.yaml"), []byte(metadata), 0644)) + + // Write arbor.yaml + cfg := &config.Config{ + SiteName: "test-project", + DefaultBranch: "main", + WorkspaceMode: "cow", + } + require.NoError(t, config.SaveProject(projectRoot, cfg)) + + return projectRoot, mainPath +} + +// --- OpenProjectFromCWD in CoW mode --- + +func TestOpenProjectFromCWD_CowMode_Success(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + assert.NotNil(t, pc) +} + +func TestOpenProjectFromCWD_CowMode_WorkspaceModeIsCow(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + assert.Equal(t, "cow", pc.Config.WorkspaceMode) +} + +func TestOpenProjectFromCWD_CowMode_BarePathIsEmpty(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + // CoW mode has no bare path + assert.Empty(t, pc.BarePath) +} + +func TestOpenProjectFromCWD_CowMode_ProjectPathIsCorrect(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + + expectedRoot, _ := filepath.EvalSymlinks(projectRoot) + actualRoot, _ := filepath.EvalSymlinks(pc.ProjectPath) + assert.Equal(t, expectedRoot, actualRoot) +} + +func TestOpenProjectFromCWD_CowMode_DefaultBranchIsMain(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + assert.Equal(t, "main", pc.DefaultBranch) +} + +// --- IsInWorktree in CoW mode --- + +func TestProjectContext_IsInWorktree_CowMode_InsideWorkspace(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + pc := &ProjectContext{ + Mode: workspace.ModeCow, + CWD: mainPath, + ProjectPath: projectRoot, + } + + // Should return true when inside a CoW workspace + assert.True(t, pc.IsInWorktree(), "IsInWorktree() should be true when inside a CoW workspace") +} + +func TestProjectContext_IsInWorktree_CowMode_AtProjectRoot(t *testing.T) { + projectRoot, _ := createTestCowProjectForCLI(t) + + pc := &ProjectContext{ + Mode: workspace.ModeCow, + CWD: projectRoot, + ProjectPath: projectRoot, + } + + // Should return false at the project root itself + assert.False(t, pc.IsInWorktree(), "IsInWorktree() should be false at project root") +} + +func TestProjectContext_IsInWorktree_CowMode_InsideArborDir(t *testing.T) { + projectRoot, _ := createTestCowProjectForCLI(t) + + pc := &ProjectContext{ + Mode: workspace.ModeCow, + CWD: filepath.Join(projectRoot, ".arbor"), + ProjectPath: projectRoot, + } + + // Should return false inside the .arbor marker directory + assert.False(t, pc.IsInWorktree(), "IsInWorktree() should be false inside .arbor directory") +} + +// --- WorkspaceManager --- + +func TestProjectContext_WorkspaceManager_CowMode(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + + wsm := pc.WorkspaceManager() + require.NotNil(t, wsm) + assert.Equal(t, "cow", wsm.Mode) +} + +func TestProjectContext_WorkspaceManager_WorktreeMode(t *testing.T) { + worktreePath, _ := createTestWorktree(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(worktreePath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + + wsm := pc.WorkspaceManager() + require.NotNil(t, wsm) + assert.Equal(t, "worktree", wsm.Mode) +} + +func TestProjectContext_WorkspaceManager_IsSingleton(t *testing.T) { + _, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + pc, err := OpenProjectFromCWD() + require.NoError(t, err) + + wsm1 := pc.WorkspaceManager() + wsm2 := pc.WorkspaceManager() + assert.Same(t, wsm1, wsm2, "WorkspaceManager() should return the same instance on multiple calls") +} diff --git a/internal/cli/context_test.go b/internal/cli/context_test.go index 3600ee3..3442015 100644 --- a/internal/cli/context_test.go +++ b/internal/cli/context_test.go @@ -5,6 +5,8 @@ import ( "os/exec" "path/filepath" "testing" + + "github.com/artisanexperiences/arbor/internal/workspace" ) func evalSymlinks(path string) string { @@ -143,8 +145,11 @@ func TestProjectContext_IsInWorktree(t *testing.T) { t.Run("returns false for non-worktree directory", func(t *testing.T) { tmpDir := t.TempDir() + // ProjectPath == CWD → at project root, not inside a workspace pc := &ProjectContext{ - CWD: tmpDir, + Mode: workspace.ModeWorktree, + CWD: tmpDir, + ProjectPath: tmpDir, } if pc.IsInWorktree() { @@ -153,10 +158,13 @@ func TestProjectContext_IsInWorktree(t *testing.T) { }) t.Run("returns true for worktree directory", func(t *testing.T) { - worktreePath, _ := createTestWorktree(t) + worktreePath, barePath := createTestWorktree(t) + projectRoot := filepath.Dir(barePath) pc := &ProjectContext{ - CWD: worktreePath, + Mode: workspace.ModeWorktree, + CWD: worktreePath, + ProjectPath: projectRoot, } if !pc.IsInWorktree() { @@ -169,7 +177,9 @@ func TestProjectContext_IsInWorktree(t *testing.T) { projectRoot := filepath.Dir(barePath) pc := &ProjectContext{ - CWD: projectRoot, + Mode: workspace.ModeWorktree, + CWD: projectRoot, + ProjectPath: projectRoot, } if pc.IsInWorktree() { @@ -185,9 +195,12 @@ func TestProjectContext_IsInWorktree(t *testing.T) { t.Run("returns false for .bare directory itself", func(t *testing.T) { _, barePath := createTestWorktree(t) + projectRoot := filepath.Dir(barePath) pc := &ProjectContext{ - CWD: barePath, + Mode: workspace.ModeWorktree, + CWD: barePath, + ProjectPath: projectRoot, } if pc.IsInWorktree() { @@ -199,8 +212,11 @@ func TestProjectContext_IsInWorktree(t *testing.T) { func TestProjectContext_MustBeInWorktree(t *testing.T) { tmpDir := t.TempDir() + // ProjectPath == CWD → at project root, not inside a workspace pc := &ProjectContext{ - CWD: tmpDir, + Mode: workspace.ModeWorktree, + CWD: tmpDir, + ProjectPath: tmpDir, } err := pc.MustBeInWorktree() @@ -208,9 +224,11 @@ func TestProjectContext_MustBeInWorktree(t *testing.T) { t.Error("MustBeInWorktree() = nil, want error for non-worktree directory") } - worktreePath, _ := createTestWorktree(t) + worktreePath, barePath := createTestWorktree(t) + projectRoot := filepath.Dir(barePath) pc.CWD = worktreePath + pc.ProjectPath = projectRoot err = pc.MustBeInWorktree() if err != nil { t.Errorf("MustBeInWorktree() = %v, want nil for worktree directory", err) diff --git a/internal/cli/destroy.go b/internal/cli/destroy.go index e77fb8e..20b093f 100644 --- a/internal/cli/destroy.go +++ b/internal/cli/destroy.go @@ -16,15 +16,16 @@ import ( "github.com/artisanexperiences/arbor/internal/scaffold/steps" "github.com/artisanexperiences/arbor/internal/scaffold/types" "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" ) var destroyCmd = &cobra.Command{ Use: "destroy [PROJECT_PATH]", Short: "Completely destroy an arbor project", Long: `Destroys an arbor project by: - 1. Finding all worktrees + 1. Finding all workspaces 2. Running cleanup for each (features first, then main) - 3. Removing all worktrees and branches + 3. Removing all workspaces and branches 4. Deleting the project folder This operation cannot be undone.`, @@ -68,16 +69,26 @@ This operation cannot be undone.`, return fmt.Errorf("not an arbor project: %w", err) } - barePath := filepath.Join(absProjectPath, ".bare") - if _, err := os.Stat(barePath); err != nil { - return fmt.Errorf("project missing .bare folder: %w", err) + // Determine mode and barePath + info, err := workspace.FindProjectRoot(absProjectPath) + if err != nil { + return fmt.Errorf("finding project root: %w", err) + } + + defaultBranch := cfg.DefaultBranch + if defaultBranch == "" { + defaultBranch = "main" } - worktrees, err := git.ListWorktrees(barePath) + wsManager := workspace.NewManager(info, defaultBranch) + + workspaces, err := wsManager.ListWorkspaces() if err != nil { - return fmt.Errorf("listing worktrees: %w", err) + return fmt.Errorf("listing workspaces: %w", err) } - worktrees = sortWorktreesForDestroy(worktrees, cfg.DefaultBranch) + + worktrees := workspacesToGitWorktrees(workspaces) + worktrees = sortWorktreesForDestroy(worktrees, defaultBranch) projectName := cfg.SiteName if projectName == "" { @@ -96,7 +107,7 @@ This operation cannot be undone.`, } if dryRun { - ui.PrintInfo(fmt.Sprintf("Would destroy project %q with %d worktrees:", projectName, len(worktrees))) + ui.PrintInfo(fmt.Sprintf("Would destroy project %q with %d workspace(s):", projectName, len(worktrees))) for _, wt := range worktrees { ui.PrintInfo(fmt.Sprintf(" - %s", wt.Branch)) } @@ -121,7 +132,7 @@ This operation cannot be undone.`, CI: os.Getenv("CI") != "", } for _, wt := range worktrees { - ui.PrintStep("Removing worktree: " + wt.Branch) + ui.PrintStep("Removing workspace: " + wt.Branch) wtPreset := preset if wtPreset == "" { @@ -130,10 +141,10 @@ This operation cannot be undone.`, if wtPreset != "" { siteName := filepath.Base(wt.Path) - if wt.Branch == cfg.DefaultBranch && cfg.SiteName != "" { + if wt.Branch == defaultBranch && cfg.SiteName != "" { siteName = cfg.SiteName } - if err := scaffoldManager.RunCleanup(wt.Path, wt.Branch, repoName, siteName, wtPreset, cfg, barePath, promptMode, false, verbose, quiet); err != nil { + if err := scaffoldManager.RunCleanup(wt.Path, wt.Branch, repoName, siteName, wtPreset, cfg, info.BarePath, promptMode, false, verbose, quiet); err != nil { ui.PrintWarning(fmt.Sprintf("Cleanup failed for %s: %v", wt.Branch, err)) } else { allCleanupFailed = false @@ -142,12 +153,15 @@ This operation cannot be undone.`, allCleanupFailed = false } - if err := git.RemoveWorktree(wt.Path, true); err != nil { - ui.PrintWarning(fmt.Sprintf("Failed to remove worktree %s: %v", wt.Branch, err)) + if err := wsManager.RemoveWorkspace(wt.Path, true); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to remove workspace %s: %v", wt.Branch, err)) } - if err := git.DeleteBranch(barePath, wt.Branch, true); err != nil { - ui.PrintWarning(fmt.Sprintf("Failed to delete branch %s: %v", wt.Branch, err)) + // Branch deletion: only in worktree mode (CoW clones own their branches) + if info.Mode == workspace.ModeWorktree { + if err := git.DeleteBranch(info.BarePath, wt.Branch, true); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to delete branch %s: %v", wt.Branch, err)) + } } ui.PrintSuccess(fmt.Sprintf("Removed %s", wt.Branch)) @@ -158,8 +172,11 @@ This operation cannot be undone.`, return fmt.Errorf("all cleanup operations failed") } - if err := git.PruneWorktrees(barePath); err != nil { - ui.PrintWarning(fmt.Sprintf("Failed to prune worktrees: %v", err)) + // Prune stale worktree refs: only applicable in worktree mode + if info.Mode == workspace.ModeWorktree { + if err := git.PruneWorktrees(info.BarePath); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to prune worktrees: %v", err)) + } } ui.PrintStep("Deleting project folder...") diff --git a/internal/cli/init.go b/internal/cli/init.go index 0ca67d7..92d6878 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -3,7 +3,9 @@ package cli import ( "fmt" "os" + "os/exec" "path/filepath" + "strings" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -53,6 +55,85 @@ Arguments: return fmt.Errorf("getting absolute path: %w", err) } + // Determine workspace mode (flag → interactive → default) + modeFlag := mustGetString(cmd, "mode") + wsMode, err := determineWorkspaceMode(modeFlag) + if err != nil { + return err + } + + // CoW init path — diverges from the traditional worktree flow + if wsMode == "cow" { + repoName := utils.SanitisePath(utils.ExtractRepoName(repo)) + siteName := utils.SanitisePath(filepath.Base(path)) + + defaultBranch, err := detectDefaultBranchFromRemote(repo) + if err != nil { + defaultBranch = "main" + } + + if err := performCowInit(repo, absPath, defaultBranch, siteName); err != nil { + return err + } + + mainPath := filepath.Join(absPath, defaultBranch) + cfg, _ := config.LoadProject(absPath) + if cfg == nil { + cfg = &config.Config{ + SiteName: siteName, + DefaultBranch: defaultBranch, + WorkspaceMode: "cow", + } + } + + verbose := mustGetBool(cmd, "verbose") + quiet := mustGetBool(cmd, "quiet") + skipScaffold := mustGetBool(cmd, "skip-scaffold") + + if !skipScaffold { + presetManager := presets.NewManager() + scaffoldManager := scaffold.NewScaffoldManager() + presets.RegisterAllWithScaffold(scaffoldManager) + + preset := mustGetString(cmd, "preset") + if preset == "" { + preset = presetManager.Detect(mainPath) + if preset != "" { + cfg.Preset = preset + } + } else { + cfg.Preset = preset + } + + // Persist the detected/specified preset so subsequent commands (e.g. arbor + // scaffold, arbor work) know which preset to use. + if cfg.Preset != "" { + if err := config.SaveProject(absPath, cfg); err != nil { + ui.PrintWarning(fmt.Sprintf("Could not save preset to config: %v", err)) + } + } + + promptMode := types.PromptMode{ + Interactive: ui.IsInteractive(), + NoInteractive: false, + Force: false, + CI: os.Getenv("CI") != "", + } + if err := scaffoldManager.RunScaffold(mainPath, defaultBranch, repoName, siteName, cfg.Preset, cfg, "", promptMode, false, verbose, quiet); err != nil { + ui.PrintErrorWithHint("Scaffold steps failed", err.Error()) + } + } + + if !quiet { + checkArborLocalGitignore(mainPath) + } + + ui.PrintDone("Repository ready (copy-on-write mode)!") + ui.PrintInfo(fmt.Sprintf("cd %s", absPath)) + ui.PrintInfo("arbor work feature/my-feature") + return nil + } + ghAvailable := isCommandAvailable("gh") barePath := filepath.Join(absPath, ".bare") @@ -178,6 +259,30 @@ func init() { initCmd.Flags().String("preset", "", "Project preset (laravel, php)") initCmd.Flags().Bool("skip-scaffold", false, "Skip scaffold steps during init") initCmd.Flags().Bool("use-repo-config", true, "Automatically use repository config (non-interactive, default: true)") + initCmd.Flags().String("mode", "", "Workspace mode: worktree or cow (copy-on-write)") +} + +// detectDefaultBranchFromRemote clones the repository temporarily to determine +// the default branch, then returns it. Falls back to "main" on error. +// For local paths, it uses git directly. +func detectDefaultBranchFromRemote(repoURL string) (string, error) { + cmd := exec.Command("git", "ls-remote", "--symref", repoURL, "HEAD") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("ls-remote failed: %w", err) + } + + // Output format: "ref: refs/heads/\tHEAD" + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "ref: refs/heads/") { + branch := strings.TrimPrefix(line, "ref: refs/heads/") + branch = strings.Fields(branch)[0] // strip trailing \tHEAD + return branch, nil + } + } + + return "", fmt.Errorf("could not determine default branch from remote") } // checkAndCopyRepoConfig checks for arbor.yaml in the repository and prompts to copy it. diff --git a/internal/cli/init_cow.go b/internal/cli/init_cow.go new file mode 100644 index 0000000..bcfdd7d --- /dev/null +++ b/internal/cli/init_cow.go @@ -0,0 +1,97 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/artisanexperiences/arbor/internal/config" + "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" +) + +// cloneNormal clones repoURL as a regular (non-bare) git repository to dst. +func cloneNormal(repoURL, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("creating parent directory: %w", err) + } + + cmd := exec.Command("git", "clone", repoURL, dst) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git clone failed: %w\n%s", err, string(output)) + } + return nil +} + +// determineWorkspaceMode returns the workspace mode to use for a new project. +// +// - If modeFlag is non-empty it is validated and returned directly. +// - If the terminal is interactive the user is prompted. +// - Otherwise "worktree" is returned as the default. +func determineWorkspaceMode(modeFlag string) (string, error) { + if modeFlag != "" { + switch modeFlag { + case workspace.ModeWorktree, workspace.ModeCow: + return modeFlag, nil + default: + return "", fmt.Errorf("invalid workspace mode %q: must be %q or %q", + modeFlag, workspace.ModeWorktree, workspace.ModeCow) + } + } + + if ui.IsInteractive() { + return ui.PromptWorkspaceMode() + } + + // Non-interactive default: worktree mode (backward compatible) + return workspace.ModeWorktree, nil +} + +// performCowInit initialises a new project in copy-on-write mode: +// 1. Clones repoURL as a normal (non-bare) clone to // +// 2. Creates the /.arbor/ marker directory with metadata +// 3. Writes arbor.yaml to projectRoot with workspace_mode: cow +func performCowInit(repoURL, projectRoot, defaultBranch, siteName string) error { + mainPath := filepath.Join(projectRoot, defaultBranch) + + // Clone as a normal (non-bare) repo + ui.PrintStep(fmt.Sprintf("Cloning %s to %s (copy-on-write mode)...", repoURL, mainPath)) + if err := cloneNormal(repoURL, mainPath); err != nil { + return fmt.Errorf("cloning repository: %w", err) + } + ui.PrintSuccess(fmt.Sprintf("Cloned to %s", mainPath)) + + // Create .arbor/ marker directory + arborDir := filepath.Join(projectRoot, ".arbor") + if err := os.MkdirAll(arborDir, 0755); err != nil { + return fmt.Errorf("creating .arbor directory: %w", err) + } + + // Write metadata.yaml + metadata := fmt.Sprintf("workspace_mode: cow\nremote_url: %s\ndefault_branch: %s\n", + repoURL, defaultBranch) + metadataPath := filepath.Join(arborDir, "metadata.yaml") + if err := os.WriteFile(metadataPath, []byte(metadata), 0644); err != nil { + return fmt.Errorf("writing .arbor/metadata.yaml: %w", err) + } + ui.PrintSuccess("Created .arbor/ project marker") + + // Warn if CoW is not natively supported on this filesystem + if supported, err := workspace.DetectCowSupport(projectRoot); err == nil && !supported { + ui.PrintWarning(workspace.CowSupportWarning()) + } + + // Write arbor.yaml to project root + cfg := &config.Config{ + SiteName: siteName, + DefaultBranch: defaultBranch, + WorkspaceMode: workspace.ModeCow, + } + if err := config.SaveProject(projectRoot, cfg); err != nil { + return fmt.Errorf("saving project config: %w", err) + } + ui.PrintSuccess("Saved arbor.yaml (workspace_mode: cow)") + + return nil +} diff --git a/internal/cli/init_cow_test.go b/internal/cli/init_cow_test.go new file mode 100644 index 0000000..f660044 --- /dev/null +++ b/internal/cli/init_cow_test.go @@ -0,0 +1,198 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/artisanexperiences/arbor/internal/config" +) + +// createTestSourceRepo creates a minimal git source repo with an initial commit +// at the given directory path. Returns the path (same as sourceDir). +func createTestSourceRepo(t *testing.T, sourceDir string) string { + t.Helper() + require.NoError(t, os.MkdirAll(sourceDir, 0755)) + + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("test"), 0644)) + + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "Initial commit"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + return sourceDir +} + +// --- performCowInit helper --- + +func TestPerformCowInit_CreatesBareArborDir(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + // .arbor/ marker directory must exist + arborDir := filepath.Join(projectRoot, ".arbor") + assert.DirExists(t, arborDir, ".arbor directory should be created") + + // .arbor/metadata.yaml must exist + metadataPath := filepath.Join(arborDir, "metadata.yaml") + assert.FileExists(t, metadataPath, ".arbor/metadata.yaml should exist") +} + +func TestPerformCowInit_CreatesNormalClone(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + // main/ workspace must exist and be a normal (non-bare) clone + mainPath := filepath.Join(projectRoot, "main") + assert.DirExists(t, mainPath) + + // It should have a .git directory (not a bare repo which has HEAD, refs/ etc. at root) + gitDir := filepath.Join(mainPath, ".git") + assert.DirExists(t, gitDir, "main workspace should have a .git directory (normal clone)") + + // Bare repos do NOT have a .git directory at their root + bareFile := filepath.Join(mainPath, "HEAD") + _, err = os.Stat(bareFile) + // HEAD should be inside .git, not at workspace root + assert.False(t, !os.IsNotExist(err) && gitDir == "", "should be a normal clone, not a bare repo") +} + +func TestPerformCowInit_NoBareDirCreated(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + // .bare directory must NOT be created in CoW mode + barePath := filepath.Join(projectRoot, ".bare") + _, statErr := os.Stat(barePath) + assert.True(t, os.IsNotExist(statErr), ".bare directory should NOT be created in CoW mode") +} + +func TestPerformCowInit_MetadataContainsExpectedFields(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + metadataPath := filepath.Join(projectRoot, ".arbor", "metadata.yaml") + content, err := os.ReadFile(metadataPath) + require.NoError(t, err) + + contentStr := string(content) + assert.Contains(t, contentStr, "workspace_mode: cow") + assert.Contains(t, contentStr, "default_branch: main") + // remote_url should reference the source repo + assert.Contains(t, contentStr, sourceDir) +} + +func TestPerformCowInit_MainWorkspaceOnDefaultBranch(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + mainPath := filepath.Join(projectRoot, "main") + + // The main workspace should be on the main branch + cmd := exec.Command("git", "-C", mainPath, "branch", "--show-current") + output, err := cmd.Output() + require.NoError(t, err) + assert.Equal(t, "main", strings.TrimSpace(string(output))) +} + +// --- Config integration --- + +func TestInitCow_SavesWorkspaceModeInConfig(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "test-project") + require.NoError(t, err) + + // The project arbor.yaml should have workspace_mode: cow + cfg, err := config.LoadProject(projectRoot) + require.NoError(t, err) + assert.Equal(t, "cow", cfg.WorkspaceMode) +} + +func TestInitCow_ConfigHasSiteName(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "my-project") + require.NoError(t, err) + + cfg, err := config.LoadProject(projectRoot) + require.NoError(t, err) + assert.Equal(t, "my-project", cfg.SiteName) +} + +func TestInitCow_ConfigHasDefaultBranch(t *testing.T) { + sourceDir := createTestSourceRepo(t, filepath.Join(t.TempDir(), "source")) + projectRoot := filepath.Join(t.TempDir(), "project") + + err := performCowInit(sourceDir, projectRoot, "main", "my-project") + require.NoError(t, err) + + cfg, err := config.LoadProject(projectRoot) + require.NoError(t, err) + assert.Equal(t, "main", cfg.DefaultBranch) +} + +// --- determineWorkspaceMode helper --- + +func TestDetermineWorkspaceMode_FlagWorktree(t *testing.T) { + mode, err := determineWorkspaceMode("worktree") + require.NoError(t, err) + assert.Equal(t, "worktree", mode) +} + +func TestDetermineWorkspaceMode_FlagCow(t *testing.T) { + mode, err := determineWorkspaceMode("cow") + require.NoError(t, err) + assert.Equal(t, "cow", mode) +} + +func TestDetermineWorkspaceMode_InvalidFlag(t *testing.T) { + _, err := determineWorkspaceMode("invalid-mode") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid workspace mode") +} + +func TestDetermineWorkspaceMode_EmptyFlagNonInteractive(t *testing.T) { + // With no flag and non-interactive env, should default to worktree + mode, err := determineWorkspaceMode("") + require.NoError(t, err) + // In tests the terminal is non-interactive, so it should default to worktree + assert.Equal(t, "worktree", mode) +} diff --git a/internal/cli/list.go b/internal/cli/list.go index 962018c..fc67dd5 100644 --- a/internal/cli/list.go +++ b/internal/cli/list.go @@ -14,10 +14,10 @@ import ( var listCmd = &cobra.Command{ Use: "list", - Short: "List all worktrees", - Long: `List all worktrees in the repository with their status. + Short: "List all workspaces", + Long: `List all workspaces in the repository with their status. -Shows worktrees with merge status, current worktree indicator, +Shows workspaces with merge status, current workspace indicator, and main branch highlighting.`, RunE: func(cmd *cobra.Command, args []string) error { pc, err := OpenProjectFromCWD() @@ -30,11 +30,13 @@ and main branch highlighting.`, sortBy := mustGetString(cmd, "sort-by") reverse := mustGetBool(cmd, "reverse") - worktrees, err := git.ListWorktreesDetailed(pc.BarePath, pc.CWD, pc.DefaultBranch) + workspaces, err := pc.WorkspaceManager().ListWorkspacesDetailed(pc.CWD) if err != nil { - return fmt.Errorf("listing worktrees: %w", err) + return fmt.Errorf("listing workspaces: %w", err) } + // Convert to git.Worktree for existing UI/sort functions + worktrees := workspacesToGitWorktrees(workspaces) worktrees = git.SortWorktrees(worktrees, sortBy, reverse) if jsonOutput { @@ -51,7 +53,7 @@ and main branch highlighting.`, func printTable(w io.Writer, worktrees []git.Worktree) error { if len(worktrees) == 0 { - _, err := fmt.Fprintln(w, "No worktrees found.") + _, err := fmt.Fprintln(w, "No workspaces found.") return err } diff --git a/internal/cli/list_test.go b/internal/cli/list_test.go index 11f4f95..5c1f195 100644 --- a/internal/cli/list_test.go +++ b/internal/cli/list_test.go @@ -74,8 +74,8 @@ func TestPrintTable_Empty(t *testing.T) { } output := buf.String() - if !strings.Contains(output, "No worktrees found") { - t.Errorf("expected 'No worktrees found' message, got: %s", output) + if !strings.Contains(output, "No workspaces found") { + t.Errorf("expected 'No workspaces found' message, got: %s", output) } } diff --git a/internal/cli/prune.go b/internal/cli/prune.go index edd172d..888be12 100644 --- a/internal/cli/prune.go +++ b/internal/cli/prune.go @@ -10,14 +10,15 @@ import ( "github.com/artisanexperiences/arbor/internal/git" "github.com/artisanexperiences/arbor/internal/scaffold/types" "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" ) var pruneCmd = &cobra.Command{ Use: "prune", - Short: "Remove merged worktrees", - Long: `Removes merged worktrees automatically. + Short: "Remove merged workspaces", + Long: `Removes merged workspaces automatically. -Lists all worktrees, identifies merged ones, and provides an +Lists all workspaces, identifies merged ones, and provides an interactive review before removal.`, RunE: func(cmd *cobra.Command, args []string) error { pc, err := OpenProjectFromCWD() @@ -30,9 +31,21 @@ interactive review before removal.`, verbose := mustGetBool(cmd, "verbose") quiet := mustGetBool(cmd, "quiet") - worktrees, err := git.ListWorktrees(pc.BarePath) + wsManager := pc.WorkspaceManager() + workspaces, err := wsManager.ListWorkspaces() if err != nil { - return fmt.Errorf("listing worktrees: %w", err) + return fmt.Errorf("listing workspaces: %w", err) + } + + // Convert to git.Worktree for existing UI functions + worktrees := workspacesToGitWorktrees(workspaces) + + // Determine the git repo path used for merge checks once, based on mode. + var mergeCheckPath string + if pc.Mode == workspace.ModeCow { + mergeCheckPath = filepath.Join(pc.ProjectPath, pc.DefaultBranch) + } else { + mergeCheckPath = pc.BarePath } var removable []git.Worktree @@ -43,7 +56,7 @@ interactive review before removal.`, continue } - merged, err := git.IsMerged(pc.BarePath, wt.Branch, pc.DefaultBranch) + merged, err := git.IsMerged(mergeCheckPath, wt.Branch, pc.DefaultBranch) if err != nil { ui.PrintErrorWithHint(fmt.Sprintf("Error checking %s", wt.Branch), err.Error()) continue @@ -58,11 +71,11 @@ interactive review before removal.`, } if len(removable) == 0 { - ui.PrintDone("No merged worktrees to remove.") + ui.PrintDone("No merged workspaces to remove.") return nil } - ui.PrintInfo(fmt.Sprintf("%d merged worktree(s) found.", len(removable))) + ui.PrintInfo(fmt.Sprintf("%d merged workspace(s) found.", len(removable))) var toRemove []git.Worktree if force { @@ -70,12 +83,12 @@ interactive review before removal.`, } else { selected, err := ui.SelectWorktreesToPrune(removable) if err != nil { - return fmt.Errorf("selecting worktrees: %w", err) + return fmt.Errorf("selecting workspaces: %w", err) } toRemove = selected if len(toRemove) == 0 { - ui.PrintInfo("No worktrees selected for removal.") + ui.PrintInfo("No workspaces selected for removal.") return nil } @@ -84,12 +97,12 @@ interactive review before removal.`, return fmt.Errorf("confirmation: %w", err) } if !confirmed { - ui.PrintInfo("No worktrees removed.") + ui.PrintInfo("No workspaces removed.") return nil } } - ui.PrintInfo(fmt.Sprintf("Removing %d worktree(s):", len(toRemove))) + ui.PrintInfo(fmt.Sprintf("Removing %d workspace(s):", len(toRemove))) for _, wt := range toRemove { ui.PrintSuccessPath("Removed", wt.Path) } @@ -114,7 +127,7 @@ interactive review before removal.`, ui.PrintErrorWithHint("Cleanup failed", err.Error()) } - if err := git.RemoveWorktree(wt.Path, true); err != nil { + if err := wsManager.RemoveWorkspace(wt.Path, true); err != nil { ui.PrintErrorWithHint(fmt.Sprintf("Error removing %s", wt.Branch), err.Error()) } } else { diff --git a/internal/cli/remove.go b/internal/cli/remove.go index a2042e2..de8b2e7 100644 --- a/internal/cli/remove.go +++ b/internal/cli/remove.go @@ -11,15 +11,16 @@ import ( "github.com/artisanexperiences/arbor/internal/git" "github.com/artisanexperiences/arbor/internal/scaffold/types" "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" ) var removeCmd = &cobra.Command{ Use: "remove [FOLDER]", - Short: "Remove a worktree with cleanup", - Long: `Removes a worktree and runs preset-defined cleanup steps. + Short: "Remove a workspace with cleanup", + Long: `Removes a workspace and runs preset-defined cleanup steps. Arguments: - FOLDER Name of the worktree folder to remove (e.g., feature-test-change) + FOLDER Name of the workspace folder to remove (e.g., feature-test-change) Cleanup steps may include: - Removing Herd site links @@ -36,28 +37,29 @@ Cleanup steps may include: verbose := mustGetBool(cmd, "verbose") quiet := mustGetBool(cmd, "quiet") - currentWorktreePath, err := os.Getwd() + currentWorkspacePath, err := os.Getwd() if err != nil { return fmt.Errorf("getting current directory: %w", err) } - defaultBranch, err := git.GetDefaultBranch(pc.BarePath) - if err != nil { - return fmt.Errorf("getting default branch: %w", err) - } + wsManager := pc.WorkspaceManager() - worktrees, err := git.ListWorktreesDetailed(pc.BarePath, currentWorktreePath, defaultBranch) + workspaces, err := wsManager.ListWorkspacesDetailed(currentWorkspacePath) if err != nil { - return fmt.Errorf("listing worktrees: %w", err) + return fmt.Errorf("listing workspaces: %w", err) } + // Convert to git.Worktree for existing UI functions + gitWorktrees := workspacesToGitWorktrees(workspaces) + var targetWorktree *git.Worktree if len(args) > 0 { folderName := args[0] - for _, wt := range worktrees { + for _, wt := range gitWorktrees { if filepath.Base(wt.Path) == folderName { - targetWorktree = &wt + wtCopy := wt + targetWorktree = &wtCopy break } } @@ -65,7 +67,7 @@ Cleanup steps may include: return fmt.Errorf("worktree '%s' not found: %w", folderName, arborerrors.ErrWorktreeNotFound) } } else if ui.IsInteractive() { - selected, err := ui.SelectWorktreeToRemove(worktrees) + selected, err := ui.SelectWorktreeToRemove(gitWorktrees) if err != nil { return fmt.Errorf("selecting worktree: %w", err) } @@ -96,17 +98,21 @@ Cleanup steps may include: return nil } - if git.BranchExists(pc.BarePath, targetWorktree.Branch) { + // Branch deletion only makes sense in worktree mode where there is a shared bare repo + if pc.Mode == workspace.ModeWorktree && git.BranchExists(pc.BarePath, targetWorktree.Branch) { deleteBranch, err = ui.Confirm(fmt.Sprintf("Also delete branch '%s'?", targetWorktree.Branch)) if err != nil { return fmt.Errorf("branch deletion confirmation: %w", err) } } } else { - deleteBranch = mustGetBool(cmd, "delete-branch") + // --delete-branch only applicable in worktree mode + if pc.Mode == workspace.ModeWorktree { + deleteBranch = mustGetBool(cmd, "delete-branch") + } } - ui.PrintStep("Removing worktree") + ui.PrintStep("Removing workspace") if !dryRun { preset := pc.Config.Preset @@ -131,12 +137,13 @@ Cleanup steps may include: } } - if err := git.RemoveWorktree(targetWorktree.Path, true); err != nil { - return fmt.Errorf("removing worktree: %w", err) + if err := wsManager.RemoveWorkspace(targetWorktree.Path, true); err != nil { + return fmt.Errorf("removing workspace: %w", err) } ui.PrintSuccessPath("Removed", targetWorktree.Path) - if deleteBranch && git.BranchExists(pc.BarePath, targetWorktree.Branch) { + // Branch deletion: only in worktree mode + if deleteBranch && pc.Mode == workspace.ModeWorktree && git.BranchExists(pc.BarePath, targetWorktree.Branch) { if err := git.DeleteBranch(pc.BarePath, targetWorktree.Branch, true); err != nil { ui.PrintErrorWithHint("Failed to delete branch", err.Error()) } else { @@ -152,13 +159,13 @@ Cleanup steps may include: } } } else { - ui.PrintInfo("[DRY RUN] Would run cleanup and remove worktree") + ui.PrintInfo("[DRY RUN] Would run cleanup and remove workspace") if deleteBranch { ui.PrintInfo("[DRY RUN] Would delete branch") } } - ui.PrintDone("Worktree removed") + ui.PrintDone("Workspace removed") return nil }, } @@ -167,5 +174,21 @@ func init() { rootCmd.AddCommand(removeCmd) removeCmd.Flags().BoolP("force", "f", false, "Skip confirmation and cleanup prompts") - removeCmd.Flags().Bool("delete-branch", false, "Also delete the branch after removing worktree") + removeCmd.Flags().Bool("delete-branch", false, "Also delete the branch after removing worktree (worktree mode only)") +} + +// workspacesToGitWorktrees converts workspace.Workspace slices to git.Worktree slices +// for use with existing UI functions that expect git.Worktree. +func workspacesToGitWorktrees(workspaces []workspace.Workspace) []git.Worktree { + result := make([]git.Worktree, len(workspaces)) + for i, ws := range workspaces { + result[i] = git.Worktree{ + Path: ws.Path, + Branch: ws.Branch, + IsMain: ws.IsMain, + IsCurrent: ws.IsCurrent, + IsMerged: ws.IsMerged, + } + } + return result } diff --git a/internal/cli/remove_cow_test.go b/internal/cli/remove_cow_test.go new file mode 100644 index 0000000..a4a6272 --- /dev/null +++ b/internal/cli/remove_cow_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupCowProjectWithFeature creates a CoW project with a feature workspace +// already created. Returns (projectRoot, mainPath, featurePath). +func setupCowProjectWithFeature(t *testing.T) (projectRoot, mainPath, featurePath string) { + t.Helper() + + projectRoot, mainPath = createTestCowProjectForCLI(t) + featurePath = filepath.Join(projectRoot, "feature-to-remove") + + // Create the feature workspace by copying main + cmd := exec.Command("cp", "-R", mainPath+"/", featurePath+"/") + require.NoError(t, cmd.Run()) + + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"checkout", "-b", "feature/to-remove"}, + } { + c := exec.Command("git", args...) + c.Dir = featurePath + require.NoError(t, c.Run()) + } + + return projectRoot, mainPath, featurePath +} + +func TestRemoveCmd_CowMode_RemovesWorkspace(t *testing.T) { + _, mainPath, featurePath := setupCowProjectWithFeature(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().BoolP("force", "f", true, "") + cmd.Flags().Bool("delete-branch", false, "") + cmd.Flags().Bool("dry-run", false, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = removeCmd.RunE(cmd, []string{"feature-to-remove"}) + require.NoError(t, err) + + // The workspace directory should be gone + _, statErr := os.Stat(featurePath) + assert.True(t, os.IsNotExist(statErr), "feature workspace should be removed") +} + +func TestRemoveCmd_CowMode_PreventsMainRemoval(t *testing.T) { + _, mainPath, _ := setupCowProjectWithFeature(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().BoolP("force", "f", true, "") + cmd.Flags().Bool("delete-branch", false, "") + cmd.Flags().Bool("dry-run", false, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = removeCmd.RunE(cmd, []string{"main"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "cannot remove main") +} + +func TestRemoveCmd_CowMode_DryRunDoesNotRemove(t *testing.T) { + _, mainPath, featurePath := setupCowProjectWithFeature(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().BoolP("force", "f", true, "") + cmd.Flags().Bool("delete-branch", false, "") + cmd.Flags().Bool("dry-run", true, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = removeCmd.RunE(cmd, []string{"feature-to-remove"}) + require.NoError(t, err) + + // Directory should still exist after dry-run + assert.DirExists(t, featurePath, "feature workspace should still exist after dry-run") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 76f8a20..6a5d737 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -110,18 +110,19 @@ func printBanner() { commands := ` Commands: - init Initialize a new repository - work Create or checkout a worktree - list List all worktrees - sync Sync current worktree with upstream branch - remove Remove a worktree - prune Remove merged worktrees - scaffold Run scaffold steps for a worktree + init Initialize a new repository (worktree or copy-on-write mode) + work Create or checkout a workspace + list List all workspaces + sync Sync current workspace with upstream branch + remove Remove a workspace + prune Remove merged workspaces + switch Switch workspace mode (worktree ↔ copy-on-write) + scaffold Run scaffold steps for a workspace repair Repair git configuration for existing project - pull-config Update project config from the default branch worktree + pull-config Update project config from the default branch workspace destroy Completely destroy an arbor project - install Setup global configuration - version Show arbor version + install Setup global configuration + version Show arbor version Run 'arbor --help' for more information.` diff --git a/internal/cli/switch.go b/internal/cli/switch.go new file mode 100644 index 0000000..95843af --- /dev/null +++ b/internal/cli/switch.go @@ -0,0 +1,282 @@ +package cli + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/artisanexperiences/arbor/internal/config" + "github.com/artisanexperiences/arbor/internal/git" + "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" +) + +var switchCmd = &cobra.Command{ + Use: "switch [MODE]", + Short: "Switch workspace mode between worktree and copy-on-write", + Long: `Converts the current project between worktree mode and copy-on-write (CoW) mode. + + arbor switch cow — convert a worktree project to CoW mode + arbor switch worktree — convert a CoW project back to worktree mode + +The switch operation: + 1. Identifies all feature workspaces + 2. Confirms removal (feature workspaces cannot be migrated; recreate with 'arbor work') + 3. Converts the project layout to the target mode + 4. Updates arbor.yaml with the new workspace_mode + +Feature workspaces are removed during the switch. After switching, use +'arbor work ' to recreate them in the new mode.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + targetMode := args[0] + force := mustGetBool(cmd, "force") + + pc, err := OpenProjectFromCWD() + if err != nil { + return err + } + + return performSwitch(pc.ProjectPath, targetMode, force) + }, +} + +func init() { + rootCmd.AddCommand(switchCmd) + switchCmd.Flags().BoolP("force", "f", false, "Skip confirmation prompts") +} + +// performSwitch converts projectRoot from its current workspace mode to targetMode. +// force=true skips user confirmation prompts. +func performSwitch(projectRoot, targetMode string, force bool) error { + if targetMode != workspace.ModeWorktree && targetMode != workspace.ModeCow { + return fmt.Errorf("invalid target mode %q: must be %q or %q", + targetMode, workspace.ModeWorktree, workspace.ModeCow) + } + + // Detect current mode + info, err := workspace.FindProjectRoot(projectRoot) + if err != nil { + return fmt.Errorf("finding project: %w", err) + } + + if info.Mode == targetMode { + return fmt.Errorf("already using %s mode", targetMode) + } + + cfg, err := config.LoadProject(projectRoot) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + defaultBranch := cfg.DefaultBranch + if defaultBranch == "" { + defaultBranch = "main" + } + + wsManager := workspace.NewManager(info, defaultBranch) + workspaces, err := wsManager.ListWorkspaces() + if err != nil { + return fmt.Errorf("listing workspaces: %w", err) + } + + // Identify feature workspaces that will be removed + var featureWorkspaces []workspace.Workspace + for _, ws := range workspaces { + if ws.Branch != defaultBranch { + featureWorkspaces = append(featureWorkspaces, ws) + } + } + + if len(featureWorkspaces) > 0 { + ui.PrintWarning(fmt.Sprintf("The following %d feature workspace(s) will be removed during the switch:", len(featureWorkspaces))) + for _, ws := range featureWorkspaces { + ui.PrintInfo(fmt.Sprintf(" - %s (%s)", ws.Branch, ws.Path)) + } + ui.PrintInfo("You can recreate them with 'arbor work ' after switching.") + + if !force { + confirmed, err := ui.Confirm(fmt.Sprintf("Remove %d feature workspace(s) and switch to %s mode?", len(featureWorkspaces), targetMode)) + if err != nil { + return fmt.Errorf("confirmation: %w", err) + } + if !confirmed { + ui.PrintInfo("Switch cancelled.") + return nil + } + } + + // Remove feature workspaces + for _, ws := range featureWorkspaces { + ui.PrintStep(fmt.Sprintf("Removing workspace %s...", ws.Branch)) + if err := wsManager.RemoveWorkspace(ws.Path, true); err != nil { + ui.PrintWarning(fmt.Sprintf("Failed to remove %s: %v", ws.Branch, err)) + } + } + } + + // Get the remote URL before we tear down the current structure + remoteURL, err := getRemoteURLForSwitch(info, defaultBranch) + if err != nil { + return fmt.Errorf("getting remote URL: %w", err) + } + if remoteURL == "" { + return fmt.Errorf("could not determine remote URL; ensure 'origin' is configured") + } + + switch targetMode { + case workspace.ModeCow: + return switchToCoW(projectRoot, info, defaultBranch, remoteURL, cfg) + case workspace.ModeWorktree: + return switchToWorktree(projectRoot, info, defaultBranch, remoteURL, cfg) + default: + panic("unreachable: mode already validated above") + } +} + +// getRemoteURLForSwitch retrieves the origin URL depending on the current mode. +func getRemoteURLForSwitch(info *workspace.ProjectInfo, defaultBranch string) (string, error) { + switch info.Mode { + case workspace.ModeWorktree: + url, err := git.GetRemoteURL(info.BarePath, "origin") + if err != nil { + return "", fmt.Errorf("reading remote from .bare: %w", err) + } + return url, nil + case workspace.ModeCow: + mainPath := filepath.Join(info.ProjectRoot, defaultBranch) + url, err := git.GetRemoteURLFromWorktree(mainPath) + if err != nil { + return "", fmt.Errorf("reading remote from workspace: %w", err) + } + return url, nil + default: + return "", fmt.Errorf("unknown mode: %q", info.Mode) + } +} + +// switchToCoW converts a worktree-mode project to CoW mode: +// 1. Remove the main git worktree (git worktree remove) +// 2. Create a normal clone in its place +// 3. Remove .bare +// 4. Create .arbor/ marker +// 5. Update arbor.yaml +func switchToCoW(projectRoot string, info *workspace.ProjectInfo, defaultBranch, remoteURL string, cfg *config.Config) error { + mainPath := filepath.Join(projectRoot, defaultBranch) + barePath := info.BarePath + tmpMainPath := mainPath + "-switch-tmp" + + ui.PrintStep(fmt.Sprintf("Switching to copy-on-write mode (remote: %s)...", remoteURL)) + + // Clone a normal repo to a temp path (don't overwrite mainPath yet) + ui.PrintInfo("Cloning normal repository...") + cmd := exec.Command("git", "clone", remoteURL, tmpMainPath) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("cloning repository: %w\n%s", err, string(output)) + } + + // Remove the worktree-mode main worktree from git + ui.PrintInfo("Removing worktree-mode main workspace...") + if err := git.RemoveWorktree(mainPath, true); err != nil { + // Try force removal of the directory if git worktree remove fails + _ = os.RemoveAll(mainPath) + } + + // Move the normal clone into place + if err := os.Rename(tmpMainPath, mainPath); err != nil { + return fmt.Errorf("moving clone into place: %w", err) + } + ui.PrintSuccess(fmt.Sprintf("Created normal clone at %s", mainPath)) + + // Remove .bare + if err := os.RemoveAll(barePath); err != nil { + return fmt.Errorf("removing .bare: %w", err) + } + ui.PrintSuccess("Removed .bare directory") + + // Create .arbor/ marker + arborDir := filepath.Join(projectRoot, ".arbor") + if err := os.MkdirAll(arborDir, 0755); err != nil { + return fmt.Errorf("creating .arbor directory: %w", err) + } + metadata := fmt.Sprintf("workspace_mode: cow\nremote_url: %s\ndefault_branch: %s\n", + remoteURL, defaultBranch) + if err := os.WriteFile(filepath.Join(arborDir, "metadata.yaml"), []byte(metadata), 0644); err != nil { + return fmt.Errorf("writing .arbor/metadata.yaml: %w", err) + } + ui.PrintSuccess("Created .arbor/ project marker") + + // Update arbor.yaml + cfg.WorkspaceMode = workspace.ModeCow + if err := config.SaveProject(projectRoot, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + ui.PrintSuccess("Updated arbor.yaml (workspace_mode: cow)") + + // Warn about CoW filesystem support + if supported, err := workspace.DetectCowSupport(projectRoot); err == nil && !supported { + ui.PrintWarning(workspace.CowSupportWarning()) + } + + ui.PrintDone("Switched to copy-on-write mode. Use 'arbor work ' to create workspaces.") + return nil +} + +// switchToWorktree converts a CoW-mode project to worktree mode: +// 1. Create a bare clone in .bare/ +// 2. Configure fetch refspec on the bare repo +// 3. Remove the CoW main clone +// 4. Create a git worktree for the default branch +// 5. Remove .arbor/ +// 6. Update arbor.yaml +func switchToWorktree(projectRoot string, info *workspace.ProjectInfo, defaultBranch, remoteURL string, cfg *config.Config) error { + mainPath := filepath.Join(projectRoot, defaultBranch) + barePath := filepath.Join(projectRoot, ".bare") + arborDir := filepath.Join(projectRoot, ".arbor") + + ui.PrintStep(fmt.Sprintf("Switching to worktree mode (remote: %s)...", remoteURL)) + + // Create bare clone + ui.PrintInfo("Creating bare repository...") + if err := git.CloneRepo(remoteURL, barePath); err != nil { + return fmt.Errorf("creating bare repository: %w", err) + } + ui.PrintSuccess("Created bare repository at .bare/") + + // Configure fetch refspec (needed for branch tracking in bare repos) + if err := git.ConfigureFetchRefspec(barePath, remoteURL); err != nil { + ui.PrintWarning(fmt.Sprintf("Could not configure fetch refspec: %v", err)) + } + + // Remove the CoW main clone + ui.PrintInfo("Removing CoW main workspace...") + if err := os.RemoveAll(mainPath); err != nil { + return fmt.Errorf("removing CoW main workspace: %w", err) + } + + // Create the main git worktree + ui.PrintInfo(fmt.Sprintf("Creating main worktree at %s...", mainPath)) + if err := git.CreateWorktree(barePath, mainPath, defaultBranch, ""); err != nil { + return fmt.Errorf("creating main worktree: %w", err) + } + ui.PrintSuccess(fmt.Sprintf("Created main worktree at %s", mainPath)) + + // Remove .arbor/ + if err := os.RemoveAll(arborDir); err != nil { + return fmt.Errorf("removing .arbor directory: %w", err) + } + ui.PrintSuccess("Removed .arbor/ directory") + + // Update arbor.yaml + cfg.WorkspaceMode = workspace.ModeWorktree + if err := config.SaveProject(projectRoot, cfg); err != nil { + return fmt.Errorf("saving config: %w", err) + } + ui.PrintSuccess("Updated arbor.yaml (workspace_mode: worktree)") + + ui.PrintDone("Switched to worktree mode. Use 'arbor work ' to create workspaces.") + return nil +} diff --git a/internal/cli/switch_test.go b/internal/cli/switch_test.go new file mode 100644 index 0000000..a71fcec --- /dev/null +++ b/internal/cli/switch_test.go @@ -0,0 +1,235 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/artisanexperiences/arbor/internal/config" +) + +// --- setupWorktreeProjectWithFeatures creates a worktree-mode project with +// main and feature workspaces for switch tests. +func setupWorktreeProjectWithFeatures(t *testing.T) (projectRoot, barePath, mainPath string) { + t.Helper() + + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + projectRoot = filepath.Join(tmpDir, "project") + barePath = filepath.Join(projectRoot, ".bare") + mainPath = filepath.Join(projectRoot, "main") + + require.NoError(t, os.MkdirAll(sourceDir, 0755)) + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("test"), 0644)) + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "Initial commit"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + + // Bare clone + cmd := exec.Command("git", "clone", "--bare", sourceDir, barePath) + require.NoError(t, cmd.Run()) + + // Configure remote + cmd = exec.Command("git", "-C", barePath, "config", "remote.origin.url", sourceDir) + require.NoError(t, cmd.Run()) + + // Main worktree + cmd = exec.Command("git", "-C", barePath, "worktree", "add", mainPath, "main") + require.NoError(t, cmd.Run()) + + // Write arbor.yaml with worktree mode (the default) + cfg := &config.Config{ + SiteName: "test-project", + DefaultBranch: "main", + } + require.NoError(t, config.SaveProject(projectRoot, cfg)) + + return projectRoot, barePath, mainPath +} + +// --- performSwitch helper --- + +func TestPerformSwitch_WorktreeToCow_CreatesArborDir(t *testing.T) { + projectRoot, _, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "cow", true) // force = true to skip confirmation + require.NoError(t, err) + + // .arbor/ directory should now exist + arborDir := filepath.Join(projectRoot, ".arbor") + assert.DirExists(t, arborDir, ".arbor directory should be created after switching to CoW") +} + +func TestPerformSwitch_WorktreeToCow_RemovesBareDir(t *testing.T) { + projectRoot, barePath, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + _ = barePath // just for clarity + + err = performSwitch(projectRoot, "cow", true) + require.NoError(t, err) + + // .bare/ directory should be gone + barePath2 := filepath.Join(projectRoot, ".bare") + _, statErr := os.Stat(barePath2) + assert.True(t, os.IsNotExist(statErr), ".bare directory should be removed after switching to CoW") +} + +func TestPerformSwitch_WorktreeToCow_MainWorkspaceIsNormalClone(t *testing.T) { + projectRoot, _, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "cow", true) + require.NoError(t, err) + + // After switch, main/ should have a .git DIRECTORY (normal clone) + mainAfterSwitch := filepath.Join(projectRoot, "main") + gitDir := filepath.Join(mainAfterSwitch, ".git") + info, statErr := os.Stat(gitDir) + require.NoError(t, statErr, ".git should exist in main workspace after CoW switch") + assert.True(t, info.IsDir(), ".git should be a directory (normal clone), not a file (worktree pointer)") +} + +func TestPerformSwitch_WorktreeToCow_UpdatesConfig(t *testing.T) { + projectRoot, _, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "cow", true) + require.NoError(t, err) + + cfg, err := config.LoadProject(projectRoot) + require.NoError(t, err) + assert.Equal(t, "cow", cfg.WorkspaceMode) +} + +func TestPerformSwitch_CowToWorktree_CreatesBareDir(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "worktree", true) + require.NoError(t, err) + + // .bare/ should now exist + barePath := filepath.Join(projectRoot, ".bare") + assert.DirExists(t, barePath, ".bare directory should be created after switching to worktree mode") +} + +func TestPerformSwitch_CowToWorktree_RemovesArborDir(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "worktree", true) + require.NoError(t, err) + + // .arbor/ should be gone + arborDir := filepath.Join(projectRoot, ".arbor") + _, statErr := os.Stat(arborDir) + assert.True(t, os.IsNotExist(statErr), ".arbor directory should be removed after switching to worktree mode") +} + +func TestPerformSwitch_CowToWorktree_MainWorkspaceIsLinkedWorktree(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "worktree", true) + require.NoError(t, err) + + // After switch, main/ should have a .git FILE (worktree pointer), not a directory + mainAfterSwitch := filepath.Join(projectRoot, "main") + gitPath := filepath.Join(mainAfterSwitch, ".git") + info, statErr := os.Stat(gitPath) + require.NoError(t, statErr, ".git should exist in main workspace after worktree switch") + assert.False(t, info.IsDir(), ".git should be a file (worktree pointer), not a directory (normal clone)") +} + +func TestPerformSwitch_CowToWorktree_UpdatesConfig(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "worktree", true) + require.NoError(t, err) + + cfg, err := config.LoadProject(projectRoot) + require.NoError(t, err) + assert.Equal(t, "worktree", cfg.WorkspaceMode) +} + +func TestPerformSwitch_AlreadyInTargetMode_Errors(t *testing.T) { + projectRoot, _, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + // Trying to switch to worktree mode when already in worktree mode + err = performSwitch(projectRoot, "worktree", true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already using worktree mode") +} + +func TestPerformSwitch_InvalidMode_Errors(t *testing.T) { + projectRoot, _, mainPath := setupWorktreeProjectWithFeatures(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + require.NoError(t, os.Chdir(mainPath)) + + err = performSwitch(projectRoot, "invalid-mode", true) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid target mode") +} diff --git a/internal/cli/sync.go b/internal/cli/sync.go index dde3b31..b310add 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -8,6 +8,7 @@ import ( "github.com/artisanexperiences/arbor/internal/config" "github.com/artisanexperiences/arbor/internal/git" "github.com/artisanexperiences/arbor/internal/ui" + "github.com/artisanexperiences/arbor/internal/workspace" ) var syncCmd = &cobra.Command{ @@ -50,6 +51,15 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel yesFlag := mustGetBool(cmd, "yes") noAutoStashFlag := mustGetBool(cmd, "no-auto-stash") + // In CoW mode there is no bare repo — git operations run against the + // current workspace directory instead. + var gitRefsPath string + if pc.Mode == workspace.ModeCow { + gitRefsPath = pc.CWD + } else { + gitRefsPath = pc.BarePath + } + // Get current branch currentBranch, err := git.GetCurrentBranch(pc.CWD) if err != nil { @@ -145,12 +155,12 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel if shouldPrompt { // Prompt for upstream if not set via flag or config if upstreamFlag == "" && pc.Config.Sync.Upstream == "" { - localBranches, err := git.ListLocalBranches(pc.BarePath) + localBranches, err := git.ListLocalBranches(gitRefsPath) if err != nil { return fmt.Errorf("listing local branches: %w", err) } - remoteBranches, _ := git.ListRemoteBranches(pc.BarePath) + remoteBranches, _ := git.ListRemoteBranches(gitRefsPath) selected, err := ui.SelectUpstreamBranch(localBranches, remoteBranches, pc.DefaultBranch) if err != nil { @@ -184,7 +194,7 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel } // Check remote exists - remoteURL, err := git.GetRemoteURL(pc.BarePath, remote) + remoteURL, err := git.GetRemoteURL(gitRefsPath, remote) if err != nil { return fmt.Errorf("checking remote: %w", err) } @@ -226,7 +236,7 @@ Configuration can be set via flags, project config (arbor.yaml), or interactivel if verbose && !quiet { ui.PrintInfo(fmt.Sprintf("Fetching from %s", remote)) } - if err := git.FetchRemote(pc.BarePath, remote); err != nil { + if err := git.FetchRemote(gitRefsPath, remote); err != nil { return fmt.Errorf("fetch failed: %w", err) } if !quiet { diff --git a/internal/cli/sync_cow_test.go b/internal/cli/sync_cow_test.go new file mode 100644 index 0000000..1388e18 --- /dev/null +++ b/internal/cli/sync_cow_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestCowProjectWithRemote creates a CoW-mode project where main/ +// has a configured origin remote so sync can fetch. +func createTestCowProjectWithRemote(t *testing.T) (projectRoot, mainPath string) { + t.Helper() + + sourceDir := filepath.Join(t.TempDir(), "source") + createTestSourceRepo(t, sourceDir) + + projectRoot = filepath.Join(t.TempDir(), "project") + mainPath = filepath.Join(projectRoot, "main") + + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + + // Clone — this sets up origin automatically + cmd := exec.Command("git", "clone", sourceDir, mainPath) + require.NoError(t, cmd.Run()) + + // Create .arbor/ marker + arborDir := filepath.Join(projectRoot, ".arbor") + require.NoError(t, os.MkdirAll(arborDir, 0755)) + metadata := "workspace_mode: cow\nremote_url: " + sourceDir + "\ndefault_branch: main\n" + require.NoError(t, os.WriteFile(filepath.Join(arborDir, "metadata.yaml"), []byte(metadata), 0644)) + + // Write arbor.yaml + cfg := "site_name: test\ndefault_branch: main\nworkspace_mode: cow\n" + require.NoError(t, os.WriteFile(filepath.Join(projectRoot, "arbor.yaml"), []byte(cfg), 0644)) + + return projectRoot, mainPath +} + +func TestSyncCmd_CowMode_FetchUsesWorkspaceDir(t *testing.T) { + _, mainPath := createTestCowProjectWithRemote(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().StringP("upstream", "u", "main", "") + cmd.Flags().StringP("strategy", "s", "rebase", "") + cmd.Flags().StringP("remote", "r", "origin", "") + cmd.Flags().Bool("save", false, "") + cmd.Flags().BoolP("yes", "y", true, "") // skip all prompts + cmd.Flags().Bool("no-auto-stash", false, "") + cmd.Flags().Bool("dry-run", true, "") // dry-run so we don't actually change anything + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + // In CoW mode, syncing the main branch with itself should work without error + err = syncCmd.RunE(cmd, []string{}) + // Dry-run should always succeed + assert.NoError(t, err) +} + +func TestSyncCmd_CowMode_MustBeInWorkspace(t *testing.T) { + projectRoot, _ := createTestCowProjectWithRemote(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + // Run from the project root — not inside a workspace + require.NoError(t, os.Chdir(projectRoot)) + + cmd := &cobra.Command{} + cmd.Flags().StringP("upstream", "u", "main", "") + cmd.Flags().StringP("strategy", "s", "rebase", "") + cmd.Flags().StringP("remote", "r", "origin", "") + cmd.Flags().Bool("save", false, "") + cmd.Flags().BoolP("yes", "y", true, "") + cmd.Flags().Bool("no-auto-stash", false, "") + cmd.Flags().Bool("dry-run", false, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = syncCmd.RunE(cmd, []string{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "sync must be run from within a worktree") +} diff --git a/internal/cli/work.go b/internal/cli/work.go index 16c4e13..6c24d59 100644 --- a/internal/cli/work.go +++ b/internal/cli/work.go @@ -13,6 +13,7 @@ import ( "github.com/artisanexperiences/arbor/internal/scaffold/types" "github.com/artisanexperiences/arbor/internal/ui" "github.com/artisanexperiences/arbor/internal/utils" + "github.com/artisanexperiences/arbor/internal/workspace" ) var workCmd = &cobra.Command{ @@ -43,14 +44,23 @@ available branches or entering a new branch name.`, if len(args) > 0 { branch = args[0] } else if ui.IsInteractive() { - localBranches, err := git.ListAllBranches(pc.BarePath) + // For branch listing we need a git repo path. + // In worktree mode use .bare; in CoW mode use the default-branch workspace. + var gitRefPath string + if pc.Mode == workspace.ModeCow { + gitRefPath = filepath.Join(pc.ProjectPath, pc.DefaultBranch) + } else { + gitRefPath = pc.BarePath + } + + localBranches, err := git.ListAllBranches(gitRefPath) if err != nil { return fmt.Errorf("listing local branches: %w", err) } - remoteBranches, _ := git.ListRemoteBranches(pc.BarePath) + remoteBranches, _ := git.ListRemoteBranches(gitRefPath) - selected, err := ui.SelectBranchInteractive(pc.BarePath, localBranches, remoteBranches) + selected, err := ui.SelectBranchInteractive(gitRefPath, localBranches, remoteBranches) if err != nil { return fmt.Errorf("selecting branch: %w", err) } @@ -69,7 +79,14 @@ available branches or entering a new branch name.`, remote := branch[:idx] localBranch := branch[idx+1:] // Only treat it as a remote ref when the prefix matches a known remote. - remotes, _ := git.ListRemotes(pc.BarePath) + // In worktree mode use .bare; in CoW mode use the default-branch workspace. + var gitRefPath string + if pc.Mode == workspace.ModeCow { + gitRefPath = filepath.Join(pc.ProjectPath, pc.DefaultBranch) + } else { + gitRefPath = pc.BarePath + } + remotes, _ := git.ListRemotes(gitRefPath) for _, r := range remotes { if r == remote { if baseBranch == "" { @@ -97,34 +114,32 @@ available branches or entering a new branch name.`, return fmt.Errorf("getting absolute path: %w", err) } - exists := git.BranchExists(pc.BarePath, branch) - if exists { - worktrees, err := git.ListWorktrees(pc.BarePath) - if err != nil { - return fmt.Errorf("listing worktrees: %w", err) - } - for _, wt := range worktrees { - if wt.Branch == branch { - ui.PrintInfo(fmt.Sprintf("Worktree already exists at %s", wt.Path)) - return nil - } + // Check whether a workspace already exists for this branch + existingWorkspaces, err := pc.WorkspaceManager().ListWorkspaces() + if err != nil { + return fmt.Errorf("listing workspaces: %w", err) + } + for _, ws := range existingWorkspaces { + if ws.Branch == branch { + ui.PrintInfo(fmt.Sprintf("Workspace already exists at %s", ws.Path)) + return nil } } - ui.PrintStep(fmt.Sprintf("Creating worktree for branch '%s' from '%s'", branch, baseBranch)) + ui.PrintStep(fmt.Sprintf("Creating workspace for branch '%s' from '%s'", branch, baseBranch)) ui.PrintInfo(fmt.Sprintf("Path: %s", absWorktreePath)) if !dryRun { - if err := git.CreateWorktree(pc.BarePath, absWorktreePath, branch, baseBranch); err != nil { - return fmt.Errorf("creating worktree: %w", err) + if _, err := pc.WorkspaceManager().CreateWorkspace(branch, baseBranch, absWorktreePath); err != nil { + return fmt.Errorf("creating workspace: %w", err) } } else { - ui.PrintInfo("[DRY RUN] Would create worktree") + ui.PrintInfo("[DRY RUN] Would create workspace") } - // Set up branch tracking unless --no-track is specified + // Set up branch tracking for worktree mode only (CoW clones track independently) noTrack := mustGetBool(cmd, "no-track") - if !dryRun && !noTrack { + if !dryRun && !noTrack && pc.Mode == workspace.ModeWorktree { if err := git.SetBranchUpstream(pc.BarePath, branch, "origin"); err != nil { // Non-fatal - just inform user if verbose if verbose { diff --git a/internal/cli/work_cow_test.go b/internal/cli/work_cow_test.go new file mode 100644 index 0000000..5b948b6 --- /dev/null +++ b/internal/cli/work_cow_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkCmd_CowMode_CreatesWorkspace(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().StringP("base", "b", "", "") + cmd.Flags().Bool("no-track", false, "") + cmd.Flags().Bool("skip-scaffold", false, "") + cmd.Flags().Bool("dry-run", false, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = workCmd.RunE(cmd, []string{"feature/cow-test"}) + require.NoError(t, err) + + // CoW workspace directory should exist + expectedPath := filepath.Join(projectRoot, "feature-cow-test") + assert.DirExists(t, expectedPath, "CoW workspace directory should exist") + + // Should contain a .git directory (normal clone) + assert.DirExists(t, filepath.Join(expectedPath, ".git"), "workspace should be a normal git clone") +} + +func TestWorkCmd_CowMode_WorkspaceOnCorrectBranch(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().StringP("base", "b", "", "") + cmd.Flags().Bool("no-track", false, "") + cmd.Flags().Bool("skip-scaffold", false, "") + cmd.Flags().Bool("dry-run", false, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = workCmd.RunE(cmd, []string{"feature/branch-test"}) + require.NoError(t, err) + + expectedPath := filepath.Join(projectRoot, "feature-branch-test") + branchCmd := exec.Command("git", "-C", expectedPath, "branch", "--show-current") + output, err := branchCmd.Output() + require.NoError(t, err) + assert.Equal(t, "feature/branch-test", strings.TrimSpace(string(output))) +} + +func TestWorkCmd_CowMode_DryRunDoesNotCreate(t *testing.T) { + projectRoot, mainPath := createTestCowProjectForCLI(t) + + originalCWD, err := os.Getwd() + require.NoError(t, err) + defer func() { _ = os.Chdir(originalCWD) }() + + require.NoError(t, os.Chdir(mainPath)) + + cmd := &cobra.Command{} + cmd.Flags().StringP("base", "b", "", "") + cmd.Flags().Bool("no-track", false, "") + cmd.Flags().Bool("skip-scaffold", false, "") + cmd.Flags().Bool("dry-run", true, "") + cmd.Flags().Bool("verbose", false, "") + cmd.Flags().Bool("quiet", false, "") + + err = workCmd.RunE(cmd, []string{"feature/dry-run-test"}) + require.NoError(t, err) + + // Directory should NOT be created in dry-run mode + expectedPath := filepath.Join(projectRoot, "feature-dry-run-test") + _, statErr := os.Stat(expectedPath) + assert.True(t, os.IsNotExist(statErr), "workspace should not be created in dry-run mode") +} diff --git a/internal/config/config.go b/internal/config/config.go index 39d6b1f..3efaac7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,6 +38,7 @@ type Config struct { SiteName string `mapstructure:"site_name"` Preset string `mapstructure:"preset"` DefaultBranch string `mapstructure:"default_branch"` + WorkspaceMode string `mapstructure:"workspace_mode"` Scaffold ScaffoldConfig `mapstructure:"scaffold"` Cleanup CleanupConfig `mapstructure:"cleanup"` Tools map[string]ToolConfig `mapstructure:"tools"` @@ -373,6 +374,9 @@ func SaveProject(path string, config *Config) error { if config.DefaultBranch != "" { setValue("default_branch", config.DefaultBranch) } + if config.WorkspaceMode != "" { + setValue("workspace_mode", config.WorkspaceMode) + } // Update sync config if any values are set if config.Sync.Upstream != "" || config.Sync.Strategy != "" || config.Sync.Remote != "" || config.Sync.AutoStash != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 90b274d..9083398 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -189,6 +189,54 @@ scaffold: assert.False(t, *step.Enabled) } +func TestConfig_WorkspaceModeField_Cow(t *testing.T) { + tmpDir := t.TempDir() + + configContent := `preset: laravel +default_branch: main +workspace_mode: cow +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "arbor.yaml"), []byte(configContent), 0644)) + + cfg, err := LoadProject(tmpDir) + + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, "cow", cfg.WorkspaceMode) +} + +func TestConfig_WorkspaceModeField_Worktree(t *testing.T) { + tmpDir := t.TempDir() + + configContent := `preset: laravel +default_branch: main +workspace_mode: worktree +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "arbor.yaml"), []byte(configContent), 0644)) + + cfg, err := LoadProject(tmpDir) + + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, "worktree", cfg.WorkspaceMode) +} + +func TestConfig_WorkspaceModeDefault(t *testing.T) { + tmpDir := t.TempDir() + + // Config without workspace_mode should default to empty string (treated as worktree) + configContent := `preset: laravel +default_branch: main +` + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "arbor.yaml"), []byte(configContent), 0644)) + + cfg, err := LoadProject(tmpDir) + + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.Equal(t, "", cfg.WorkspaceMode, "workspace_mode should default to empty string for backward compatibility") +} + func loadGlobalFromTestDir(testDir string) (*GlobalConfig, error) { v := viper.New() diff --git a/internal/config/config_write_test.go b/internal/config/config_write_test.go index 373a24f..c8d5af8 100644 --- a/internal/config/config_write_test.go +++ b/internal/config/config_write_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -76,13 +77,13 @@ custom_field: custom_value } contentStr := string(content) - if !contains(contentStr, "site_name: NewSite") { + if !strings.Contains(contentStr, "site_name: NewSite") { t.Errorf("expected updated site_name not found in:\n%s", contentStr) } - if !contains(contentStr, "preset: old_preset") { + if !strings.Contains(contentStr, "preset: old_preset") { t.Errorf("expected preserved preset not found in:\n%s", contentStr) } - if !contains(contentStr, "default_branch: old_branch") { + if !strings.Contains(contentStr, "default_branch: old_branch") { t.Errorf("expected preserved default_branch not found in:\n%s", contentStr) } }) @@ -121,16 +122,77 @@ custom_field: custom_value }) } -// Helper function to check if a string contains a substring -func contains(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) -} +func TestSaveProject_WorkspaceMode(t *testing.T) { + t.Run("saves workspace_mode cow", func(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &Config{ + SiteName: "TestProject", + DefaultBranch: "main", + WorkspaceMode: "cow", + } -func containsHelper(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true + if err := SaveProject(tmpDir, cfg); err != nil { + t.Fatalf("SaveProject failed: %v", err) + } + + loaded, err := LoadProject(tmpDir) + if err != nil { + t.Fatalf("LoadProject failed: %v", err) } - } - return false + + if loaded.WorkspaceMode != "cow" { + t.Errorf("WorkspaceMode mismatch: expected 'cow', got '%s'", loaded.WorkspaceMode) + } + }) + + t.Run("saves workspace_mode worktree", func(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &Config{ + SiteName: "TestProject", + DefaultBranch: "main", + WorkspaceMode: "worktree", + } + + if err := SaveProject(tmpDir, cfg); err != nil { + t.Fatalf("SaveProject failed: %v", err) + } + + loaded, err := LoadProject(tmpDir) + if err != nil { + t.Fatalf("LoadProject failed: %v", err) + } + + if loaded.WorkspaceMode != "worktree" { + t.Errorf("WorkspaceMode mismatch: expected 'worktree', got '%s'", loaded.WorkspaceMode) + } + }) + + t.Run("preserves existing workspace_mode when saving other fields", func(t *testing.T) { + tmpDir := t.TempDir() + + initial := `site_name: old +default_branch: main +workspace_mode: cow +` + configPath := filepath.Join(tmpDir, "arbor.yaml") + if err := os.WriteFile(configPath, []byte(initial), 0644); err != nil { + t.Fatalf("failed to write initial config: %v", err) + } + + // Save only site_name update – workspace_mode should be preserved + cfg := &Config{SiteName: "new"} + if err := SaveProject(tmpDir, cfg); err != nil { + t.Fatalf("SaveProject failed: %v", err) + } + + content, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("failed to read config: %v", err) + } + if !strings.Contains(string(content), "workspace_mode: cow") { + t.Errorf("workspace_mode should be preserved, got:\n%s", content) + } + }) } diff --git a/internal/ui/interactive.go b/internal/ui/interactive.go index a0d8f33..041794d 100644 --- a/internal/ui/interactive.go +++ b/internal/ui/interactive.go @@ -157,6 +157,38 @@ func Confirm(message string) (bool, error) { return confirmed, nil } +// PromptWorkspaceMode presents an interactive select asking the user to choose +// between worktree mode and copy-on-write mode. Returns the chosen mode string +// ("worktree" or "cow"). +func PromptWorkspaceMode() (string, error) { + var selected string + + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Workspace mode"). + Description("How should Arbor manage parallel workspaces for this project?"). + Options( + huh.NewOption( + "Worktrees (traditional) — shared object store, slightly less disk", + "worktree", + ), + huh.NewOption( + "Copy-on-write — independent clones, best disk efficiency on APFS/Btrfs", + "cow", + ), + ). + Value(&selected), + ), + ).WithTheme(huh.ThemeCatppuccin()) + + if err := form.Run(); err != nil { + return "", NormalizeAbort(err) + } + + return selected, nil +} + func PromptRepoURL() (string, error) { var repo string diff --git a/internal/workspace/cow.go b/internal/workspace/cow.go new file mode 100644 index 0000000..24a300c --- /dev/null +++ b/internal/workspace/cow.go @@ -0,0 +1,161 @@ +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// DetectCowSupport probes whether the filesystem at dir supports copy-on-write +// (reflink) copies. +// +// - macOS / APFS: attempts `cp -c` (clonefile syscall). +// - Linux / Btrfs|XFS: attempts `cp --reflink=always`. +// - All other platforms: returns false, nil. +// +// A returned error means the probe itself failed for an unexpected reason; it +// does NOT mean CoW is unsupported (that is indicated by supported=false). +func DetectCowSupport(dir string) (supported bool, err error) { + // Write a tiny temp file to probe with + srcFile, err := os.CreateTemp(dir, "arbor-cow-probe-src-*") + if err != nil { + return false, fmt.Errorf("creating probe source: %w", err) + } + srcPath := srcFile.Name() + _ = srcFile.Close() + defer os.Remove(srcPath) //nolint:errcheck + + dstPath := srcPath + ".dst" + defer os.Remove(dstPath) //nolint:errcheck + + switch runtime.GOOS { + case "darwin": + cmd := exec.Command("cp", "-c", srcPath, dstPath) + if runErr := cmd.Run(); runErr != nil { + return false, nil // clonefile not available (non-APFS volume) + } + return true, nil + case "linux": + cmd := exec.Command("cp", "--reflink=always", srcPath, dstPath) + if runErr := cmd.Run(); runErr != nil { + return false, nil // reflink not available + } + return true, nil + default: + return false, nil + } +} + +// CowSupportWarning returns a human-readable warning message explaining that +// CoW is not supported on the current filesystem and that a regular copy will +// be used instead. +func CowSupportWarning() string { + switch runtime.GOOS { + case "darwin": + return "Copy-on-write is not supported on this volume (requires APFS). " + + "Workspaces will be created using a regular copy instead. " + + "Consider using an APFS-formatted volume for full CoW benefits." + case "linux": + return "Copy-on-write is not supported on this filesystem (requires Btrfs or XFS with reflink). " + + "Workspaces will be created using a regular copy instead." + default: + return "Copy-on-write is not supported on this platform. " + + "Workspaces will be created using a regular copy instead." + } +} + +// CopyCoW copies the directory at src to dst using copy-on-write semantics +// when the filesystem supports it, falling back to a regular recursive copy +// otherwise. +// +// dst must not already exist. src must be an existing directory. +func CopyCoW(src, dst string) error { + // Validate source exists + if _, err := os.Stat(src); err != nil { + return fmt.Errorf("source directory %q: %w", src, err) + } + + // Validate destination does not exist + if _, err := os.Stat(dst); err == nil { + return fmt.Errorf("destination directory %q already exists", dst) + } + + // Ensure parent of dst exists + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return fmt.Errorf("creating parent of destination: %w", err) + } + + supported, err := DetectCowSupport(filepath.Dir(dst)) + if err != nil { + // Detection failed — fall back to regular copy + supported = false + } + + return copyDir(src, dst, supported) +} + +// copyDir copies src directory to dst. +// When cowSupported is true and the platform supports it, CoW clone flags are +// used; otherwise a plain recursive copy is performed. +func copyDir(src, dst string, cowSupported bool) error { + switch runtime.GOOS { + case "darwin": + // -R recursive + // -c clone (CoW) when cowSupported, otherwise ignored (falls back gracefully) + args := []string{"-R"} + if cowSupported { + args = append(args, "-c") + } + // Ensure src doesn't have trailing slash so cp places it AT dst, not inside + args = append(args, src, dst) + cmd := exec.Command("cp", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("cp failed: %w\n%s", err, string(output)) + } + return nil + case "linux": + reflinkFlag := "--reflink=auto" + cmd := exec.Command("cp", "-R", reflinkFlag, src, dst) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("cp failed: %w\n%s", err, string(output)) + } + return nil + default: + // Fallback: use os package recursive copy + return copyDirFallback(src, dst) + } +} + +// copyDirFallback is a pure-Go recursive directory copy used on platforms where +// neither APFS nor reflink is available. +func copyDirFallback(src, dst string) error { + return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(target, 0755) + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("reading %s: %w", path, err) + } + + info, err := d.Info() + if err != nil { + return err + } + + return os.WriteFile(target, data, info.Mode()) + }) +} diff --git a/internal/workspace/cow_test.go b/internal/workspace/cow_test.go new file mode 100644 index 0000000..3fdf138 --- /dev/null +++ b/internal/workspace/cow_test.go @@ -0,0 +1,77 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetectCowSupport_ReturnsResult(t *testing.T) { + tmpDir := t.TempDir() + + // DetectCowSupport should not panic and should return a bool + nil error + // on any supported platform. We don't assert the bool because it depends + // on the filesystem, but we do assert that it doesn't error unexpectedly. + supported, err := DetectCowSupport(tmpDir) + // err is allowed to be non-nil on unsupported platforms, but the function + // must not panic. We simply log the result. + t.Logf("CoW support: supported=%v, err=%v", supported, err) +} + +func TestCopyCoW_NonExistentSource(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "does-not-exist") + dst := filepath.Join(tmpDir, "dst") + + err := CopyCoW(src, dst) + assert.Error(t, err, "CopyCoW should error when source does not exist") +} + +func TestCopyCoW_DestinationAlreadyExists(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "src") + dst := filepath.Join(tmpDir, "dst") + + require.NoError(t, os.MkdirAll(src, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello"), 0644)) + require.NoError(t, os.MkdirAll(dst, 0755)) + + err := CopyCoW(src, dst) + assert.Error(t, err, "CopyCoW should error when destination already exists") +} + +func TestCopyCoW_CopiesFiles(t *testing.T) { + tmpDir := t.TempDir() + src := filepath.Join(tmpDir, "src") + dst := filepath.Join(tmpDir, "dst") + + require.NoError(t, os.MkdirAll(src, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "file.txt"), []byte("hello world"), 0644)) + require.NoError(t, os.MkdirAll(filepath.Join(src, "subdir"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(src, "subdir", "nested.txt"), []byte("nested"), 0644)) + + err := CopyCoW(src, dst) + require.NoError(t, err) + + // Destination should exist + _, statErr := os.Stat(dst) + require.NoError(t, statErr, "destination directory should exist") + + // Files should be copied + content, readErr := os.ReadFile(filepath.Join(dst, "file.txt")) + require.NoError(t, readErr) + assert.Equal(t, "hello world", string(content)) + + nestedContent, readErr := os.ReadFile(filepath.Join(dst, "subdir", "nested.txt")) + require.NoError(t, readErr) + assert.Equal(t, "nested", string(nestedContent)) +} + +func TestCowSupport_WarnMessage(t *testing.T) { + // CowSupportWarning should return a non-empty string for any platform + msg := CowSupportWarning() + assert.NotEmpty(t, msg) +} diff --git a/internal/workspace/manager.go b/internal/workspace/manager.go new file mode 100644 index 0000000..e0acd3b --- /dev/null +++ b/internal/workspace/manager.go @@ -0,0 +1,359 @@ +package workspace + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + arborerrors "github.com/artisanexperiences/arbor/internal/errors" + "github.com/artisanexperiences/arbor/internal/utils" +) + +// Manager manages workspaces in an Arbor project, abstracting over the +// differences between worktree mode and cow mode. +type Manager struct { + Mode string + ProjectRoot string + BarePath string // empty for cow mode + DefaultBranch string +} + +// NewManager constructs a Manager from a discovered ProjectInfo and the +// project's default branch name. +func NewManager(info *ProjectInfo, defaultBranch string) *Manager { + return &Manager{ + Mode: info.Mode, + ProjectRoot: info.ProjectRoot, + BarePath: info.BarePath, + DefaultBranch: defaultBranch, + } +} + +// FindProjectRoot searches startPath and its parents for an Arbor project +// root. It looks for a .bare directory (worktree mode) or a .arbor directory +// (cow mode). If both are present, .bare takes precedence for backward +// compatibility. +// +// Returns ErrProjectNotFound (wrapping arborerrors.ErrWorktreeNotFound) when +// neither marker is found. +func FindProjectRoot(startPath string) (*ProjectInfo, error) { + absPath, err := filepath.Abs(startPath) + if err != nil { + return nil, fmt.Errorf("resolving path: %w", err) + } + + current := absPath + for { + // Check for .bare (worktree mode) first — highest precedence + barePath := filepath.Join(current, ".bare") + if info, err := os.Stat(barePath); err == nil && info.IsDir() { + return &ProjectInfo{ + Mode: ModeWorktree, + ProjectRoot: current, + BarePath: barePath, + }, nil + } + + // Check for .arbor (cow mode) + arborDir := filepath.Join(current, ".arbor") + if info, err := os.Stat(arborDir); err == nil && info.IsDir() { + return &ProjectInfo{ + Mode: ModeCow, + ProjectRoot: current, + BarePath: "", + }, nil + } + + parent := filepath.Dir(current) + if parent == current { + // Reached filesystem root without finding a marker + return nil, fmt.Errorf("arbor project not found in %s or any parent directory: %w", + absPath, arborerrors.ErrWorktreeNotFound) + } + current = parent + } +} + +// CreateWorkspace creates a new workspace for the given branch. The workspace +// path is derived from the project root and the sanitised branch name unless +// customPath is non-empty, in which case customPath is used directly. +// +// Returns the absolute path of the created workspace. +func (m *Manager) CreateWorkspace(branch, baseBranch, customPath string) (string, error) { + wsPath := customPath + if wsPath == "" { + wsPath = filepath.Join(m.ProjectRoot, utils.SanitisePath(branch)) + } + + absPath, err := filepath.Abs(wsPath) + if err != nil { + return "", fmt.Errorf("resolving workspace path: %w", err) + } + + switch m.Mode { + case ModeWorktree: + return absPath, m.createWorktree(branch, baseBranch, absPath) + case ModeCow: + return absPath, m.createCowWorkspace(branch, baseBranch, absPath) + default: + return "", fmt.Errorf("unknown workspace mode: %q", m.Mode) + } +} + +// createWorktree handles workspace creation for worktree mode. +func (m *Manager) createWorktree(branch, baseBranch, absPath string) error { + if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil { + return fmt.Errorf("creating parent directory: %w", err) + } + + // Check if branch already exists + checkCmd := exec.Command("git", "-C", m.BarePath, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) + branchExists := checkCmd.Run() == nil + + var cmd *exec.Cmd + if branchExists { + cmd = exec.Command("git", "-C", m.BarePath, "worktree", "add", absPath, branch) + } else { + if baseBranch == "" { + baseBranch = m.DefaultBranch + } + cmd = exec.Command("git", "-C", m.BarePath, "worktree", "add", "-b", branch, absPath, baseBranch) + } + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git worktree add failed: %w\n%s", err, string(output)) + } + return nil +} + +// createCowWorkspace handles workspace creation for cow mode. +// It copies the default-branch workspace using CoW semantics and then +// switches the clone to the requested branch starting from baseBranch. +// If baseBranch is empty it defaults to m.DefaultBranch. +func (m *Manager) createCowWorkspace(branch, baseBranch, absPath string) error { + if baseBranch == "" { + baseBranch = m.DefaultBranch + } + + srcPath := filepath.Join(m.ProjectRoot, m.DefaultBranch) + if _, err := os.Stat(srcPath); err != nil { + return fmt.Errorf("source workspace %q not found: %w", srcPath, err) + } + + // Warn if source has uncommitted changes (non-fatal) + dirtyCmd := exec.Command("git", "-C", srcPath, "status", "--porcelain") + if output, err := dirtyCmd.Output(); err == nil && len(strings.TrimSpace(string(output))) > 0 { + fmt.Fprintf(os.Stderr, "warning: source workspace %q has uncommitted changes; "+ + "these will be included in the new workspace\n", srcPath) + } + + if err := CopyCoW(srcPath, absPath); err != nil { + return fmt.Errorf("copying workspace: %w", err) + } + + // Check if the branch already exists in the clone's repo + checkCmd := exec.Command("git", "-C", absPath, "rev-parse", "--verify", "--quiet", "refs/heads/"+branch) + branchExists := checkCmd.Run() == nil + + var branchCmd *exec.Cmd + if branchExists { + branchCmd = exec.Command("git", "-C", absPath, "checkout", branch) + } else { + // Create the new branch starting from baseBranch. This honours -b + // from callers (e.g. arbor work feature/foo -b develop). + branchCmd = exec.Command("git", "-C", absPath, "checkout", "-b", branch, baseBranch) + } + + if output, err := branchCmd.CombinedOutput(); err != nil { + // Clean up on failure + _ = os.RemoveAll(absPath) + return fmt.Errorf("checking out branch %q: %w\n%s", branch, err, string(output)) + } + + return nil +} + +// RemoveWorkspace removes the workspace at the given path. In worktree mode +// this deregisters the worktree from git; in cow mode it removes the directory. +// The force flag is passed to git worktree remove in worktree mode. +func (m *Manager) RemoveWorkspace(wsPath string, force bool) error { + switch m.Mode { + case ModeWorktree: + return m.removeWorktree(wsPath, force) + case ModeCow: + return os.RemoveAll(wsPath) + default: + return fmt.Errorf("unknown workspace mode: %q", m.Mode) + } +} + +// removeWorktree handles workspace removal for worktree mode. +func (m *Manager) removeWorktree(wsPath string, force bool) error { + args := []string{"-C", m.BarePath, "worktree", "remove"} + if force { + args = append(args, "-f") + } + args = append(args, wsPath) + + cmd := exec.Command("git", args...) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git worktree remove failed: %w\n%s", err, string(output)) + } + return nil +} + +// ListWorkspaces returns all workspaces in the project. +func (m *Manager) ListWorkspaces() ([]Workspace, error) { + switch m.Mode { + case ModeWorktree: + return m.listWorktrees() + case ModeCow: + return m.listCowWorkspaces() + default: + return nil, fmt.Errorf("unknown workspace mode: %q", m.Mode) + } +} + +// listWorktrees lists git worktrees for worktree mode. +func (m *Manager) listWorktrees() ([]Workspace, error) { + cmd := exec.Command("git", "-C", m.BarePath, "worktree", "list", "--porcelain") + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git worktree list failed: %w", err) + } + + parentDir := filepath.Dir(m.BarePath) + + var workspaces []Workspace + var currentPath string + var currentBranch string + + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "worktree ") { + currentPath = strings.TrimSpace(strings.TrimPrefix(line, "worktree ")) + if !filepath.IsAbs(currentPath) && parentDir != "" { + currentPath = filepath.Join(parentDir, currentPath) + } + } else if strings.HasPrefix(line, "branch refs/heads/") { + currentBranch = strings.TrimSpace(strings.TrimPrefix(line, "branch refs/heads/")) + if currentPath != "" && currentBranch != "" { + workspaces = append(workspaces, Workspace{ + Path: currentPath, + Branch: currentBranch, + }) + currentPath = "" + } + } + } + + return workspaces, nil +} + +// listCowWorkspaces scans the project root for subdirectories that are git +// repos (contain a .git entry) and reads their current branch. +func (m *Manager) listCowWorkspaces() ([]Workspace, error) { + entries, err := os.ReadDir(m.ProjectRoot) + if err != nil { + return nil, fmt.Errorf("reading project root: %w", err) + } + + var workspaces []Workspace + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + dirPath := filepath.Join(m.ProjectRoot, entry.Name()) + + // Skip the .arbor marker directory + if entry.Name() == ".arbor" { + continue + } + + // Check if it is a git repository + gitPath := filepath.Join(dirPath, ".git") + if _, err := os.Stat(gitPath); err != nil { + continue + } + + // Get the current branch + branchCmd := exec.Command("git", "-C", dirPath, "branch", "--show-current") + branchOutput, err := branchCmd.Output() + if err != nil { + continue + } + branch := strings.TrimSpace(string(branchOutput)) + if branch == "" { + continue + } + + workspaces = append(workspaces, Workspace{ + Path: dirPath, + Branch: branch, + }) + } + + return workspaces, nil +} + +// ListWorkspacesDetailed returns workspaces enriched with IsMain, IsCurrent, +// and IsMerged metadata. currentPath is the path of the caller's workspace +// (used to set IsCurrent). +func (m *Manager) ListWorkspacesDetailed(currentPath string) ([]Workspace, error) { + workspaces, err := m.ListWorkspaces() + if err != nil { + return nil, err + } + + currentPathEval, _ := filepath.EvalSymlinks(currentPath) + + for i := range workspaces { + ws := &workspaces[i] + ws.IsMain = ws.Branch == m.DefaultBranch + + wsPathEval, _ := filepath.EvalSymlinks(ws.Path) + ws.IsCurrent = wsPathEval == currentPathEval + + if ws.Branch != m.DefaultBranch { + ws.IsMerged = m.isMerged(ws.Branch) + } + } + + return workspaces, nil +} + +// isMerged checks whether branch has been merged into m.DefaultBranch and is +// not the same as the default branch tip. +func (m *Manager) isMerged(branch string) bool { + var gitDir string + switch m.Mode { + case ModeWorktree: + gitDir = m.BarePath + case ModeCow: + // Use the default-branch workspace's repo for merge checks + gitDir = filepath.Join(m.ProjectRoot, m.DefaultBranch) + default: + return false + } + + // feature is an ancestor of default (i.e., merged) + ancestorCmd := exec.Command("git", "-C", gitDir, "merge-base", "--is-ancestor", branch, m.DefaultBranch) + if err := ancestorCmd.Run(); err != nil { + return false + } + + // default is NOT an ancestor of feature (i.e., feature has no extra commits beyond default) + reverseCmd := exec.Command("git", "-C", gitDir, "merge-base", "--is-ancestor", m.DefaultBranch, branch) + if err := reverseCmd.Run(); err != nil { + return true // default is not ancestor of feature → feature was merged but differs + } + + // Both are ancestors of each other → same commit → not "merged" in the PR sense + return false +} diff --git a/internal/workspace/manager_test.go b/internal/workspace/manager_test.go new file mode 100644 index 0000000..812b2bd --- /dev/null +++ b/internal/workspace/manager_test.go @@ -0,0 +1,562 @@ +package workspace + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createTestWorktreeProject creates a bare-repo project layout matching the +// worktree mode structure: +// +// / +// .bare/ ← bare git repo +// main/ ← main worktree +func createTestWorktreeProject(t *testing.T) (projectRoot, barePath, mainPath string) { + t.Helper() + + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + projectRoot = filepath.Join(tmpDir, "project") + barePath = filepath.Join(projectRoot, ".bare") + mainPath = filepath.Join(projectRoot, "main") + + // Set up a source repo with an initial commit + for _, args := range [][]string{ + {"init", "-b", "main", sourceDir}, + } { + cmd := exec.Command("git", args...) + require.NoError(t, cmd.Run()) + } + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("test"), 0644)) + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "Initial commit"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + // Clone to bare + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + cmd := exec.Command("git", "clone", "--bare", sourceDir, barePath) + require.NoError(t, cmd.Run()) + + // Create main worktree + cmd = exec.Command("git", "-C", barePath, "worktree", "add", mainPath, "main") + require.NoError(t, cmd.Run()) + + return projectRoot, barePath, mainPath +} + +// createTestCowProject creates a CoW-mode project layout: +// +// / +// .arbor/ ← CoW project marker +// main/ ← normal git clone (default branch) +func createTestCowProject(t *testing.T) (projectRoot, mainPath string) { + t.Helper() + + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + projectRoot = filepath.Join(tmpDir, "project") + mainPath = filepath.Join(projectRoot, "main") + + // Set up a source repo with an initial commit + for _, args := range [][]string{ + {"init", "-b", "main", sourceDir}, + } { + cmd := exec.Command("git", args...) + require.NoError(t, cmd.Run()) + } + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + require.NoError(t, os.WriteFile(filepath.Join(sourceDir, "README.md"), []byte("test"), 0644)) + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "Initial commit"}, + } { + cmd := exec.Command("git", args...) + cmd.Dir = sourceDir + require.NoError(t, cmd.Run()) + } + + // Normal clone as main workspace + require.NoError(t, os.MkdirAll(projectRoot, 0755)) + cmd := exec.Command("git", "clone", sourceDir, mainPath) + require.NoError(t, cmd.Run()) + + // Create .arbor marker directory + arborDir := filepath.Join(projectRoot, ".arbor") + require.NoError(t, os.MkdirAll(arborDir, 0755)) + + // Write metadata + metadata := "workspace_mode: cow\nremote_url: " + sourceDir + "\ndefault_branch: main\n" + require.NoError(t, os.WriteFile(filepath.Join(arborDir, "metadata.yaml"), []byte(metadata), 0644)) + + return projectRoot, mainPath +} + +// --- FindProjectRoot --- + +func TestFindProjectRoot_WorktreeMode(t *testing.T) { + projectRoot, barePath, mainPath := createTestWorktreeProject(t) + + // Should find from project root + info, err := FindProjectRoot(projectRoot) + require.NoError(t, err) + assert.Equal(t, ModeWorktree, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) + assert.Equal(t, barePath, info.BarePath) + + // Should also find from inside a worktree + info, err = FindProjectRoot(mainPath) + require.NoError(t, err) + assert.Equal(t, ModeWorktree, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) + + // Should find from a subdirectory within a worktree + subDir := filepath.Join(mainPath, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + info, err = FindProjectRoot(subDir) + require.NoError(t, err) + assert.Equal(t, ModeWorktree, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) +} + +func TestFindProjectRoot_CowMode(t *testing.T) { + projectRoot, mainPath := createTestCowProject(t) + + // Should find from project root + info, err := FindProjectRoot(projectRoot) + require.NoError(t, err) + assert.Equal(t, ModeCow, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) + assert.Empty(t, info.BarePath) // CoW mode has no bare path + + // Should also find from inside a workspace clone + info, err = FindProjectRoot(mainPath) + require.NoError(t, err) + assert.Equal(t, ModeCow, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) + + // Should find from a subdirectory within a workspace + subDir := filepath.Join(mainPath, "subdir") + require.NoError(t, os.MkdirAll(subDir, 0755)) + info, err = FindProjectRoot(subDir) + require.NoError(t, err) + assert.Equal(t, ModeCow, info.Mode) + assert.Equal(t, projectRoot, info.ProjectRoot) +} + +func TestFindProjectRoot_NotFound(t *testing.T) { + tmpDir := t.TempDir() + + _, err := FindProjectRoot(tmpDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "arbor project not found") +} + +func TestFindProjectRoot_WorktreeTakesPrecedenceOverCow(t *testing.T) { + // If a directory somehow has both .bare and .arbor, .bare wins + // (backward compatibility guard) + projectRoot, barePath, _ := createTestWorktreeProject(t) + + // Also create an .arbor marker + arborDir := filepath.Join(projectRoot, ".arbor") + require.NoError(t, os.MkdirAll(arborDir, 0755)) + + info, err := FindProjectRoot(projectRoot) + require.NoError(t, err) + assert.Equal(t, ModeWorktree, info.Mode) + assert.Equal(t, barePath, info.BarePath) +} + +// --- Manager creation --- + +func TestNewManager_WorktreeMode(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + info := &ProjectInfo{ + Mode: ModeWorktree, + ProjectRoot: projectRoot, + BarePath: barePath, + } + m := NewManager(info, "main") + + assert.Equal(t, ModeWorktree, m.Mode) + assert.Equal(t, projectRoot, m.ProjectRoot) + assert.Equal(t, barePath, m.BarePath) + assert.Equal(t, "main", m.DefaultBranch) +} + +func TestNewManager_CowMode(t *testing.T) { + projectRoot, _ := createTestCowProject(t) + + info := &ProjectInfo{ + Mode: ModeCow, + ProjectRoot: projectRoot, + BarePath: "", + } + m := NewManager(info, "main") + + assert.Equal(t, ModeCow, m.Mode) + assert.Equal(t, projectRoot, m.ProjectRoot) + assert.Empty(t, m.BarePath) + assert.Equal(t, "main", m.DefaultBranch) +} + +// --- ListWorkspaces --- + +func TestManager_ListWorkspaces_WorktreeMode(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + require.Len(t, workspaces, 1) + assert.Equal(t, "main", workspaces[0].Branch) +} + +func TestManager_ListWorkspaces_WorktreeMode_MultipleWorkspaces(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + // Create a feature worktree + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "feature/foo", featurePath, "main") + require.NoError(t, cmd.Run()) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + assert.Len(t, workspaces, 2) + + branches := make(map[string]bool) + for _, ws := range workspaces { + branches[ws.Branch] = true + } + assert.True(t, branches["main"]) + assert.True(t, branches["feature/foo"]) +} + +func TestManager_ListWorkspaces_CowMode(t *testing.T) { + projectRoot, _ := createTestCowProject(t) + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + require.Len(t, workspaces, 1) + assert.Equal(t, "main", workspaces[0].Branch) +} + +func TestManager_ListWorkspaces_CowMode_MultipleWorkspaces(t *testing.T) { + projectRoot, mainPath := createTestCowProject(t) + + // Simulate a CoW clone by copying main (using regular copy for test portability) + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("cp", "-R", mainPath+"/", featurePath+"/") + require.NoError(t, cmd.Run()) + + // Switch to a new branch in the CoW clone + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"checkout", "-b", "feature/foo"}, + } { + c := exec.Command("git", args...) + c.Dir = featurePath + require.NoError(t, c.Run()) + } + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + assert.Len(t, workspaces, 2) + + branches := make(map[string]bool) + for _, ws := range workspaces { + branches[ws.Branch] = true + } + assert.True(t, branches["main"]) + assert.True(t, branches["feature/foo"]) +} + +// --- CreateWorkspace --- + +func TestManager_CreateWorkspace_WorktreeMode_NewBranch(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + wsPath, err := m.CreateWorkspace("feature/new", "main", "") + require.NoError(t, err) + + // Workspace directory should exist + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace directory should exist") + + // Branch should exist in bare repo + cmd := exec.Command("git", "-C", barePath, "rev-parse", "--verify", "refs/heads/feature/new") + assert.NoError(t, cmd.Run(), "feature branch should exist in bare repo") + + // Should be listed + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + branches := make(map[string]bool) + for _, ws := range workspaces { + branches[ws.Branch] = true + } + assert.True(t, branches["feature/new"]) +} + +func TestManager_CreateWorkspace_WorktreeMode_ExistingBranch(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + // Pre-create a branch in the bare repo + cmd := exec.Command("git", "-C", barePath, "branch", "feature/existing", "main") + require.NoError(t, cmd.Run()) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + wsPath, err := m.CreateWorkspace("feature/existing", "main", "") + require.NoError(t, err) + + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace directory should exist") +} + +func TestManager_CreateWorkspace_CowMode_NewBranch(t *testing.T) { + projectRoot, _ := createTestCowProject(t) + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + wsPath, err := m.CreateWorkspace("feature/new", "main", "") + require.NoError(t, err) + + // Workspace directory should exist + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace directory should exist") + + // Should contain a .git directory (it's a full clone) + _, statErr = os.Stat(filepath.Join(wsPath, ".git")) + assert.NoError(t, statErr, "workspace should have .git directory") + + // Should be on the new branch + cmd := exec.Command("git", "-C", wsPath, "branch", "--show-current") + output, err := cmd.Output() + require.NoError(t, err) + assert.Equal(t, "feature/new", strings.TrimSpace(string(output))) + + // Should be listed + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + branches := make(map[string]bool) + for _, ws := range workspaces { + branches[ws.Branch] = true + } + assert.True(t, branches["feature/new"]) +} + +func TestManager_CreateWorkspace_CowMode_EmptyBaseBranchDefaultsToDefault(t *testing.T) { + projectRoot, _ := createTestCowProject(t) + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + // Passing an empty baseBranch should default to the manager's DefaultBranch ("main") + wsPath, err := m.CreateWorkspace("feature/defaultbase", "", "") + require.NoError(t, err) + + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace directory should exist") + + cmd := exec.Command("git", "-C", wsPath, "branch", "--show-current") + output, err := cmd.Output() + require.NoError(t, err) + assert.Equal(t, "feature/defaultbase", strings.TrimSpace(string(output))) +} + +func TestManager_CreateWorkspace_CowMode_CustomPath(t *testing.T) { + projectRoot, _ := createTestCowProject(t) + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + customPath := filepath.Join(projectRoot, "my-custom-dir") + wsPath, err := m.CreateWorkspace("feature/custom", "main", customPath) + require.NoError(t, err) + + assert.Equal(t, customPath, wsPath) + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace at custom path should exist") +} + +func TestManager_CreateWorkspace_WorktreeMode_CustomPath(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + customPath := filepath.Join(projectRoot, "my-custom-dir") + wsPath, err := m.CreateWorkspace("feature/custom", "main", customPath) + require.NoError(t, err) + + assert.Equal(t, customPath, wsPath) + _, statErr := os.Stat(wsPath) + assert.NoError(t, statErr, "workspace at custom path should exist") +} + +// --- RemoveWorkspace --- + +func TestManager_RemoveWorkspace_WorktreeMode(t *testing.T) { + projectRoot, barePath, _ := createTestWorktreeProject(t) + + // Create a feature worktree to remove + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "feature/foo", featurePath, "main") + require.NoError(t, cmd.Run()) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + err := m.RemoveWorkspace(featurePath, true) + require.NoError(t, err) + + // Directory should be gone + _, statErr := os.Stat(featurePath) + assert.True(t, os.IsNotExist(statErr), "feature workspace directory should be removed") + + // Should no longer be listed + workspaces, err := m.ListWorkspaces() + require.NoError(t, err) + for _, ws := range workspaces { + assert.NotEqual(t, "feature/foo", ws.Branch) + } +} + +func TestManager_RemoveWorkspace_CowMode(t *testing.T) { + projectRoot, mainPath := createTestCowProject(t) + + // Create a CoW clone to remove (using regular copy for portability) + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("cp", "-R", mainPath+"/", featurePath+"/") + require.NoError(t, cmd.Run()) + + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"checkout", "-b", "feature/foo"}, + } { + c := exec.Command("git", args...) + c.Dir = featurePath + require.NoError(t, c.Run()) + } + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + err := m.RemoveWorkspace(featurePath, true) + require.NoError(t, err) + + // Directory should be gone + _, statErr := os.Stat(featurePath) + assert.True(t, os.IsNotExist(statErr), "feature workspace directory should be removed") +} + +// --- ListWorkspacesDetailed --- + +func TestManager_ListWorkspacesDetailed_WorktreeMode(t *testing.T) { + projectRoot, barePath, mainPath := createTestWorktreeProject(t) + + // Create a feature worktree with a commit so we can check merge status + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "feature/foo", featurePath, "main") + require.NoError(t, cmd.Run()) + + info := &ProjectInfo{Mode: ModeWorktree, ProjectRoot: projectRoot, BarePath: barePath} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspacesDetailed(mainPath) + require.NoError(t, err) + assert.Len(t, workspaces, 2) + + for _, ws := range workspaces { + switch ws.Branch { + case "main": + assert.True(t, ws.IsMain) + assert.True(t, ws.IsCurrent) // we passed mainPath as current + case "feature/foo": + assert.False(t, ws.IsMain) + assert.False(t, ws.IsCurrent) + assert.False(t, ws.IsMerged) + } + } +} + +func TestManager_ListWorkspacesDetailed_CowMode(t *testing.T) { + projectRoot, mainPath := createTestCowProject(t) + + // Create a CoW clone + featurePath := filepath.Join(projectRoot, "feature-foo") + cmd := exec.Command("cp", "-R", mainPath+"/", featurePath+"/") + require.NoError(t, cmd.Run()) + + for _, args := range [][]string{ + {"config", "user.email", "test@example.com"}, + {"config", "user.name", "Test User"}, + {"checkout", "-b", "feature/foo"}, + } { + c := exec.Command("git", args...) + c.Dir = featurePath + require.NoError(t, c.Run()) + } + + info := &ProjectInfo{Mode: ModeCow, ProjectRoot: projectRoot} + m := NewManager(info, "main") + + workspaces, err := m.ListWorkspacesDetailed(mainPath) + require.NoError(t, err) + assert.Len(t, workspaces, 2) + + for _, ws := range workspaces { + switch ws.Branch { + case "main": + assert.True(t, ws.IsMain) + assert.True(t, ws.IsCurrent) + case "feature/foo": + assert.False(t, ws.IsMain) + assert.False(t, ws.IsCurrent) + } + } +} diff --git a/internal/workspace/types.go b/internal/workspace/types.go new file mode 100644 index 0000000..5e22a90 --- /dev/null +++ b/internal/workspace/types.go @@ -0,0 +1,44 @@ +// Package workspace provides a unified abstraction over git worktree projects +// (worktree mode) and copy-on-write clone projects (cow mode), allowing Arbor +// CLI commands to remain mode-agnostic. +package workspace + +const ( + // ModeWorktree is the traditional git worktree mode. + // Projects have a .bare/ directory and linked worktrees. + ModeWorktree = "worktree" + + // ModeCow is the copy-on-write clone mode. + // Projects have a .arbor/ marker directory and full git clones. + ModeCow = "cow" +) + +// Workspace represents a single workspace inside an Arbor project. +// In worktree mode this corresponds to a git worktree; in cow mode it is +// a full git clone. +type Workspace struct { + // Path is the absolute path to the workspace directory. + Path string + // Branch is the git branch currently checked out in this workspace. + Branch string + // IsMain is true when the branch matches the project default branch. + IsMain bool + // IsCurrent is true when this workspace matches the caller's CWD. + IsCurrent bool + // IsMerged is true when the branch has been merged into the default branch. + IsMerged bool +} + +// ProjectInfo holds the location and mode of an Arbor project, as discovered +// by FindProjectRoot. +type ProjectInfo struct { + // Mode is either ModeWorktree or ModeCow. + Mode string + // ProjectRoot is the absolute path of the project root directory. + // In worktree mode this is the parent of .bare/; in cow mode it is the + // parent of .arbor/. + ProjectRoot string + // BarePath is the absolute path to the .bare directory. + // This is empty in cow mode. + BarePath string +}