diff --git a/README.md b/README.md index 0784fe9..db09a52 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ defaults: env_files: - path: .env + backup: true # write .env.bak before patching (optional, default false) vars: - name: DB_NAME strategy: template # renders to e.g. myapp_feat-login @@ -184,6 +185,22 @@ Output with `sensitive: true`: bight: .env → JWT_SECRET=*** ``` +### Backup files (`backup`) + +Set `backup: true` on an env file entry to write a copy of the file to `{path}.bak` before each patch is applied. Useful for inspecting what changed or recovering a previous value. + +```yaml +env_files: + - path: .env + backup: true + vars: + - name: DB_NAME + strategy: template + on: checkout +``` + +The backup is a verbatim copy of the file as it was immediately before patching. It is overwritten on each checkout — only the most recent pre-patch state is kept. + ### Preserving comments (`collect-comments`) Full comment preservation is not supported, as the package we use, `godotenv`, strips comments on rewrite. As a partial workaround, `defaults.collect-comments` re-appends comments collected before the patch was applied: diff --git a/cmd/patch.go b/cmd/patch.go index 13e93f2..2b4f2e2 100644 --- a/cmd/patch.go +++ b/cmd/patch.go @@ -60,6 +60,12 @@ func patchEnvFiles(cfg *config.Config, branch string) error { sensitiveVars[v.Name] = v.Sensitive } + 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) diff --git a/cmd/patch_test.go b/cmd/patch_test.go index 562a684..4afff89 100644 --- a/cmd/patch_test.go +++ b/cmd/patch_test.go @@ -1,6 +1,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/AndrewADev/bight/internal/config" @@ -148,3 +150,67 @@ func TestDryRunEnvFiles_MultipleFilesAndVars(t *testing.T) { t.Errorf("result[1]: got %q/%q", results[1].path, results[1].varName) } } + +func TestPatchEnvFiles_BackupCreated(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("DB_NAME=old\n"), 0600); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Project: "myapp", + Defaults: config.Defaults{ + BranchTemplate: "{{.Project}}_{{.Branch}}", + }, + EnvFiles: []config.EnvFile{ + { + Path: envPath, + Backup: true, + Vars: []config.Var{{Name: "DB_NAME", Strategy: "template", On: "checkout"}}, + }, + }, + } + + if err := patchEnvFiles(cfg, "feature-x"); err != nil { + t.Fatalf("patchEnvFiles: %v", err) + } + + data, err := os.ReadFile(envPath + ".bak") + if err != nil { + t.Fatalf("reading backup: %v", err) + } + if string(data) != "DB_NAME=old\n" { + t.Errorf("backup content = %q, want %q", string(data), "DB_NAME=old\n") + } +} + +func TestPatchEnvFiles_NoBackupByDefault(t *testing.T) { + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + if err := os.WriteFile(envPath, []byte("DB_NAME=old\n"), 0600); err != nil { + t.Fatal(err) + } + + cfg := &config.Config{ + Project: "myapp", + Defaults: config.Defaults{ + BranchTemplate: "{{.Project}}_{{.Branch}}", + }, + EnvFiles: []config.EnvFile{ + { + Path: envPath, + Backup: false, + Vars: []config.Var{{Name: "DB_NAME", Strategy: "template", On: "checkout"}}, + }, + }, + } + + if err := patchEnvFiles(cfg, "feature-x"); err != nil { + t.Fatalf("patchEnvFiles: %v", err) + } + + if _, err := os.Stat(envPath + ".bak"); !os.IsNotExist(err) { + t.Errorf("expected no backup file, but it exists") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0817fc7..539c246 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,8 +23,9 @@ type Defaults struct { } type EnvFile struct { - Path string `yaml:"path"` - Vars []Var `yaml:"vars"` + Path string `yaml:"path"` + Backup bool `yaml:"backup"` + Vars []Var `yaml:"vars"` } type Var struct { diff --git a/internal/config/generate.go b/internal/config/generate.go index 7d361fb..f92cf16 100644 --- a/internal/config/generate.go +++ b/internal/config/generate.go @@ -7,7 +7,7 @@ import ( func Generate(project, envFilePath string, vars []Var) string { var sb strings.Builder - fmt.Fprintf(&sb, "project: %s\nenv_files:\n - path: %s\n vars:\n", project, envFilePath) + fmt.Fprintf(&sb, "project: %s\nenv_files:\n - path: %s\n # backup: true\n vars:\n", project, envFilePath) for _, v := range vars { fmt.Fprintf(&sb, " - name: %s\n strategy: %s\n on: checkout\n # sensitive: true\n", v.Name, v.Strategy) } diff --git a/internal/config/generate_test.go b/internal/config/generate_test.go index 15c0d27..43850d3 100644 --- a/internal/config/generate_test.go +++ b/internal/config/generate_test.go @@ -14,6 +14,7 @@ func TestGenerate(t *testing.T) { checks := []string{ "project: myapp", "path: .env.local", + "# backup: true", "name: DB_NAME", "strategy: template", "on: checkout", diff --git a/internal/env/patch.go b/internal/env/patch.go index cb986ff..fbd6a67 100644 --- a/internal/env/patch.go +++ b/internal/env/patch.go @@ -3,6 +3,7 @@ package env import ( "bufio" "fmt" + "io" "os" "path/filepath" "strings" @@ -10,6 +11,27 @@ import ( "github.com/joho/godotenv" ) +func BackupFile(path string) error { + f, err := os.Open(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + data, err := io.ReadAll(f) + if err != nil { + return err + } + return os.WriteFile(path+".bak", data, fi.Mode().Perm()) +} + func Patch(path, key, value string) error { return PatchAll(path, map[string]string{key: value}, nil) } diff --git a/internal/env/patch_test.go b/internal/env/patch_test.go index df9eafe..b9959e7 100644 --- a/internal/env/patch_test.go +++ b/internal/env/patch_test.go @@ -264,6 +264,46 @@ func TestPatchAll_TempFileRemovedOnError(t *testing.T) { } } +func TestBackupFile_CreatesBackup(t *testing.T) { + path := writeTempEnv(t, "KEY=original\n") + if err := os.Chmod(path, 0640); err != nil { + t.Fatal(err) + } + + if err := BackupFile(path); err != nil { + t.Fatalf("BackupFile: %v", err) + } + + data, err := os.ReadFile(path + ".bak") + if err != nil { + t.Fatalf("reading backup: %v", err) + } + t.Cleanup(func() { os.Remove(path + ".bak") }) + + if string(data) != "KEY=original\n" { + t.Errorf("backup content = %q, want %q", string(data), "KEY=original\n") + } + + fi, err := os.Stat(path + ".bak") + if err != nil { + t.Fatal(err) + } + if fi.Mode().Perm() != 0640 { + t.Errorf("backup mode = %04o, want 0640", fi.Mode().Perm()) + } +} + +func TestBackupFile_NonExistentFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "does-not-exist.env") + + if err := BackupFile(path); err != nil { + t.Errorf("BackupFile on missing file: %v, want nil", err) + } + if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) { + t.Errorf("expected no backup file, but it exists") + } +} + func TestPatch_IsTransactional(t *testing.T) { path := writeTempEnv(t, "A=1\nB=2\n")