Skip to content

Commit 172c69a

Browse files
feat: separate local state from team config
Implement three-tier configuration architecture: - Project config (project-root/arbor.yaml) - scaffold steps, not versioned - Repository config (worktree/arbor.yaml) - team defaults, versioned - Local state (worktree/.arbor.local) - runtime state, gitignored Changes: - Add LocalState type with ReadLocalState/WriteLocalState functions - Migrate db_suffix from arbor.yaml to .arbor.local automatically - Update ScaffoldManager to use local state for db_suffix - Add repo config auto-detection in init command - Add --use-repo-config flag (defaults to true) - Add gitignore warning for .arbor.local - Update all tests to use .arbor.local - Update documentation with configuration hierarchy Benefits: - Clear separation of team config vs local state - No conflicts from db_suffix in version control - Easy team onboarding with shared scaffold config - Automatic migration for existing projects
1 parent ee55c56 commit 172c69a

15 files changed

Lines changed: 735 additions & 48 deletions

AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ arbor remove feature-my-feature # When done
4646

4747
### Config Files
4848

49-
| Config | Location | Purpose |
50-
|--------|----------|---------|
51-
| Project | `arbor.yaml` in worktree root | Project-specific settings |
52-
| Global | `~/.config/arbor/arbor.yaml` | User defaults |
49+
| Config | Location | Purpose | Versioned? |
50+
|--------|----------|---------|------------|
51+
| Project | `<project-root>/arbor.yaml` | Project-specific settings, scaffold config | No (not in a repo) |
52+
| Repository | `<worktree>/arbor.yaml` | Team defaults (copied during init) | Yes (committed to git) |
53+
| Local State | `<worktree>/.arbor.local` | Runtime state (db_suffix) | No (gitignored) |
54+
| Global | `~/.config/arbor/arbor.yaml` | User defaults | No (local machine) |
5355

5456
### Step Naming
5557

README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,81 @@ arbor scaffold main
184184

185185
## Configuration
186186

187-
Arbor uses a configuration file to define scaffold steps for `init` and `work` commands. Configuration is read from `arbor.yaml` in your project root.
187+
Arbor uses a three-tier configuration system to separate team configuration from local state.
188+
189+
### Configuration Hierarchy
190+
191+
#### 1. Project Config (`<project-root>/arbor.yaml`)
192+
193+
Located at the project root (alongside `.bare/`), this file contains:
194+
- Scaffold steps and cleanup steps
195+
- Preset selection
196+
- Tool configurations
197+
- Project-wide settings
198+
199+
This file is **not versioned** (the project root is not a git repository).
200+
201+
During `arbor init`, if an `arbor.yaml` file is found in the repository, you'll be prompted to copy it to the project root.
202+
203+
#### 2. Repository Config (`<worktree>/arbor.yaml`)
204+
205+
Located inside each worktree and **committed to git**, this file contains:
206+
- Team default scaffold steps
207+
- Shared cleanup steps
208+
- Tool configurations
209+
210+
This file serves as the source of truth for team configuration and is copied to the project root during `arbor init`.
211+
212+
#### 3. Local State (`<worktree>/.arbor.local`)
213+
214+
Located inside each worktree and **NOT versioned** (should be in `.gitignore`), this file contains:
215+
- `db_suffix` - unique database suffix for the worktree
216+
- Other worktree-specific runtime state
217+
218+
This file is automatically created by Arbor and should never be committed.
219+
220+
**Example `.gitignore` entry:**
221+
```
222+
.arbor.local
223+
```
224+
225+
**Example `.arbor.local` file:**
226+
```yaml
227+
db_suffix: "sunset"
228+
```
229+
230+
### Sharing Team Configuration
231+
232+
To share scaffold configuration with your team:
233+
234+
1. Create `arbor.yaml` in your repository with scaffold steps:
235+
```yaml
236+
preset: laravel
237+
scaffold:
238+
steps:
239+
- name: file.copy
240+
from: .env.example
241+
to: .env
242+
- name: db.create
243+
- name: php.composer
244+
args: ["install"]
245+
```
246+
247+
2. Commit and push to git:
248+
```bash
249+
git add arbor.yaml
250+
git commit -m "Add Arbor scaffold configuration"
251+
git push
252+
```
253+
254+
3. Team members run `arbor init`:
255+
```bash
256+
arbor init user/repo
257+
# → Found arbor.yaml in repository. Copy to project root for team config? [Y/n]
258+
# → Press Enter to use team config
259+
```
260+
261+
The config will be automatically copied to their project root and used for all worktrees.
188262

