From 46952bd54cfae2ac4ae1abb7b7132e6498482c21 Mon Sep 17 00:00:00 2001 From: Andrew A Date: Thu, 30 Apr 2026 20:47:25 +0200 Subject: [PATCH] feat: support enabling backup files Add support for configuring backup file per env file. Backups are created before patching the file and allow the user the chance to restore the file if needed (for now, only manually). --- README.md | 17 ++++++++ cmd/patch.go | 6 +++ cmd/patch_test.go | 66 ++++++++++++++++++++++++++++++++ internal/config/config.go | 5 ++- internal/config/generate.go | 2 +- internal/config/generate_test.go | 1 + internal/env/patch.go | 22 +++++++++++ internal/env/patch_test.go | 40 +++++++++++++++++++ 8 files changed, 156 insertions(+), 3 deletions(-) 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")