diff --git a/cmd/clone.go b/cmd/clone.go index 787dd22..75f2c26 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -852,9 +852,23 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) { // Retry checkout errRetry := git.Checkout(repo) if errRetry != nil { - e := fmt.Sprintf("Could not checkout out %s, branch may not exist or may not have any contents/commits, no changes made on: %s Error: %v", repo.CloneBranch, repo.URL, errRetry) - cloneErrors = append(cloneErrors, e) - return + hasRemoteHeads, errHasRemoteHeads := git.HasRemoteHeads(repo) + if errHasRemoteHeads != nil { + e := fmt.Sprintf("Could not checkout %s, branch may not exist or may not have any contents/commits, no changes made on: %s Errors: %v %v", repo.CloneBranch, repo.URL, errRetry, errHasRemoteHeads) + cloneErrors = append(cloneErrors, e) + return + } + if hasRemoteHeads { + // weird, should not happen, return original checkout error + e := fmt.Sprintf("Could not checkout %s, branch may not exist or may not have any contents/commits, no changes made on: %s Error: %v", repo.CloneBranch, repo.URL, errRetry) + cloneErrors = append(cloneErrors, e) + return + } else { + // this is _just_ an empty repository + e := fmt.Sprintf("Could not checkout %s due to repository being empty, no changes made on: %s", repo.CloneBranch, repo.URL) + cloneInfos = append(cloneInfos, e) + return + } } } @@ -1032,24 +1046,29 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) { writeGhorgStats(date, allReposToCloneCount, cloneCount, pulledCount, cloneInfosCount, cloneErrorsCount, updateRemoteCount, newCommits, pruneCount, hasCollisions) } - if os.Getenv("GHORG_EXIT_CODE_ON_CLONE_INFOS") != "0" && cloneInfosCount > 0 { - exitCode, err := strconv.Atoi(os.Getenv("GHORG_EXIT_CODE_ON_CLONE_INFOS")) - if err != nil { - colorlog.PrintError("Could not convert GHORG_EXIT_CODE_ON_CLONE_INFOS from string to integer") - os.Exit(1) - } + if os.Getenv("GHORG_DONT_EXIT_UNDER_TEST") != "true" { + if os.Getenv("GHORG_EXIT_CODE_ON_CLONE_INFOS") != "0" && cloneInfosCount > 0 { + exitCode, err := strconv.Atoi(os.Getenv("GHORG_EXIT_CODE_ON_CLONE_INFOS")) + if err != nil { + colorlog.PrintError("Could not convert GHORG_EXIT_CODE_ON_CLONE_INFOS from string to integer") + os.Exit(1) + } - os.Exit(exitCode) + os.Exit(exitCode) + } } - if cloneErrorsCount > 0 { - exitCode, err := strconv.Atoi(os.Getenv("GHORG_EXIT_CODE_ON_CLONE_ISSUES")) - if err != nil { - colorlog.PrintError("Could not convert GHORG_EXIT_CODE_ON_CLONE_ISSUES from string to integer") - os.Exit(1) + if os.Getenv("GHORG_DONT_EXIT_UNDER_TEST") != "true" { + if cloneErrorsCount > 0 { + exitCode, err := strconv.Atoi(os.Getenv("GHORG_EXIT_CODE_ON_CLONE_ISSUES")) + if err != nil { + colorlog.PrintError("Could not convert GHORG_EXIT_CODE_ON_CLONE_ISSUES from string to integer") + os.Exit(1) + } + os.Exit(exitCode) } - - os.Exit(exitCode) + } else { + cloneErrorsCount = 0 } } diff --git a/cmd/clone_test.go b/cmd/clone_test.go index 3520112..c36b3e0 100644 --- a/cmd/clone_test.go +++ b/cmd/clone_test.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "log" "os" "reflect" @@ -13,6 +14,7 @@ import ( func TestShouldLowerRegularString(t *testing.T) { upperName := "RepoName" + defer setOutputDirName([]string{""}) setOutputDirName([]string{upperName}) if outputDirName != "reponame" { @@ -23,6 +25,7 @@ func TestShouldLowerRegularString(t *testing.T) { func TestShouldNotChangeLowerCasedRegularString(t *testing.T) { lowerName := "repo_name" + defer setOutputDirName([]string{""}) setOutputDirName([]string{lowerName}) if outputDirName != "repo_name" { @@ -34,6 +37,7 @@ func TestReplaceDashWithUnderscore(t *testing.T) { want := "repo-name" lowerName := "repo-name" + defer setOutputDirName([]string{""}) setOutputDirName([]string{lowerName}) if outputDirName != want { @@ -44,6 +48,7 @@ func TestReplaceDashWithUnderscore(t *testing.T) { func TestShouldNotChangeNonLettersString(t *testing.T) { numberName := "1234567_8" + defer setOutputDirName([]string{""}) setOutputDirName([]string{numberName}) if outputDirName != "1234567_8" { @@ -57,6 +62,13 @@ func NewMockGit() MockGitClient { return MockGitClient{} } +func (g MockGitClient) HasRemoteHeads(repo scm.Repo) (bool, error) { + if repo.Name == "testRepoEmpty" { + return false, nil + } + return true, nil +} + func (g MockGitClient) Clone(repo scm.Repo) error { _, err := os.MkdirTemp(os.Getenv("GHORG_ABSOLUTE_PATH_TO_CLONE_TO"), repo.Name) if err != nil { @@ -74,6 +86,9 @@ func (g MockGitClient) SetOriginWithCredentials(repo scm.Repo) error { } func (g MockGitClient) Checkout(repo scm.Repo) error { + if repo.Name == "testRepoEmpty" { + return errors.New("Cannot checkout any specific branch in an empty repository") + } return nil } @@ -144,6 +159,49 @@ func TestInitialClone(t *testing.T) { } } +func TestCloneEmptyRepo(t *testing.T) { + defer UnsetEnv("GHORG_")() + dir, err := os.MkdirTemp("", "ghorg_test_empty_repo") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(dir) + + os.Setenv("GHORG_ABSOLUTE_PATH_TO_CLONE_TO", dir) + setOuputDirAbsolutePath() + + os.Setenv("GHORG_DONT_EXIT_UNDER_TEST", "true") + + // simulate a previous clone of empty git repository + repoErr := os.Mkdir(outputDirAbsolutePath+"/"+"testRepoEmpty", 0o700) + if repoErr != nil { + log.Fatal(repoErr) + } + defer os.RemoveAll(outputDirAbsolutePath + "/" + "testRepoEmpty") + + os.Setenv("GHORG_CONCURRENCY", "1") + var testRepos = []scm.Repo{ + { + Name: "testRepoEmpty", + URL: "git@github.com:org/testRepoEmpty.git", + CloneBranch: "main", + }, + } + + mockGit := NewMockGit() + CloneAllRepos(mockGit, testRepos) + gotInfos := len(cloneInfos) + expectedInfos := 1 + if gotInfos != expectedInfos { + t.Fatalf("Wrong number of cloneInfos, expected: %v, got: %v", expectedInfos, gotInfos) + } + gotInfo := cloneInfos[0] + expected := "Could not checkout main due to repository being empty, no changes made on: git@github.com:org/testRepoEmpty.git" + if gotInfo != expected { + t.Errorf("Wrong cloneInfo, expected: %v, got: %v", expected, gotInfo) + } +} + func TestMatchPrefix(t *testing.T) { defer UnsetEnv("GHORG_")() dir, err := os.MkdirTemp("", "ghorg_test_match_prefix") @@ -154,6 +212,8 @@ func TestMatchPrefix(t *testing.T) { os.Setenv("GHORG_ABSOLUTE_PATH_TO_CLONE_TO", dir) os.Setenv("GHORG_CONCURRENCY", "1") os.Setenv("GHORG_MATCH_PREFIX", "test") + os.Setenv("GHORG_DONT_EXIT_UNDER_TEST", "true") + var testRepos = []scm.Repo{ { Name: "testRepoOne", @@ -191,6 +251,8 @@ func TestExcludeMatchPrefix(t *testing.T) { os.Setenv("GHORG_ABSOLUTE_PATH_TO_CLONE_TO", dir) os.Setenv("GHORG_CONCURRENCY", "1") os.Setenv("GHORG_EXCLUDE_MATCH_PREFIX", "test") + os.Setenv("GHORG_DONT_EXIT_UNDER_TEST", "true") + var testRepos = []scm.Repo{ { Name: "testRepoOne", @@ -228,6 +290,8 @@ func TestMatchRegex(t *testing.T) { os.Setenv("GHORG_ABSOLUTE_PATH_TO_CLONE_TO", dir) os.Setenv("GHORG_CONCURRENCY", "1") os.Setenv("GHORG_MATCH_REGEX", "^test-") + os.Setenv("GHORG_DONT_EXIT_UNDER_TEST", "true") + var testRepos = []scm.Repo{ { Name: "test-RepoOne", @@ -267,6 +331,8 @@ func TestExcludeMatchRegex(t *testing.T) { os.Setenv("GHORG_CONCURRENCY", "1") os.Setenv("GHORG_OUTPUT_DIR", testDescriptor) os.Setenv("GHORG_EXCLUDE_MATCH_REGEX", "^test-") + os.Setenv("GHORG_DONT_EXIT_UNDER_TEST", "true") + var testRepos = []scm.Repo{ { Name: "test-RepoOne", diff --git a/git/git.go b/git/git.go index 7254b19..03d6b4d 100644 --- a/git/git.go +++ b/git/git.go @@ -1,6 +1,7 @@ package git import ( + "errors" "fmt" "os" "os/exec" @@ -26,6 +27,7 @@ type Gitter interface { FetchAll(scm.Repo) error FetchCloneBranch(scm.Repo) error RepoCommitCount(scm.Repo) (int, error) + HasRemoteHeads(scm.Repo) (bool, error) } type GitClient struct{} @@ -51,6 +53,35 @@ func printDebugCmd(cmd *exec.Cmd, repo scm.Repo) error { return err } +func (g GitClient) HasRemoteHeads(repo scm.Repo) (bool, error) { + cmd := exec.Command("git", "ls-remote", "--heads", "--quiet", "--exit-code") + + err := cmd.Run() + if err == nil { + // successfully listed the remote heads + return true, nil + } + + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + // error, but no exit code, return err + return false, err + } + + exitCode := exitError.ExitCode() + if exitCode == 0 { + // ls-remote did successfully list the remote heads + return true, nil + } else if exitCode == 2 { + // repository is empty + return false, nil + } else { + // another exit code, simply return err + return false, err + } + +} + func (g GitClient) Clone(repo scm.Repo) error { args := []string{"clone", repo.CloneURL, repo.HostPath}