diff --git a/branches.go b/branches.go index 6a0c8af..0fe4435 100644 --- a/branches.go +++ b/branches.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/ghodss/yaml" "github.com/pkg/errors" @@ -145,7 +146,10 @@ func switchToCommitBranch(rc requestContext) (string, error) { } // Clean the branch so we can replace its contents wholesale - if err := cleanCommitBranch(rc.repo.WorkingDir()); err != nil { + if err := cleanCommitBranch( + rc.repo.WorkingDir(), + rc.target.branchConfig.PreservedPaths, + ); err != nil { return "", errors.Wrap(err, "error cleaning commit branch") } logger.Debug("cleaned commit branch") @@ -154,19 +158,77 @@ func switchToCommitBranch(rc requestContext) (string, error) { } // cleanCommitBranch deletes the entire contents of the specified directory -// EXCEPT for the .git and .kargo-render subdirectories. -func cleanCommitBranch(dir string) error { - dirEntries, err := os.ReadDir(dir) +// EXCEPT for the paths specified by preservedPaths. +func cleanCommitBranch(dir string, preservedPaths []string) error { + _, err := cleanDir( + dir, + normalizePreservedPaths( + dir, + append(preservedPaths, ".git", ".kargo-render"), + ), + ) + return err +} + +// normalizePreservedPaths converts the relative paths in the preservedPaths +// argument to absolute paths relative to the workingDir argument. It also +// removes any trailing path separators from the paths. +func normalizePreservedPaths( + workingDir string, + preservedPaths []string, +) []string { + normalizedPreservedPaths := make([]string, len(preservedPaths)) + for i, preservedPath := range preservedPaths { + if strings.HasSuffix(preservedPath, string(os.PathSeparator)) { + preservedPath = preservedPath[:len(preservedPath)-1] + } + normalizedPreservedPaths[i] = filepath.Join(workingDir, preservedPath) + } + return normalizedPreservedPaths +} + +// cleanDir recursively deletes the entire contents of the directory specified +// by the absolute path dir EXCEPT for any paths specified by the preservedPaths +// argument. The function returns true if dir is left empty afterwards and false +// otherwise. +func cleanDir(dir string, preservedPaths []string) (bool, error) { + items, err := os.ReadDir(dir) if err != nil { - return err + return false, err } - for _, dirEntry := range dirEntries { - if dirEntry.Name() == ".git" || dirEntry.Name() == ".kargo-render" { + for _, item := range items { + path := filepath.Join(dir, item.Name()) + if isPathPreserved(path, preservedPaths) { continue } - if err = os.RemoveAll(filepath.Join(dir, dirEntry.Name())); err != nil { - return err + if item.IsDir() { + var isEmpty bool + if isEmpty, err = cleanDir(path, preservedPaths); err != nil { + return false, err + } + if isEmpty { + if err = os.Remove(path); err != nil { + return false, err + } + } + } else if err = os.Remove(path); err != nil { + return false, err } } - return nil + if items, err = os.ReadDir(dir); err != nil { + return false, err + } + return len(items) == 0, nil +} + +// isPathPreserved returns true if the specified path is among those specified +// by the preservedPaths argument. Both path and preservedPaths MUST be absolute +// paths. Paths to directories MUST NOT end with a trailing path separator. +func isPathPreserved(path string, preservedPaths []string) bool { + for _, preservedPath := range preservedPaths { + if path == preservedPath { + return true + } + } + return false } diff --git a/branches_test.go b/branches_test.go index f38c47b..9750eae 100644 --- a/branches_test.go +++ b/branches_test.go @@ -107,7 +107,7 @@ func TestCleanCommitBranch(t *testing.T) { require.NoError(t, err) require.Len(t, dirEntries, subdirCount+fileCount+2) // Delete - err = cleanCommitBranch(dir) + err = cleanCommitBranch(dir, []string{}) require.NoError(t, err) // .git should not have been deleted _, err = os.Stat(filepath.Join(dir, ".git")) @@ -121,6 +121,107 @@ func TestCleanCommitBranch(t *testing.T) { require.Len(t, dirEntries, 2) } +func TestNormalizePreservedPaths(t *testing.T) { + preservedPaths := []string{ + "foo/bar", + "bat/baz/", + } + normalizedPreservedPaths := + normalizePreservedPaths("fake-work-dir", preservedPaths) + require.Equal( + t, + []string{ + filepath.Join("fake-work-dir", "foo", "bar"), + filepath.Join("fake-work-dir", "bat", "baz"), + }, + normalizedPreservedPaths, + ) +} + +func TestCleanDir(t *testing.T) { + dir, err := os.MkdirTemp("", "") + defer os.RemoveAll(dir) + require.NoError(t, err) + + // This is what the test directory structure will look like: + // . + // ├── foo preserved directly + // │   └── foo.txt preserved because foo is + // ├── bar preserved because bar/bar.txt is + // │   └── bar.txt preserved directly + // ├── baz deleted because empty + // │   └── baz.txt deleted + // └── keep.txt preserved directly + + // Create the test directory structure + fooDir := filepath.Join(dir, "foo") + err = os.Mkdir(fooDir, 0755) + require.NoError(t, err) + fooFile := filepath.Join(fooDir, "foo.txt") + err = os.WriteFile(fooFile, []byte("foo"), 0600) + require.NoError(t, err) + + barDir := filepath.Join(dir, "bar") + err = os.Mkdir(barDir, 0755) + require.NoError(t, err) + barFile := filepath.Join(barDir, "bar.txt") + err = os.WriteFile(barFile, []byte("bar"), 0600) + require.NoError(t, err) + + bazDir := filepath.Join(dir, "baz") + err = os.Mkdir(bazDir, 0755) + require.NoError(t, err) + bazFile := filepath.Join(bazDir, "baz.txt") + err = os.WriteFile(bazFile, []byte("baz"), 0600) + require.NoError(t, err) + + keepFile := filepath.Join(dir, "keep.txt") + err = os.WriteFile(keepFile, []byte("keep"), 0600) + require.NoError(t, err) + + preservedPaths := []string{ + fooDir, + barFile, + keepFile, + } + + isEmpty, err := cleanDir(dir, preservedPaths) + require.NoError(t, err) + require.False(t, isEmpty) + + // Validate what was deleted and what wasn't + + // All of foo/ remains + _, err = os.Stat(fooDir) + require.NoError(t, err) + _, err = os.Stat(fooFile) + require.NoError(t, err) + + // All of bar/ remains + _, err = os.Stat(barDir) + require.NoError(t, err) + _, err = os.Stat(barFile) + require.NoError(t, err) + + // All of baz/ is gone + _, err = os.Stat(bazDir) + require.True(t, os.IsNotExist(err)) + + // keep.txt remains + _, err = os.Stat(keepFile) + require.NoError(t, err) +} + +func TestIsPathPreserved(t *testing.T) { + preservedPaths := []string{ + "/foo/bar", + "/foo/bat", + } + require.True(t, isPathPreserved("/foo/bar", preservedPaths)) + require.True(t, isPathPreserved("/foo/bat", preservedPaths)) + require.False(t, isPathPreserved("/foo/baz", preservedPaths)) +} + func createDummyCommitBranchDir(dirCount, fileCount int) (string, error) { // Create a directory dir, err := os.MkdirTemp("", "") diff --git a/config.go b/config.go index c521a79..cffa6de 100644 --- a/config.go +++ b/config.go @@ -69,6 +69,15 @@ type branchConfig struct { // PRs encapsulates details about how to manage any pull requests associated // with this branch. PRs pullRequestConfig `json:"prs,omitempty"` + // PreservedPaths specifies paths relative to the root of the repository that + // should be exempted from pre-render cleaning (deletion) of + // environment-specific branch contents. This is useful for preserving any + // environment-specific files that are manually maintained. Typically there + // are very few such files, if any at all, with an environment-specific + // CODEOWNERS file at the root of the repository being the most emblematic + // exception. Paths may be to files or directories. Any path to a directory + // will cause that directory's entire contents to be preserved. + PreservedPaths []string `json:"preservedPaths,omitempty"` } func (b branchConfig) expand(values []string) branchConfig { @@ -77,6 +86,9 @@ func (b branchConfig) expand(values []string) branchConfig { for appName, appConfig := range b.AppConfigs { cfg.AppConfigs[appName] = appConfig.expand(values) } + for i, path := range b.PreservedPaths { + b.PreservedPaths[i] = file.ExpandPath(path, values) + } return cfg } diff --git a/schema.json b/schema.json index 6db64b9..2c1a858 100644 --- a/schema.json +++ b/schema.json @@ -40,6 +40,12 @@ }, "prs": { "$ref": "#/definitions/pullRequestConfig" + }, + "preservedPaths": { + "type": "array", + "items": { + "$ref": "#/definitions/relativePath" + } } } },