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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 37 additions & 1 deletion cmd/config.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
}
71 changes: 65 additions & 6 deletions cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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:")
Expand Down
73 changes: 73 additions & 0 deletions cmd/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cmd

import (
"errors"
"os"
"path/filepath"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
15 changes: 14 additions & 1 deletion cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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"))
Expand Down
Loading