diff --git a/README.md b/README.md index ba26fdc..97a17bd 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,43 @@ Comments are always written after the key=value pairs. |---|---| | `checkout` | Every branch switch | +### Seeding env files (`copy`) + +When you `git worktree add` a new working tree, you land in a clean directory with no `.env`. Set `copy` on an env file to seed it from somewhere — typically the same file in the main worktree: + +```yaml +env_files: + - path: .env + copy: ../main/.env # short form: just the source path + vars: # Optional. Can be omitted when no branch-dependent variable updates desired. + - name: JWT_SECRET + strategy: random + on: checkout +``` + +Source paths may be: + +- absolute (`/path/to/.env`) +- `~`-prefixed (`~/envs/myapp.env`) +- relative — resolved against the **main worktree root**, not the current working directory. This is what makes `../main/.env` work the same from every linked worktree. + +If you need to control overwrite behavior, use the mapping form: + +```yaml +env_files: + - path: .env + copy: + source: ../main/.env + overwrite: true # clobber an existing .env on init +``` + +**`overwrite` behavior:** + +- `false` (default) — if `.env` already exists, the copy is silently skipped. `copy:` is a "seed if absent" declaration, not a per-checkout request — once the file exists, bight leaves it alone. To see whether bight thinks it would copy, use `bight doctor` or `bight run --dry-run`. +- `true` — if `.env` already exists it is replaced (after the `backup` step, if `backup: true`). + +`overwrite` controls only the file copy. Var patching always rewrites the keys it targets regardless of this setting. + ### Global config (`~/.bight.yml`) Settings in `~/.bight.yml` apply across all repos and are overridden field-by-field by the repo's `.bight.yml`. Only `defaults` fields are supported globally — `env_files` and `vars` must be defined in the repo config. If a repo has no `.bight.yml`, `bight` does nothing — the global config alone is not enough to trigger patching. diff --git a/cmd/config.go b/cmd/config.go index b1489a9..1547a94 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,9 +1,12 @@ package cmd import ( + "errors" "os" + "path/filepath" "github.com/AndrewADev/bight/internal/config" + "github.com/AndrewADev/bight/internal/hook" ) var configPath string @@ -61,5 +64,38 @@ func loadConfig() (*config.Config, string, configSource, error) { return cfg, p, sourceEnv, err } cfg, path, err := config.Load() - return cfg, path, sourceAuto, err + if err == nil { + return cfg, path, sourceAuto, nil + } + if !errors.Is(err, os.ErrNotExist) { + return cfg, path, sourceAuto, err + } + // Fallback: when cwd has no .bight.yml, look in the main worktree root. + // This makes linked worktrees inherit the main worktree's config, the + // same way they already inherit hooks via the shared common git dir. + // Without this, `git worktree add` into a clean directory lands in a + // worktree where bight is a silent no-op — which defeats the whole + // point of the `copy:` feature. + root, rerr := hook.MainWorktreeRoot() + if rerr != nil { + return nil, "", sourceAuto, err // surface original ErrNotExist + } + cwd, _ := os.Getwd() + cwdAbs, _ := filepath.Abs(cwd) + rootAbs, _ := filepath.Abs(root) + if cwdAbs == rootAbs { + // We're already in the main worktree; no fallback location to try. + return nil, "", sourceAuto, err + } + for _, name := range []string{".bight.yml", ".bight.yaml"} { + p := filepath.Join(root, name) + c, _, ferr := config.LoadFrom(p) + if ferr == nil { + return c, p, sourceAuto, nil + } + if !errors.Is(ferr, os.ErrNotExist) { + return nil, "", sourceAuto, ferr + } + } + return nil, "", sourceAuto, err } diff --git a/cmd/doctor.go b/cmd/doctor.go index a2e7024..6b68ec3 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -6,6 +6,7 @@ import ( "os" "github.com/AndrewADev/bight/internal/config" + bcopy "github.com/AndrewADev/bight/internal/copy" "github.com/AndrewADev/bight/internal/hook" "github.com/AndrewADev/bight/internal/output" "github.com/spf13/cobra" @@ -29,6 +30,12 @@ type checkDeps struct { existingEnvFiles map[string]bool cfgPath string cfgSource configSource + // resolvedCopySources maps `ef.Path -> resolved absolute source path` + // for every env_file with a Copy configured. Empty if a Copy is + // configured but the source path could not be resolved (treated as a + // failure by the doctor check). + resolvedCopySources map[string]string + copyResolveErrors map[string]error } func runChecks(cfg *config.Config, cfgErr error, deps checkDeps) []result { @@ -112,7 +119,7 @@ func runChecks(cfg *config.Config, cfgErr error, deps checkDeps) []result { } // Check 7: triggers valid - validTriggers := map[string]bool{"checkout": true} + validTriggers := map[string]bool{triggerCheckout: true} var badTriggers []string for _, ef := range cfg.EnvFiles { for _, v := range ef.Vars { @@ -127,6 +134,35 @@ func runChecks(cfg *config.Config, cfgErr error, deps checkDeps) []result { results = append(results, ok("vars: all triggers valid")) } + // Check 8: copy sources resolve and exist (only for env_files with copy configured) + var copyConfigured int + var badCopySources []string + for _, ef := range cfg.EnvFiles { + if ef.Copy == nil { + continue + } + copyConfigured++ + if err, has := deps.copyResolveErrors[ef.Path]; has && err != nil { + badCopySources = append(badCopySources, fmt.Sprintf("%s ← %q: %v", ef.Path, ef.Copy.Source, err)) + continue + } + resolved, has := deps.resolvedCopySources[ef.Path] + if !has { + badCopySources = append(badCopySources, fmt.Sprintf("%s ← %q: not resolved", ef.Path, ef.Copy.Source)) + continue + } + if _, err := os.Stat(resolved); err != nil { + badCopySources = append(badCopySources, fmt.Sprintf("%s ← %s: %v", ef.Path, resolved, err)) + } + } + if copyConfigured > 0 { + if len(badCopySources) > 0 { + results = append(results, fail(fmt.Sprintf("copy sources: missing/unreadable: %v", badCopySources))) + } else { + results = append(results, ok(fmt.Sprintf("copy sources: %d configured, all reachable", copyConfigured))) + } + } + return results } @@ -156,18 +192,41 @@ func doctorCmd() *cobra.Command { cfg, cfgPath, cfgSource, cfgErr := loadConfig() existing := map[string]bool{} + resolvedSources := map[string]string{} + resolveErrs := map[string]error{} if cfg != nil { for _, ef := range cfg.EnvFiles { _, err := os.Stat(ef.Path) existing[ef.Path] = err == nil } + + // Resolve copy sources up-front so runChecks can be purely + // validation logic. + var mainRoot string + if root, err := hook.MainWorktreeRoot(); err == nil { + mainRoot = root + } + homeDir, _ := os.UserHomeDir() + for _, ef := range cfg.EnvFiles { + if ef.Copy == nil { + continue + } + src, err := bcopy.ResolveSource(ef.Copy.Source, mainRoot, homeDir) + if err != nil { + resolveErrs[ef.Path] = err + continue + } + resolvedSources[ef.Path] = src + } } results := runChecks(cfg, cfgErr, checkDeps{ - gitOK: gitErr == nil, - hookErr: hook.Check(), - existingEnvFiles: existing, - cfgPath: cfgPath, - cfgSource: cfgSource, + gitOK: gitErr == nil, + hookErr: hook.Check(), + existingEnvFiles: existing, + cfgPath: cfgPath, + cfgSource: cfgSource, + resolvedCopySources: resolvedSources, + copyResolveErrors: resolveErrs, }) fmt.Println("bight doctor:") diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index f61a85e..3c05679 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -2,6 +2,8 @@ package cmd import ( "errors" + "os" + "path/filepath" "strings" "testing" @@ -174,3 +176,74 @@ func TestRunChecks_UnknownTrigger(t *testing.T) { t.Errorf("expected fail for unknown trigger, got: %v", r) } } + +func TestRunChecks_CopySourceMissing(t *testing.T) { + cfg := &config.Config{ + Project: "myapp", + EnvFiles: []config.EnvFile{ + { + Path: ".env", + Copy: &config.Copy{Source: "../nonexistent/.env"}, + Vars: []config.Var{ + {Name: "DB_NAME", Strategy: "template", On: "checkout"}, + }, + }, + }, + } + deps := happyDeps + deps.resolvedCopySources = map[string]string{".env": "/path/that/does/not/exist/.env"} + results := runChecks(cfg, nil, deps) + r, found := findByPrefix(results, "copy sources: missing/unreadable") + if !found || r.status != "fail" { + t.Errorf("expected fail for missing copy source, got: %v", r) + } +} + +func TestRunChecks_CopySourceExists(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "main.env") + if err := os.WriteFile(src, []byte("SEED=1\n"), 0o600); err != nil { + t.Fatal(err) + } + cfg := &config.Config{ + Project: "myapp", + EnvFiles: []config.EnvFile{ + { + Path: ".env", + Copy: &config.Copy{Source: src}, + Vars: []config.Var{ + {Name: "DB_NAME", Strategy: "template", On: "checkout"}, + }, + }, + }, + } + deps := happyDeps + deps.resolvedCopySources = map[string]string{".env": src} + results := runChecks(cfg, nil, deps) + r, found := findByPrefix(results, "copy sources: ") + if !found || r.status != "ok" { + t.Errorf("expected ok for existing copy source, got: %v", r) + } +} + +func TestRunChecks_CopySourceResolveError(t *testing.T) { + cfg := &config.Config{ + Project: "myapp", + EnvFiles: []config.EnvFile{ + { + Path: ".env", + Copy: &config.Copy{Source: "../relative/.env"}, + Vars: []config.Var{ + {Name: "DB_NAME", Strategy: "template", On: "checkout"}, + }, + }, + }, + } + deps := happyDeps + deps.copyResolveErrors = map[string]error{".env": errors.New("base directory unknown")} + results := runChecks(cfg, nil, deps) + r, found := findByPrefix(results, "copy sources: missing/unreadable") + if !found || r.status != "fail" { + t.Errorf("expected fail for resolve error, got: %v", r) + } +} diff --git a/cmd/install.go b/cmd/install.go index f36812c..1afc982 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -64,6 +64,19 @@ func promptInitConfig() error { envFile = ".env" } + var copySource string + fmt.Print(" Seed this file from another path on first worktree init? [y/N] ") + seedAnswer, _ := reader.ReadString('\n') + if v := strings.TrimSpace(seedAnswer); v == "y" || v == "Y" { + defaultSource := envFile + fmt.Printf(" Source path [%s]: ", defaultSource) + src, _ := reader.ReadString('\n') + copySource = strings.TrimSpace(src) + if copySource == "" { + copySource = defaultSource + } + } + var vars []config.Var fmt.Print(" Add env vars to track? [Y/n] ") addVars, _ := reader.ReadString('\n') @@ -89,7 +102,7 @@ func promptInitConfig() error { } } - if err := os.WriteFile(".bight.yml", []byte(config.Generate(project, envFile, vars)), 0o644); err != nil { + if err := os.WriteFile(".bight.yml", []byte(config.Generate(project, envFile, copySource, vars)), 0o644); err != nil { return err } fmt.Println(output.Green("bight: created .bight.yml")) diff --git a/cmd/patch.go b/cmd/patch.go index e71d6fc..eff5f87 100644 --- a/cmd/patch.go +++ b/cmd/patch.go @@ -1,14 +1,22 @@ package cmd import ( + "errors" "fmt" + "os" "github.com/AndrewADev/bight/internal/config" + bcopy "github.com/AndrewADev/bight/internal/copy" "github.com/AndrewADev/bight/internal/env" + "github.com/AndrewADev/bight/internal/hook" "github.com/AndrewADev/bight/internal/output" "github.com/AndrewADev/bight/internal/strategy" ) +// triggerCheckout is the only var-level event bight currently dispatches on. +// Vars with `on:` set to any other value are skipped. +const triggerCheckout = "checkout" + type dryRunResult struct { path string varName string @@ -17,13 +25,22 @@ type dryRunResult struct { err error } +// shouldCopy reports whether ef should be (re)seeded from ef.Copy this run, +// given whether the destination existed before bight started. +func shouldCopy(ef config.EnvFile, existedBefore bool) bool { + if ef.Copy == nil { + return false + } + return !existedBefore || ef.Copy.Overwrite +} + func dryRunEnvFiles(cfg *config.Config, branch string) []dryRunResult { ctx := strategy.Context{Branch: branch, Project: cfg.Project} var results []dryRunResult for _, ef := range cfg.EnvFiles { for _, v := range ef.Vars { - if v.On != "checkout" { + if v.On != triggerCheckout { continue } val, err := strategy.Apply(v.Strategy, ctx, cfg) @@ -45,11 +62,37 @@ func patchEnvFiles(cfg *config.Config, branch string) error { Project: cfg.Project, } + // Resolve the main worktree root lazily — only needed when an env_file + // has a `copy:` configured. + var ( + mainRoot string + mainRootErr error + mainOnce bool + ) + resolveMain := func() (string, error) { + if !mainOnce { + mainRoot, mainRootErr = hook.MainWorktreeRoot() + mainOnce = true + } + return mainRoot, mainRootErr + } + for _, ef := range cfg.EnvFiles { + _, statErr := os.Stat(ef.Path) + existedBefore := statErr == nil + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", ef.Path, statErr) + } + + willCopy := shouldCopy(ef, existedBefore) + + // Gather patches first so the "nothing to do" check below is honest: + // if no var matches the checkout event AND no copy is queued, this + // env_file gets skipped entirely (no backup, no PatchAll write). patches := make(map[string]string) sensitiveVars := make(map[string]bool) for _, v := range ef.Vars { - if v.On != "checkout" { + if v.On != triggerCheckout { continue } val, err := strategy.Apply(v.Strategy, ctx, cfg) @@ -60,31 +103,53 @@ func patchEnvFiles(cfg *config.Config, branch string) error { sensitiveVars[v.Name] = v.Sensitive } - if len(patches) == 0 { + if !willCopy && len(patches) == 0 { + // Nothing will modify this file. The `len(patches) == 0` half of + // this guard also protects against the v0.1.0 bug where an empty + // patches map collapsed the file to "\n" via PatchAll. continue } + // Single backup point per file, before any mutation. BackupFile is + // itself a no-op when the source file doesn't exist, so no extra + // gating needed for the worktree-init / fresh-file case. if ef.Backup { if err := env.BackupFile(ef.Path); err != nil { return fmt.Errorf("backup %s: %w", ef.Path, err) } } - comments, err := env.ScanComments(ef.Path, cfg.Defaults.CollectComments) - if err != nil { - return fmt.Errorf("scanning %s: %w", ef.Path, err) - } - - if err := env.PatchAll(ef.Path, patches, comments); err != nil { - return fmt.Errorf("patching %s: %w", ef.Path, err) + if willCopy { + // resolveMain is best-effort: absolute and ~-prefixed sources + // don't need it. ResolveSource will error if a relative source + // is given without a usable base. + base, _ := resolveMain() + homeDir, _ := os.UserHomeDir() + src, err := bcopy.ResolveSource(ef.Copy.Source, base, homeDir) + if err != nil { + return fmt.Errorf("copy %s: %w", ef.Path, err) + } + if err := bcopy.File(src, ef.Path); err != nil { + return fmt.Errorf("copy %s ← %s: %w", ef.Path, src, err) + } + fmt.Printf("bight: %s %s %s\n", ef.Path, output.Dim("←"), output.Cyan(src)) } - for name, val := range patches { - display := val - if sensitiveVars[name] { - display = "***" + if len(patches) > 0 { + comments, err := env.ScanComments(ef.Path, cfg.Defaults.CollectComments) + if err != nil { + return fmt.Errorf("scanning %s: %w", ef.Path, err) + } + if err := env.PatchAll(ef.Path, patches, comments); err != nil { + return fmt.Errorf("patching %s: %w", ef.Path, err) + } + for name, val := range patches { + display := val + if sensitiveVars[name] { + display = "***" + } + fmt.Printf("bight: %s %s %s=%s\n", ef.Path, output.Dim("→"), output.Cyan(name), output.Bold(display)) } - fmt.Printf("bight: %s %s %s=%s\n", ef.Path, output.Dim("→"), output.Cyan(name), output.Bold(display)) } } return nil diff --git a/cmd/patch_test.go b/cmd/patch_test.go index 6017921..da2be7c 100644 --- a/cmd/patch_test.go +++ b/cmd/patch_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "strings" "testing" "github.com/AndrewADev/bight/internal/config" @@ -291,6 +292,130 @@ func TestPatchEnvFiles_NoBackupWhenSkipped(t *testing.T) { } } +func TestPatchEnvFiles_CopyWhenDestMissing(t *testing.T) { + dir := t.TempDir() + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, ".env") + if err := os.WriteFile(srcPath, []byte("SEEDED=from-source\n"), 0o600); err != nil { + t.Fatal(err) + } + envPath := filepath.Join(dir, ".env") // does not exist yet + + cfg := &config.Config{ + Project: "myapp", + EnvFiles: []config.EnvFile{ + { + Path: envPath, + Copy: &config.Copy{Source: srcPath}, + Vars: []config.Var{ + {Name: "JWT_SECRET", Strategy: "random", On: "checkout"}, + }, + }, + }, + } + + if err := patchEnvFiles(cfg, "feat-x"); err != nil { + t.Fatalf("patchEnvFiles: %v", err) + } + + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("reading dest: %v", err) + } + s := string(data) + if !strings.Contains(s, "SEEDED=") { + t.Errorf("expected dest to contain SEEDED= (from copy), got: %q", s) + } + if !strings.Contains(s, "JWT_SECRET=") { + t.Errorf("expected dest to contain JWT_SECRET= (checkout var applied after copy), got: %q", s) + } +} + +func TestPatchEnvFiles_NoCopyWhenDestExistsAndOverwriteFalse(t *testing.T) { + dir := t.TempDir() + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, ".env") + if err := os.WriteFile(srcPath, []byte("SEEDED=from-source\n"), 0o600); err != nil { + t.Fatal(err) + } + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("PREEXISTING=hi\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + EnvFiles: []config.EnvFile{ + { + Path: envPath, + Copy: &config.Copy{Source: srcPath, Overwrite: false}, + Vars: []config.Var{ + {Name: "JWT_SECRET", Strategy: "random", On: "checkout"}, + }, + }, + }, + } + + if err := patchEnvFiles(cfg, "feat-x"); err != nil { + t.Fatalf("patchEnvFiles: %v", err) + } + + data, _ := os.ReadFile(envPath) + s := string(data) + if strings.Contains(s, "SEEDED=") { + t.Errorf("dest unexpectedly contains SEEDED= (copy should have been skipped): %q", s) + } + if !strings.Contains(s, "PREEXISTING=") { + t.Errorf("dest lost PREEXISTING=: %q", s) + } + if !strings.Contains(s, "JWT_SECRET=") { + t.Errorf("dest missing JWT_SECRET= (checkout vars should still fire): %q", s) + } +} + +func TestPatchEnvFiles_OverwriteClobbers(t *testing.T) { + dir := t.TempDir() + srcDir := t.TempDir() + srcPath := filepath.Join(srcDir, ".env") + if err := os.WriteFile(srcPath, []byte("SEEDED=fresh\n"), 0o600); err != nil { + t.Fatal(err) + } + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("OLD=ghost\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + EnvFiles: []config.EnvFile{ + { + Path: envPath, + Backup: true, + Copy: &config.Copy{Source: srcPath, Overwrite: true}, + }, + }, + } + + if err := patchEnvFiles(cfg, "feat-x"); err != nil { + t.Fatalf("patchEnvFiles: %v", err) + } + + data, _ := os.ReadFile(envPath) + s := string(data) + if !strings.Contains(s, "SEEDED=") { + t.Errorf("dest should contain SEEDED= after overwrite: %q", s) + } + if strings.Contains(s, "OLD=") { + t.Errorf("dest should not contain OLD= after overwrite: %q", s) + } + + bak, err := os.ReadFile(envPath + ".bak") + if err != nil { + t.Fatalf("backup missing: %v", err) + } + if string(bak) != "OLD=ghost\n" { + t.Errorf("backup = %q, want %q", bak, "OLD=ghost\n") + } +} + func TestPatchEnvFiles_NoBackupByDefault(t *testing.T) { dir := t.TempDir() envPath := filepath.Join(dir, ".env") diff --git a/internal/config/config.go b/internal/config/config.go index bd36d86..b00eb77 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,9 +25,46 @@ type Defaults struct { type EnvFile struct { Path string `yaml:"path"` Backup bool `yaml:"backup"` + Copy *Copy `yaml:"copy"` Vars []Var `yaml:"vars"` } +// Copy describes how to seed an env file by copying it from elsewhere. +// In YAML, copy may be given as either a scalar (the source path, with +// Overwrite defaulting to false) or a mapping with explicit source and +// overwrite fields: +// +// copy: ../main/.env +// copy: { source: ../main/.env, overwrite: true } +// +// Overwrite controls only the file copy step. Var patching always rewrites +// the keys it targets regardless of this setting. +type Copy struct { + Source string `yaml:"source"` + Overwrite bool `yaml:"overwrite"` +} + +// UnmarshalYAML allows `copy:` to be either a scalar (treated as the source +// path) or a full mapping with source + overwrite. +func (c *Copy) UnmarshalYAML(value *yaml.Node) error { + switch value.Kind { + case yaml.ScalarNode: + c.Source = value.Value + return nil + case yaml.MappingNode: + // Use an alias type to avoid recursing into this UnmarshalYAML. + type rawCopy Copy + var raw rawCopy + if err := value.Decode(&raw); err != nil { + return err + } + *c = Copy(raw) + return nil + default: + return fmt.Errorf("copy: expected scalar or mapping, got %v", value.Kind) + } +} + type Var struct { Name string `yaml:"name"` Strategy string `yaml:"strategy"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index df2ffd1..844e4c1 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -313,6 +313,99 @@ env_files: } } +func TestLoad_CopyShortForm(t *testing.T) { + yaml := ` +project: myapp +env_files: + - path: .env + copy: ../main/.env + vars: + - name: DB_NAME + strategy: template + on: checkout +` + f, err := os.CreateTemp("", "bight-*.yml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.WriteString(yaml) + f.Close() + + cfg, err := load(f.Name()) + if err != nil { + t.Fatalf("load: %v", err) + } + ef := cfg.EnvFiles[0] + if ef.Copy == nil { + t.Fatal("Copy is nil, expected populated from short form") + } + if ef.Copy.Source != "../main/.env" { + t.Errorf("Copy.Source = %q, want %q", ef.Copy.Source, "../main/.env") + } + if ef.Copy.Overwrite { + t.Errorf("Copy.Overwrite = true, want false (default)") + } +} + +func TestLoad_CopyMappingForm(t *testing.T) { + yaml := ` +project: myapp +env_files: + - path: .env + copy: + source: /abs/path/.env + overwrite: true + vars: [] +` + f, err := os.CreateTemp("", "bight-*.yml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.WriteString(yaml) + f.Close() + + cfg, err := load(f.Name()) + if err != nil { + t.Fatalf("load: %v", err) + } + ef := cfg.EnvFiles[0] + if ef.Copy == nil { + t.Fatal("Copy is nil, expected populated from mapping form") + } + if ef.Copy.Source != "/abs/path/.env" { + t.Errorf("Copy.Source = %q", ef.Copy.Source) + } + if !ef.Copy.Overwrite { + t.Errorf("Copy.Overwrite = false, want true") + } +} + +func TestLoad_CopyOmittedIsNil(t *testing.T) { + yaml := ` +project: myapp +env_files: + - path: .env + vars: [] +` + f, err := os.CreateTemp("", "bight-*.yml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(f.Name()) + f.WriteString(yaml) + f.Close() + + cfg, err := load(f.Name()) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.EnvFiles[0].Copy != nil { + t.Errorf("Copy should be nil when omitted, got %+v", cfg.EnvFiles[0].Copy) + } +} + func TestLoad_SensitiveField(t *testing.T) { yaml := ` project: myapp diff --git a/internal/config/generate.go b/internal/config/generate.go index f92cf16..ab55b60 100644 --- a/internal/config/generate.go +++ b/internal/config/generate.go @@ -5,11 +5,26 @@ import ( "strings" ) -func Generate(project, envFilePath string, vars []Var) string { +// Generate returns the contents of a fresh .bight.yml. +// +// `copySource`, if non-empty, emits a `copy:` block on the env_file so a +// freshly-created worktree seeds the dest from that path (resolved against +// the main worktree root). When empty, a commented `copy:` hint is emitted +// instead so the option is discoverable later. +func Generate(project, envFilePath, copySource string, vars []Var) string { var sb strings.Builder - fmt.Fprintf(&sb, "project: %s\nenv_files:\n - path: %s\n # backup: true\n vars:\n", project, envFilePath) + fmt.Fprintf(&sb, "project: %s\nenv_files:\n - path: %s\n # backup: true\n", project, envFilePath) + + if copySource != "" { + fmt.Fprintf(&sb, " copy:\n source: %s\n # overwrite: false # set true to clobber an existing dest\n", copySource) + } else { + sb.WriteString(" # copy: .env # seed this file from the main worktree on first checkout (path is resolved against the main worktree root)\n") + } + + sb.WriteString(" vars:\n") for _, v := range vars { fmt.Fprintf(&sb, " - name: %s\n strategy: %s\n on: checkout\n # sensitive: true\n", v.Name, v.Strategy) } + return sb.String() } diff --git a/internal/config/generate_test.go b/internal/config/generate_test.go index 43850d3..938454a 100644 --- a/internal/config/generate_test.go +++ b/internal/config/generate_test.go @@ -6,7 +6,7 @@ import ( ) func TestGenerate(t *testing.T) { - out := Generate("myapp", ".env.local", []Var{ + out := Generate("myapp", ".env.local", "", []Var{ {Name: "DB_NAME", Strategy: "template"}, {Name: "JWT_SECRET", Strategy: "random"}, }) @@ -19,10 +19,40 @@ func TestGenerate(t *testing.T) { "strategy: template", "on: checkout", "# sensitive: true", + // When no copy source is given, the commented hint should appear. + "# copy: ", } for _, want := range checks { if !strings.Contains(out, want) { t.Errorf("Generate() missing %q in output:\n%s", want, out) } } + + // Without an explicit source, the active copy block must NOT be present. + if strings.Contains(out, "copy:\n source:") { + t.Errorf("Generate() emitted an active copy block when copySource was empty:\n%s", out) + } +} + +func TestGenerate_WithCopySource(t *testing.T) { + out := Generate("myapp", ".env", "../main/.env", []Var{ + {Name: "DB_NAME", Strategy: "template"}, + }) + + checks := []string{ + "copy:", + "source: ../main/.env", + "# overwrite: false", + } + for _, want := range checks { + if !strings.Contains(out, want) { + t.Errorf("Generate() missing %q in output:\n%s", want, out) + } + } + + // The commented hint version should NOT also be emitted when an active + // copy block is generated. + if strings.Contains(out, "# copy: .env") { + t.Errorf("Generate() emitted both an active copy block and the commented hint:\n%s", out) + } } diff --git a/internal/copy/copy.go b/internal/copy/copy.go new file mode 100644 index 0000000..6f64745 --- /dev/null +++ b/internal/copy/copy.go @@ -0,0 +1,98 @@ +// Package copy provides primitives for seeding an env file by copying it +// from another location. Paths may be absolute, `~`-prefixed, or relative +// to a configured base directory (typically the main worktree root). +package copy + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// File copies src to dst atomically (via temp-file-then-rename), preserving +// src's permissions. If dst already exists it is replaced. Callers are +// responsible for deciding whether overwriting is allowed and for backing +// up the previous file if needed. +func File(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("opening source %s: %w", src, err) + } + defer in.Close() + + srcInfo, err := in.Stat() + if err != nil { + return fmt.Errorf("stat source %s: %w", src, err) + } + mode := srcInfo.Mode().Perm() + if mode == 0 { + mode = 0o600 + } + + dir := filepath.Dir(dst) + base := filepath.Base(dst) + tmp, err := os.CreateTemp(dir, fmt.Sprintf(".%s.*.tmp", base)) + if err != nil { + return fmt.Errorf("creating temp file in %s: %w", dir, err) + } + tmpName := tmp.Name() + committed := false + defer func() { + if !committed { + tmp.Close() + os.Remove(tmpName) + } + }() + + if _, err := io.Copy(tmp, in); err != nil { + return fmt.Errorf("copying bytes: %w", err) + } + if err := tmp.Sync(); err != nil { + return fmt.Errorf("syncing temp: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("closing temp: %w", err) + } + if err := os.Chmod(tmpName, mode); err != nil { + return fmt.Errorf("chmod temp: %w", err) + } + if err := os.Rename(tmpName, dst); err != nil { + return fmt.Errorf("renaming temp to %s: %w", dst, err) + } + committed = true + return nil +} + +// ResolveSource turns a user-provided source path into an absolute path. +// Absolute paths are returned as-is (after Clean), `~`-prefixed paths are +// expanded against homeDir, and everything else is resolved relative to +// baseDir (typically the main worktree root). +// +// homeDir may be empty; if so, `~`-prefixed paths return an error. +func ResolveSource(source, baseDir, homeDir string) (string, error) { + if source == "" { + return "", fmt.Errorf("copy source is empty") + } + if strings.HasPrefix(source, "~") { + if homeDir == "" { + return "", fmt.Errorf("cannot expand ~ in %q: home directory unknown", source) + } + // Accept both `~` and `~/...`. Disallow `~user/...` — out of scope. + if source == "~" { + return filepath.Clean(homeDir), nil + } + if strings.HasPrefix(source, "~/") { + return filepath.Clean(filepath.Join(homeDir, source[2:])), nil + } + return "", fmt.Errorf("unsupported ~ form: %q (only ~ and ~/... are supported)", source) + } + if filepath.IsAbs(source) { + return filepath.Clean(source), nil + } + if baseDir == "" { + return "", fmt.Errorf("cannot resolve relative source %q: base directory unknown (not in a git worktree?)", source) + } + return filepath.Clean(filepath.Join(baseDir, source)), nil +} diff --git a/internal/copy/copy_test.go b/internal/copy/copy_test.go new file mode 100644 index 0000000..4789d41 --- /dev/null +++ b/internal/copy/copy_test.go @@ -0,0 +1,139 @@ +package copy + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFile_CopiesContents(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + content := []byte("KEY=val\nOTHER=thing\n") + if err := os.WriteFile(src, content, 0o600); err != nil { + t.Fatal(err) + } + + if err := File(src, dst); err != nil { + t.Fatalf("File: %v", err) + } + + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(got) != string(content) { + t.Errorf("dst content = %q, want %q", got, content) + } +} + +func TestFile_PreservesPerms(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + if err := os.WriteFile(src, []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + + if err := File(src, dst); err != nil { + t.Fatalf("File: %v", err) + } + + fi, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if got := fi.Mode().Perm(); got != 0o640 { + t.Errorf("dst perm = %#o, want %#o", got, 0o640) + } +} + +func TestFile_OverwritesExistingDest(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + dst := filepath.Join(dir, "dst") + if err := os.WriteFile(src, []byte("new"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + + if err := File(src, dst); err != nil { + t.Fatalf("File: %v", err) + } + got, _ := os.ReadFile(dst) + if string(got) != "new" { + t.Errorf("dst = %q, want %q", got, "new") + } +} + +func TestFile_SourceMissing(t *testing.T) { + dir := t.TempDir() + err := File(filepath.Join(dir, "no-such"), filepath.Join(dir, "dst")) + if err == nil { + t.Fatal("expected error for missing source") + } +} + +func TestResolveSource_Absolute(t *testing.T) { + got, err := ResolveSource("/abs/path/.env", "/base", "/home/u") + if err != nil { + t.Fatal(err) + } + if got != "/abs/path/.env" { + t.Errorf("got %q", got) + } +} + +func TestResolveSource_Relative(t *testing.T) { + got, err := ResolveSource("../main/.env", "/base/sub", "/home/u") + if err != nil { + t.Fatal(err) + } + if got != "/base/main/.env" { + t.Errorf("got %q, want %q", got, "/base/main/.env") + } +} + +func TestResolveSource_TildeExpands(t *testing.T) { + got, err := ResolveSource("~/envs/myapp.env", "/base", "/home/u") + if err != nil { + t.Fatal(err) + } + if got != "/home/u/envs/myapp.env" { + t.Errorf("got %q, want %q", got, "/home/u/envs/myapp.env") + } +} + +func TestResolveSource_TildeAlone(t *testing.T) { + got, err := ResolveSource("~", "/base", "/home/u") + if err != nil { + t.Fatal(err) + } + if got != "/home/u" { + t.Errorf("got %q, want %q", got, "/home/u") + } +} + +func TestResolveSource_TildeWithoutHome(t *testing.T) { + _, err := ResolveSource("~/x", "/base", "") + if err == nil { + t.Fatal("expected error when home is empty") + } +} + +func TestResolveSource_UserTildeUnsupported(t *testing.T) { + _, err := ResolveSource("~someone/x", "/base", "/home/u") + if err == nil { + t.Fatal("expected error for ~user form") + } +} + +func TestResolveSource_Empty(t *testing.T) { + _, err := ResolveSource("", "/base", "/home/u") + if err == nil { + t.Fatal("expected error for empty source") + } +} diff --git a/internal/hook/install.go b/internal/hook/install.go index 0750827..84dec96 100644 --- a/internal/hook/install.go +++ b/internal/hook/install.go @@ -17,6 +17,36 @@ func HooksDir() (string, error) { } func hooksDir(dir string) (string, error) { + commonDir, err := commonGitDir(dir) + if err != nil { + return "", err + } + return filepath.Join(commonDir, "hooks"), nil +} + +// MainWorktreeRoot returns the working-tree directory of the main worktree +// for the repo at the current working directory. In a regular repo this is +// the same directory; in a linked worktree it resolves to the main repo's +// working directory via commondir. +func MainWorktreeRoot() (string, error) { + return mainWorktreeRoot(".") +} + +func mainWorktreeRoot(dir string) (string, error) { + commonDir, err := commonGitDir(dir) + if err != nil { + return "", err + } + // commonDir points to the main repo's .git directory; its parent is the + // main worktree's working directory. + return filepath.Dir(commonDir), nil +} + +// commonGitDir returns the path to the main repo's .git directory (the +// "common" git dir), resolved relative to the directory `dir`. For a +// regular repo this is just /.git; for a worktree it's the directory +// pointed at by the worktree's commondir file. +func commonGitDir(dir string) (string, error) { dotGit := filepath.Join(dir, ".git") info, err := os.Stat(dotGit) if err != nil { @@ -24,7 +54,7 @@ func hooksDir(dir string) (string, error) { } if info.IsDir() { - return filepath.Join(dotGit, "hooks"), nil + return dotGit, nil } // .git is a file — we're in a worktree. Format: "gitdir: " @@ -49,7 +79,7 @@ func hooksDir(dir string) (string, error) { commonDir = filepath.Join(worktreeGitDir, commonDir) } - return filepath.Join(filepath.Clean(commonDir), "hooks"), nil + return filepath.Clean(commonDir), nil } func Install() error { diff --git a/internal/hook/install_test.go b/internal/hook/install_test.go index 933b56f..53cafd1 100644 --- a/internal/hook/install_test.go +++ b/internal/hook/install_test.go @@ -79,3 +79,42 @@ func TestHooksDir_MissingCommondir(t *testing.T) { t.Errorf("expected commondir error, got %v", err) } } + +func TestMainWorktreeRoot_RegularRepo(t *testing.T) { + dir := makeHooksDir(t) + got, err := mainWorktreeRoot(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // In a regular repo, the main worktree root is the dir containing .git. + if got != dir { + t.Errorf("got %q, want %q", got, dir) + } +} + +func TestMainWorktreeRoot_Worktree(t *testing.T) { + // Layout:
/.git is a directory; /.git is a file pointing at + //
/.git/worktrees/; that dir's commondir →
/.git. + main := makeHooksDir(t) + worktreeGitDir := filepath.Join(main, ".git", "worktrees", "my-branch") + if err := os.MkdirAll(worktreeGitDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(worktreeGitDir, "commondir"), []byte("../.."), 0644); err != nil { + t.Fatal(err) + } + + wt := t.TempDir() + gitFile := "gitdir: " + worktreeGitDir + if err := os.WriteFile(filepath.Join(wt, ".git"), []byte(gitFile), 0644); err != nil { + t.Fatal(err) + } + + got, err := mainWorktreeRoot(wt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != main { + t.Errorf("got %q, want %q", got, main) + } +}