189263
### Scaffold Steps
190264

@@ -244,7 +318,7 @@ All steps support template variables that are replaced at runtime:
244318
- Suffix is generated once per `init` or `work` invocation and shared across all `db.create` steps
245319
- Auto-detects engine from `DB_CONNECTION` in `.env`
246320
- Retries up to 5 times on collision
247-
- Persists suffix to worktree-local `arbor.yaml` for cleanup
321+
- Persists suffix to `.arbor.local` for cleanup
248322

249323
**Multiple databases with shared suffix:**
250324

internal/cli/gitignore.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
7+
"github.com/michaeldyrynda/arbor/internal/git"
8+
"github.com/michaeldyrynda/arbor/internal/ui"
9+
)
10+
11+
// checkArborLocalGitignore checks if .arbor.local is gitignored and warns if not
12+
func checkArborLocalGitignore(worktreePath string) {
13+
// Check if .arbor.local exists
14+
localStatePath := filepath.Join(worktreePath, ".arbor.local")
15+
if _, err := os.Stat(localStatePath); os.IsNotExist(err) {
16+
return
17+
}
18+
19+
ignored, err := git.IsIgnored(worktreePath, ".arbor.local")
20+
if err == nil && ignored {
21+
return
22+
}
23+
24+
ui.PrintWarning("Add .arbor.local to .gitignore to prevent committing local state")
25+
}

internal/cli/init.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package cli
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67

78
"github.com/spf13/cobra"
9+
"gopkg.in/yaml.v3"
810

911
"github.com/michaeldyrynda/arbor/internal/config"
1012
"github.com/michaeldyrynda/arbor/internal/git"
@@ -98,6 +100,11 @@ Arguments:
98100
SiteName: siteName,
99101
}
100102

103+
// Check for arbor.yaml in the cloned repository
104+
if err := checkAndCopyRepoConfig(cmd, mainPath, absPath, cfg); err != nil {
105+
return err
106+
}
107+
101108
preset := mustGetString(cmd, "preset")
102109

103110
presetManager := presets.NewManager()
@@ -141,6 +148,11 @@ Arguments:
141148
ui.PrintInfo("Skipped scaffold (use 'arbor scaffold main' to scaffold manually)")
142149
}
143150

151+
// Check if .arbor.local should be gitignored
152+
if !quiet {
153+
checkArborLocalGitignore(mainPath)
154+
}
155+
144156
ui.PrintDone("Repository ready!")
145157
ui.PrintInfo(fmt.Sprintf("cd %s", absPath))
146158
ui.PrintInfo("arbor work feature/my-feature")
@@ -154,4 +166,76 @@ func init() {
154166

155167
initCmd.Flags().String("preset", "", "Project preset (laravel, php)")
156168
initCmd.Flags().Bool("skip-scaffold", false, "Skip scaffold steps during init")
169+
initCmd.Flags().Bool("use-repo-config", true, "Automatically use repository config (non-interactive)")
170+
}
171+
172+
// checkAndCopyRepoConfig checks for arbor.yaml in the repository and prompts to copy it
173+
func checkAndCopyRepoConfig(cmd *cobra.Command, mainPath, projectPath string, cfg *config.Config) error {
174+
repoConfigPath := filepath.Join(mainPath, "arbor.yaml")
175+
if _, err := os.Stat(repoConfigPath); os.IsNotExist(err) {
176+
return nil
177+
}
178+
179+
shouldCopy := false
180+
181+
if ui.IsInteractive() {
182+
confirmed, err := ui.Confirm("Found arbor.yaml in repository. Copy to project root for team config?")
183+
if err != nil {
184+
return fmt.Errorf("prompting for config copy: %w", err)
185+
}
186+
shouldCopy = confirmed
187+
} else {
188+
// Non-interactive: use --use-repo-config flag (default true)
189+
shouldCopy = mustGetBool(cmd, "use-repo-config")
190+
}
191+
192+
if !shouldCopy {
193+
return nil
194+
}
195+
196+
projectConfigPath := filepath.Join(projectPath, "arbor.yaml")
197+
198+
// Read repo config
199+
repoConfigData, err := os.ReadFile(repoConfigPath)
200+
if err != nil {
201+
return fmt.Errorf("reading repository config: %w", err)
202+
}
203+
204+
// Parse and clean it (remove db_suffix if present)
205+
var configData map[string]interface{}
206+
if err := yaml.Unmarshal(repoConfigData, &configData); err != nil {
207+
return fmt.Errorf("parsing repository config: %w", err)
208+
}
209+
210+
// Remove local-only fields
211+
delete(configData, "db_suffix")
212+
213+
// Always override site_name based on local path after copying team config
214+
configData["site_name"] = cfg.SiteName
215+
216+
// Write to project root
217+
cleanedData, err := yaml.Marshal(configData)
218+
if err != nil {
219+
return fmt.Errorf("marshaling cleaned config: %w", err)
220+
}
221+
222+
if err := os.WriteFile(projectConfigPath, cleanedData, 0644); err != nil {
223+
return fmt.Errorf("writing project config: %w", err)
224+
}
225+
226+
ui.PrintSuccess("Copied arbor.yaml to project root")
227+
228+
// Reload config to get scaffold steps
229+
reloadedCfg, err := config.LoadProject(projectPath)
230+
if err != nil {
231+
return fmt.Errorf("reloading config: %w", err)
232+
}
233+
234+
// Update cfg with reloaded scaffold/cleanup steps
235+
cfg.Scaffold = reloadedCfg.Scaffold
236+
cfg.Cleanup = reloadedCfg.Cleanup
237+
cfg.Preset = reloadedCfg.Preset
238+
cfg.Tools = reloadedCfg.Tools
239+
240+
return nil
157241
}

