diff --git a/internal/cmd/flags.go b/internal/cmd/flags.go index 4257aed..982d9c1 100644 --- a/internal/cmd/flags.go +++ b/internal/cmd/flags.go @@ -15,7 +15,15 @@ func addFlags(root *cobra.Command, opts *cli.Options) { if err != nil { panic(err) } + + // Default to .deviate.yaml or .github/deviate.yaml in the root config := path.Join(wd, ".deviate.yaml") + if _, err := os.Stat(config); os.IsNotExist(err) { + githubConfig := path.Join(wd, ".github", "deviate.yaml") + if _, errStatGithub := os.Stat(githubConfig); errStatGithub == nil { + config = githubConfig + } + } fl.StringVar(&opts.ConfigPath, "config", config, metadata.Name+" configuration file") } diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go index 9ccaa40..1d77462 100644 --- a/internal/cmd/sync.go +++ b/internal/cmd/sync.go @@ -1,7 +1,9 @@ package cmd import ( - "path" + "fmt" + "os" + "path/filepath" "github.com/openshift-knative/deviate/pkg/cli" "github.com/openshift-knative/deviate/pkg/config" @@ -27,14 +29,51 @@ func (s sync) run(cmd *cobra.Command, args []string) error { return cli.Sync(cmd, s.project(args)) //nolint:wrapcheck } +// findGitRoot searches upwards from startPath for a directory containing a .git subdirectory. +func findGitRoot(startPath string) (string, error) { + currentPath, err := filepath.Abs(startPath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path for %s: %w", startPath, err) + } + + for { + gitPath := filepath.Join(currentPath, ".git") + stat, err := os.Stat(gitPath) + if err == nil && stat.IsDir() { + return currentPath, nil + } + + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("error checking for .git directory at %s: %w", gitPath, err) + } + if err == nil && !stat.IsDir() { + return "", fmt.Errorf(".git found at %s but it is not a directory", currentPath) + } + + // .git not found here, try parent + parentPath := filepath.Dir(currentPath) + if parentPath == currentPath { + // Reached the root of the filesystem + return "", fmt.Errorf("'.git' directory not found in %s or any of its parent directories", startPath) + } + currentPath = parentPath + } +} + func (s sync) project(args []string) func() config.Project { return func() config.Project { + configDir := filepath.Dir(s.ConfigPath) + projectRoot, err := findGitRoot(configDir) + if err != nil { + panic(fmt.Errorf("failed to determine project root: %w", err)) + } + project := config.Project{ ConfigPath: s.ConfigPath, - Path: path.Dir(s.ConfigPath), + Path: projectRoot, } if len(args) > 0 { - project.Path = args[0] + project.Path = args[0] // Override if path is provided as a command-line argument } return project } diff --git a/internal/cmd/sync_test.go b/internal/cmd/sync_test.go new file mode 100644 index 0000000..650edc2 --- /dev/null +++ b/internal/cmd/sync_test.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestFindGitRoot(t *testing.T) { + // Helper function to create a temporary directory structure for testing + setupTestDir := func(t *testing.T, gitLocationRelPath string, isDir bool) (string, string) { + t.Helper() + baseDir, err := os.MkdirTemp("", "testFindGitRoot_*") + if err != nil { + t.Fatalf("Failed to create temp base dir: %v", err) + } + + // Default startPath deep within a nested structure + startPath := filepath.Join(baseDir, "project", "subdir1", "subdir2") + if err := os.MkdirAll(startPath, 0755); err != nil { + os.RemoveAll(baseDir) + t.Fatalf("Failed to create startPath %s: %v", startPath, err) + } + + if gitLocationRelPath != "" { + // Construct the full path for .git based on baseDir and the relative gitLocationRelPath + var gitPath string + // Handle cases where gitLocationRelPath is like ".git" (at baseDir) or "project/.git" + if filepath.IsAbs(gitLocationRelPath) { // Should not happen with current test cases, but good for robustness + gitPath = gitLocationRelPath + } else { + gitPath = filepath.Join(baseDir, gitLocationRelPath) + } + + // Ensure the parent directory of the .git entity exists + parentOfGit := filepath.Dir(gitPath) + if err := os.MkdirAll(parentOfGit, 0755); err != nil { + os.RemoveAll(baseDir) + t.Fatalf("Failed to create parent directory for .git entity at %s: %v", parentOfGit, err) + } + + if isDir { + if err := os.Mkdir(gitPath, 0755); err != nil { // Use Mkdir for .git itself + os.RemoveAll(baseDir) + t.Fatalf("Failed to create .git directory at %s: %v", gitPath, err) + } + } else { + f, err := os.Create(gitPath) + if err != nil { + os.RemoveAll(baseDir) + t.Fatalf("Failed to create .git file at %s: %v", gitPath, err) + } + f.Close() + } + } + return baseDir, startPath + } + + tests := []struct { + name string + gitLocationRelPath string // Relative to temp baseDir. E.g., "project/.git" or ".git" (for baseDir/.git) + isGitADir bool + startPathChoice string // "deep", "projectRoot", "baseDir" + expectedRootRelPath string // Expected root relative to baseDir. E.g., "project" or "" (for baseDir itself) + expectError bool + expectedErrorMsg string // Substring to look for in the error message + }{ + { + name: "git in project subdir (start deep)", + gitLocationRelPath: "project/.git", + isGitADir: true, + startPathChoice: "deep", // starts at baseDir/project/subdir1/subdir2 + expectedRootRelPath: "project", + expectError: false, + }, + { + name: "git at baseDir (start deep)", + gitLocationRelPath: ".git", // i.e. baseDir/.git + isGitADir: true, + startPathChoice: "deep", // starts at baseDir/project/subdir1/subdir2 + expectedRootRelPath: "", // Expected to be baseDir + expectError: false, + }, + { + name: "git in project subdir (start at projectRoot)", + gitLocationRelPath: "project/.git", + isGitADir: true, + startPathChoice: "projectRoot", // starts at baseDir/project + expectedRootRelPath: "project", + expectError: false, + }, + { + name: "no git directory found (start deep)", + gitLocationRelPath: "", // No .git created + isGitADir: true, // Irrelevant + startPathChoice: "deep", + expectError: true, + expectedErrorMsg: "'.git' directory not found", + }, + { + name: "git is a file, not a directory (in project)", + gitLocationRelPath: "project/.git", + isGitADir: false, // Create .git as a file + startPathChoice: "deep", + expectError: true, + expectedErrorMsg: ".git found at", // ".git found at ... but it is not a directory" + }, + { + name: "git at baseDir (start at baseDir)", + gitLocationRelPath: ".git", + isGitADir: true, + startPathChoice: "baseDir", // starts at baseDir + expectedRootRelPath: "", + expectError: false, + }, + { + name: "no git at baseDir (start at baseDir)", + gitLocationRelPath: "", + isGitADir: true, + startPathChoice: "baseDir", + expectError: true, + expectedErrorMsg: "'.git' directory not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseDir, defaultStartPath := setupTestDir(t, tt.gitLocationRelPath, tt.isGitADir) + defer os.RemoveAll(baseDir) + + var currentStartPath string + switch tt.startPathChoice { + case "deep": + currentStartPath = defaultStartPath + case "projectRoot": + currentStartPath = filepath.Join(baseDir, "project") + case "baseDir": + currentStartPath = baseDir + default: + currentStartPath = defaultStartPath + } + + var expectedRootAbsPath string + if tt.expectedRootRelPath == "" { + expectedRootAbsPath = baseDir + } else { + expectedRootAbsPath = filepath.Join(baseDir, tt.expectedRootRelPath) + } + + actualRoot, err := findGitRoot(currentStartPath) + + if tt.expectError { + if err == nil { + t.Errorf("findGitRoot() was expected to return an error, but did not") + } else if tt.expectedErrorMsg != "" && !strings.Contains(err.Error(), tt.expectedErrorMsg) { + t.Errorf("findGitRoot() error = \"%v\", expected to contain \"%s\"", err, tt.expectedErrorMsg) + } + } else { + if err != nil { + t.Errorf("findGitRoot() returned an unexpected error: %v", err) + } + normalizedActualRoot := filepath.Clean(actualRoot) + normalizedExpectedRoot := filepath.Clean(expectedRootAbsPath) + + if normalizedActualRoot != normalizedExpectedRoot { + t.Errorf("findGitRoot() actualRoot = %s, expectedRoot = %s", normalizedActualRoot, normalizedExpectedRoot) + } + } + }) + } +}