diff --git a/cmd/clone.go b/cmd/clone.go index 75f2c2632..a23054143 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -5,6 +5,7 @@ import ( "bufio" "crypto/sha256" "fmt" + "io/fs" "log" "net/url" "os" @@ -556,18 +557,18 @@ func printDryRun(repos []scm.Repo) { // to do. colorlog.PrintInfo("\nScanning for local clones that have been removed on remote...") - files, err := os.ReadDir(outputDirAbsolutePath) + repositories, err := getRelativePathRepositories(outputDirAbsolutePath) if err != nil { log.Fatal(err) } eligibleForPrune := 0 - for _, f := range files { + for _, repository := range repositories { // for each item in the org's clone directory, let's make sure we found a // corresponding repo on the remote. - if !sliceContainsNamedRepo(repos, f.Name()) { + if !sliceContainsNamedRepo(repos, repository) { eligibleForPrune++ - colorlog.PrintSubtleInfo(fmt.Sprintf("%s not found in remote.", f.Name())) + colorlog.PrintSubtleInfo(fmt.Sprintf("%s not found in remote.", repository)) } } colorlog.PrintSuccess(fmt.Sprintf("Local clones eligible for pruning: %d", eligibleForPrune)) @@ -599,6 +600,29 @@ func getCloneableInventory(allRepos []scm.Repo) (int, int, int, int) { return total, repos, snippets, wikis } +func isGitRepository(path string) bool { + stat, err := os.Stat(filepath.Join(path, ".git")) + return err == nil && stat.IsDir() +} + +func getRelativePathRepositories(root string) ([]string, error) { + var relativePaths []string + err := filepath.WalkDir(root, func(path string, file fs.DirEntry, err error) error { + if err != nil { + return err + } + if path != outputDirAbsolutePath && file.IsDir() && isGitRepository(path) { + rel, err := filepath.Rel(outputDirAbsolutePath, path) + if err != nil { + return err + } + relativePaths = append(relativePaths, rel) + } + return nil + }) + return relativePaths, err +} + // CloneAllRepos clones all repos func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) { // Filter repos that have attributes that don't need specific scm api calls @@ -1294,7 +1318,7 @@ func pruneRepos(cloneTargets []scm.Repo) int { count := 0 colorlog.PrintInfo("\nScanning for local clones that have been removed on remote...") - files, err := os.ReadDir(outputDirAbsolutePath) + repositories, err := getRelativePathRepositories(outputDirAbsolutePath) if err != nil { log.Fatal(err) } @@ -1303,18 +1327,18 @@ func pruneRepos(cloneTargets []scm.Repo) int { // break out of the loop. userAgreesToDelete := true pruneNoConfirm := os.Getenv("GHORG_PRUNE_NO_CONFIRM") == "true" - for _, f := range files { + for _, repository := range repositories { // For each item in the org's clone directory, let's make sure we found a corresponding // repo on the remote. We check userAgreesToDelete here too, so that if the user says // "No" at any time, we stop trying to prune things altogether. - if userAgreesToDelete && !sliceContainsNamedRepo(cloneTargets, f.Name()) { + if userAgreesToDelete && !sliceContainsNamedRepo(cloneTargets, repository) { // If the user specified --prune-no-confirm, we needn't prompt interactively. userAgreesToDelete = pruneNoConfirm || interactiveYesNoPrompt( - fmt.Sprintf("%s was not found in remote. Do you want to prune it?", f.Name())) + fmt.Sprintf("%s was not found in remote. Do you want to prune it?", repository)) if userAgreesToDelete { colorlog.PrintSubtleInfo( - fmt.Sprintf("Deleting %s", filepath.Join(outputDirAbsolutePath, f.Name()))) - err = os.RemoveAll(filepath.Join(outputDirAbsolutePath, f.Name())) + fmt.Sprintf("Deleting %s", repository)) + err = os.RemoveAll(filepath.Join(outputDirAbsolutePath, repository)) count++ if err != nil { log.Fatal(err) @@ -1365,18 +1389,9 @@ func interactiveYesNoPrompt(prompt string) bool { } // There's probably a nicer way of finding whether any scm.Repo in the slice matches a given name. -// TODO, currently this does not work if user sets --preserve-dir see https://github.com/gabrie30/ghorg/issues/210 for more info func sliceContainsNamedRepo(haystack []scm.Repo, needle string) bool { - - if os.Getenv("GHORG_PRESERVE_DIRECTORY_STRUCTURE") == "true" { - colorlog.PrintError("GHORG_PRUNE (--prune) does not currently work in combination with GHORG_PRESERVE_DIRECTORY_STRUCTURE (--preserve-dir), this will come in later versions") - os.Exit(1) - } - for _, repo := range haystack { - basepath := filepath.Base(repo.Path) - - if basepath == needle { + if repo.Path == needle { return true } } diff --git a/cmd/clone_test.go b/cmd/clone_test.go index c36b3e05f..857015d6d 100644 --- a/cmd/clone_test.go +++ b/cmd/clone_test.go @@ -4,6 +4,7 @@ import ( "errors" "log" "os" + "path/filepath" "reflect" "strings" "testing" @@ -525,3 +526,120 @@ func Test_filterDownReposIfTargetReposPathEnabled(t *testing.T) { }) } } + +func TestRelativePathRepositories(t *testing.T) { + testing, err := os.MkdirTemp("", "testing") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(testing) + + outputDirAbsolutePath = testing + + repository := filepath.Join(testing, "repository", ".git") + if err := os.MkdirAll(repository, 0o755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + files, err := getRelativePathRepositories(testing) + if err != nil { + t.Fatalf("getRelativePathRepositories returned an error: %v", err) + } + + if len(files) != 1 { + t.Errorf("Expected 1 directory, got %d", len(files)) + } + + if len(files) > 0 && files[0] != "repository" { + t.Errorf("Expected 'repository', got '%s'", files[0]) + } +} + +func TestRelativePathRepositoriesNoGitDir(t *testing.T) { + testing, err := os.MkdirTemp("", "testing") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(testing) + + outputDirAbsolutePath = testing + + directory := filepath.Join(testing, "directory") + if err := os.MkdirAll(directory, 0o755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + files, err := getRelativePathRepositories(testing) + if err != nil { + t.Fatalf("getRelativePathRepositories returned an error: %v", err) + } + + if len(files) != 0 { + t.Errorf("Expected 0 directories, got %d", len(files)) + } +} + +func TestRelativePathRepositoriesWithGitSubmodule(t *testing.T) { + testing, err := os.MkdirTemp("", "testing") + if err != nil { + t.Fatalf("Failed to create temp directory: %v", err) + } + defer os.RemoveAll(testing) + + outputDirAbsolutePath = testing + + repository := filepath.Join(testing, "repository", ".git") + submodule := filepath.Join(testing, "repository", "submodule", ".git") + + if err := os.MkdirAll(repository, 0o755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if err := os.MkdirAll(filepath.Dir(submodule), 0o755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + if _, err := os.Create(submodule); err != nil { + t.Fatalf("Failed to create .git file: %v", err) + } + + files, err := getRelativePathRepositories(testing) + if err != nil { + t.Fatalf("getRelativePathRepositories returned an error: %v", err) + } + + if len(files) != 1 { + t.Errorf("Expected 1 directory, got %d", len(files)) + } + + if len(files) > 0 && files[0] != "repository" { + t.Errorf("Expected 'repository', got '%s'", files[0]) + } +} + +func TestRelativePathRepositoriesDeeplyNested(t *testing.T) { + testing, err := os.MkdirTemp("", "testing") + if err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + defer os.RemoveAll(testing) + + outputDirAbsolutePath = testing + + repository := filepath.Join(testing, "deeply", "nested", "repository", ".git") + if err := os.MkdirAll(repository, 0o755); err != nil { + t.Fatalf("Failed to create temp repository: %v", err) + } + + files, err := getRelativePathRepositories(testing) + if err != nil { + t.Fatalf("getRelativePathRepositories returned an error: %v", err) + } + + if len(files) != 1 { + t.Errorf("Expected 1 directory, got %d", len(files)) + } + + expected := filepath.Join("deeply", "nested", "repository") + if len(files) > 0 && files[0] != expected { + t.Errorf("Expected '%s', got '%s'", expected, files[0]) + } +} diff --git a/scripts/gitlab_cloud_integration_tests.sh b/scripts/gitlab_cloud_integration_tests.sh index 201db79df..c2b6c8d8a 100755 --- a/scripts/gitlab_cloud_integration_tests.sh +++ b/scripts/gitlab_cloud_integration_tests.sh @@ -87,6 +87,21 @@ else exit 1 fi +# PRUNE AND PRESERVE DIR +ghorg clone $GITLAB_GROUP --token="${GITLAB_TOKEN}" --scm=gitlab --prune --preserve-dir --prune-no-confirm +git init "${HOME}"/ghorg/"${GITLAB_GROUP}"/wayne-enterprises/wayne-industries/prunable +ghorg clone $GITLAB_GROUP --token="${GITLAB_TOKEN}" --scm=gitlab --prune --preserve-dir --prune-no-confirm + +if [ -e "${HOME}"/ghorg/"${GITLAB_GROUP}"/wayne-enterprises/wayne-industries/microservice ] && \ + [ ! -e "${HOME}"/ghorg/"${GITLAB_GROUP}"/wayne-enterprises/wayne-industries/prunable ] +then + echo "Pass: gitlab org clone preserve dir, prune" + rm -rf "${HOME}/ghorg/${GITLAB_GROUP}" +else + echo "Fail: gitlab org clone preserve dir, prune" + exit 1 +fi + # REPO NAME COLLISION ghorg clone $GITLAB_GROUP_2 --token="${GITLAB_TOKEN}" --scm=gitlab