internal/cli/work.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,11 @@ available branches or entering a new branch name.`,
134134
if err := pc.ScaffoldManager().RunScaffold(absWorktreePath, branch, repoName, siteName, preset, pc.Config, false, verbose, quiet); err != nil {
135135
ui.PrintErrorWithHint("Scaffold steps failed", err.Error())
136136
}
137+
138+
// Check if .arbor.local should be gitignored
139+
if !quiet {
140+
checkArborLocalGitignore(absWorktreePath)
141+
}
137142
} else {
138143
ui.PrintInfo("[DRY RUN] Would run scaffold steps")
139144
}

internal/config/local_state.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"gopkg.in/yaml.v3"
9+
)
10+
11+
// LocalState represents worktree-local state that should never be committed
12+
type LocalState struct {
13+
DbSuffix string `yaml:"db_suffix"`
14+
}
15+
16+
// ReadLocalState reads worktree-local state from .arbor.local
17+
func ReadLocalState(worktreePath string) (*LocalState, error) {
18+
configPath := filepath.Join(worktreePath, ".arbor.local")
19+
20+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
21+
return &LocalState{}, nil
22+
}
23+
24+
content, err := os.ReadFile(configPath)
25+
if err != nil {
26+
return nil, fmt.Errorf("reading local state: %w", err)
27+
}
28+
29+
var state LocalState
30+
if err := yaml.Unmarshal(content, &state); err != nil {
31+
return nil, fmt.Errorf("parsing local state: %w", err)
32+
}
33+
34+
return &state, nil
35+
}
36+
37+
// WriteLocalState writes worktree-local state to .arbor.local
38+
func WriteLocalState(worktreePath string, data LocalState) error {
39+
configPath := filepath.Join(worktreePath, ".arbor.local")
40+
41+
// Read existing state if it exists
42+
var existing map[string]interface{}
43+
if content, err := os.ReadFile(configPath); err == nil {
44+
if err := yaml.Unmarshal(content, &existing); err != nil {
45+
return fmt.Errorf("parsing existing local state: %w", err)
46+
}
47+
}
48+
49+
if existing == nil {
50+
existing = make(map[string]interface{})
51+
}
52+
53+
// Merge new data into existing state
54+
if data.DbSuffix != "" {
55+
existing["db_suffix"] = data.DbSuffix
56+
}
57+
58+
// Marshal and write
59+
content, err := yaml.Marshal(existing)
60+
if err != nil {
61+
return fmt.Errorf("marshaling local state: %w", err)
62+
}
63+
64+
if err := os.WriteFile(configPath, content, 0644); err != nil {
65+
return fmt.Errorf("writing local state: %w", err)
66+
}
67+
68+
return nil
69+
}

0 commit comments

Comments
 (0)