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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions cmd/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions cmd/patch_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package cmd

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

"github.com/AndrewADev/bight/internal/config"
Expand Down Expand Up @@ -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")
}
}
5 changes: 3 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
1 change: 1 addition & 0 deletions internal/config/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions internal/env/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,35 @@ package env
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"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)
}
Expand Down
40 changes: 40 additions & 0 deletions internal/env/patch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down