Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add/ghorg prune untouched #459

Merged
merged 9 commits into from
Sep 22, 2024
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
### Added
- Reclone name and description to reclone output
- GHORG_PRESERVE_SCM_HOSTNAME, note that this feature changes the directory struture that gitlab all-users and all-groups clone into; thanks @rrrix
- GHORG_PRUNE_UNTOUCHED, to prune repos that users make no changes in; thanks @MaxG87
### Changed
### Deprecated
### Removed
Expand Down
94 changes: 91 additions & 3 deletions cmd/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,14 @@ func cloneFunc(cmd *cobra.Command, argz []string) {
os.Setenv("GHORG_PRUNE_NO_CONFIRM", "true")
}

if cmd.Flags().Changed("prune-untouched") {
os.Setenv("GHORG_PRUNE_UNTOUCHED", "true")
}

if cmd.Flags().Changed("prune-untouched-no-confirm") {
os.Setenv("GHORG_PRUNE_UNTOUCHED_NO_CONFIRM", "true")
}

if cmd.Flags().Changed("fetch-all") {
os.Setenv("GHORG_FETCH_ALL", "true")
}
Expand Down Expand Up @@ -663,6 +671,7 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) {

// maps in go are not safe for concurrent use
var mutex = &sync.RWMutex{}
var untouchedReposToPrune []string

for i := range cloneTargets {
repo := cloneTargets[i]
Expand Down Expand Up @@ -738,8 +747,61 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) {
repo.HostPath = filepath.Join(outputDirAbsolutePath, repoSlug, repo.GitLabSnippetInfo.Title+"-"+repo.GitLabSnippetInfo.ID)
}

action := "cloning"
repoWillBePulled := repoExistsLocally(repo)

// Repos are considered untouched if
// 1. There are no new branches, ghorg only clones one branch so if there are more then the user has done something in the repo
// 2. If there are no branches locally, this means the repo is empty or all commits have been removed
// 3. If there are any commits on the default branch locally that are not on the remote
// 4. There are any modified changes locally
if os.Getenv("GHORG_PRUNE_UNTOUCHED") == "true" && repoWillBePulled {
git.FetchCloneBranch(repo)

branches, err := git.Branch(repo)
if err != nil {
colorlog.PrintError(fmt.Sprintf("Failed to list local branches for repository %s: %v", repo.Name, err))
return
}

// Delete if it has no branches
if branches == "" {
untouchedReposToPrune = append(untouchedReposToPrune, repo.HostPath)
return
}

if len(strings.Split(strings.TrimSpace(branches), "\n")) > 1 {
return
}

status, err := git.ShortStatus(repo)
if err != nil {
colorlog.PrintError(fmt.Sprintf("Failed to get short status for repository %s: %v", repo.Name, err))
return
}

if status != "" {
return
}

// Check for new commits on the branch that exist locally but not on the remote
commits, err := git.RevListCompare(repo, "HEAD", "@{u}")
if err != nil {
colorlog.PrintError(fmt.Sprintf("Failed to get commit differences for repository %s. The repository may be empty or does not have a .git directory. Error: %v", repo.Name, err))
return
}
if commits != "" {
return
}

untouchedReposToPrune = append(untouchedReposToPrune, repo.HostPath)
}

// Don't clone any new repos when prune untouched is active
if os.Getenv("GHORG_PRUNE_UNTOUCHED") == "true" {
return
}

action := "cloning"
if repoWillBePulled {
// prevents git from asking for user for credentials, needs to be unset so creds aren't stored
err := git.SetOriginWithCredentials(repo)
Expand Down Expand Up @@ -918,9 +980,30 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) {
}

limit.WaitAndClose()
var untouchedPrunes int

if os.Getenv("GHORG_PRUNE_UNTOUCHED") == "true" && len(untouchedReposToPrune) > 0 {
if os.Getenv("GHORG_PRUNE_UNTOUCHED_NO_CONFIRM") != "true" {
colorlog.PrintSuccess(fmt.Sprintf("PLEASE CONFIRM: The following %d untouched repositories will be deleted. Press enter to confirm: ", len(untouchedReposToPrune)))
for _, repoPath := range untouchedReposToPrune {
colorlog.PrintInfo(fmt.Sprintf("- %s", repoPath))
}
fmt.Scanln()
}

for _, repoPath := range untouchedReposToPrune {
err := os.RemoveAll(repoPath)
if err != nil {
colorlog.PrintError(fmt.Sprintf("Failed to prune repository at %s: %v", repoPath, err))
} else {
untouchedPrunes++
colorlog.PrintSuccess(fmt.Sprintf("Successfully deleted %s", repoPath))
}
}
}

printRemainingMessages()
printCloneStatsMessage(cloneCount, pulledCount, updateRemoteCount, newCommits)
printCloneStatsMessage(cloneCount, pulledCount, updateRemoteCount, newCommits, untouchedPrunes)

if hasCollisions {
fmt.Println("")
Expand Down Expand Up @@ -1230,7 +1313,7 @@ func pruneRepos(cloneTargets []scm.Repo) int {
return count
}

func printCloneStatsMessage(cloneCount, pulledCount, updateRemoteCount, newCommits int) {
func printCloneStatsMessage(cloneCount, pulledCount, updateRemoteCount, newCommits, untouchedPrunes int) {
if updateRemoteCount > 0 {
colorlog.PrintSuccess(fmt.Sprintf("New clones: %v, existing resources pulled: %v, total new commits: %v, remotes updated: %v", cloneCount, pulledCount, newCommits, updateRemoteCount))
return
Expand All @@ -1241,6 +1324,11 @@ func printCloneStatsMessage(cloneCount, pulledCount, updateRemoteCount, newCommi
return
}

if untouchedPrunes > 0 {
colorlog.PrintSuccess(fmt.Sprintf("New clones: %v, existing resources pulled: %v, total prunes: %v", cloneCount, pulledCount, untouchedPrunes))
return
}

colorlog.PrintSuccess(fmt.Sprintf("New clones: %v, existing resources pulled: %v", cloneCount, pulledCount))
}

Expand Down
12 changes: 12 additions & 0 deletions cmd/clone_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ func (g MockGitClient) RepoCommitCount(repo scm.Repo) (int, error) {
return 0, nil
}

func (g MockGitClient) Branch(repo scm.Repo) (string, error) {
return "", nil
}

func (g MockGitClient) RevListCompare(repo scm.Repo, ref1 string, ref2 string) (string, error) {
return "", nil
}

func (g MockGitClient) ShortStatus(repo scm.Repo) (string, error) {
return "", nil
}

func TestInitialClone(t *testing.T) {
defer UnsetEnv("GHORG_")()
dir, err := os.MkdirTemp("", "ghorg_test_initial")
Expand Down
10 changes: 10 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ var (
noDirSize bool
ghorgStatsEnabled bool
ghorgPreserveScmHostname bool
ghorgPruneUntouched bool
ghorgPruneUntouchedNoConfirm bool
args []string
cloneErrors []string
cloneInfos []string
Expand Down Expand Up @@ -173,6 +175,10 @@ func getOrSetDefaults(envVar string) {
os.Setenv(envVar, "false")
case "GHORG_PRUNE_NO_CONFIRM":
os.Setenv(envVar, "false")
case "GHORG_PRUNE_UNTOUCHED":
os.Setenv(envVar, "false")
case "GHORG_PRUNE_UNTOUCHED_NO_CONFIRM":
os.Setenv(envVar, "false")
case "GHORG_INSECURE_GITLAB_CLIENT":
os.Setenv(envVar, "false")
case "GHORG_INSECURE_GITEA_CLIENT":
Expand Down Expand Up @@ -275,6 +281,8 @@ func InitConfig() {
getOrSetDefaults("GHORG_FETCH_ALL")
getOrSetDefaults("GHORG_PRUNE")
getOrSetDefaults("GHORG_PRUNE_NO_CONFIRM")
getOrSetDefaults("GHORG_PRUNE_UNTOUCHED")
getOrSetDefaults("GHORG_PRUNE_UNTOUCHED_NO_CONFIRM")
getOrSetDefaults("GHORG_DRY_RUN")
getOrSetDefaults("GHORG_GITHUB_USER_OPTION")
getOrSetDefaults("GHORG_CLONE_WIKI")
Expand Down Expand Up @@ -364,6 +372,8 @@ func init() {
cloneCmd.Flags().BoolVar(&includeSubmodules, "include-submodules", false, "GHORG_INCLUDE_SUBMODULES - Include submodules in all clone and pull operations.")
cloneCmd.Flags().BoolVar(&ghorgStatsEnabled, "stats-enabled", false, "GHORG_STATS_ENABLED - Creates a CSV in the GHORG_ABSOLUTE_PATH_TO_CLONE_TO called _ghorg_stats.csv with info about each clone. This allows you to track clone data over time such as number of commits and size in megabytes of the clone directory.")
cloneCmd.Flags().BoolVar(&ghorgPreserveScmHostname, "preserve-scm-hostname", false, "GHORG_PRESERVE_SCM_HOSTNAME - Appends the scm hostname to the GHORG_ABSOLUTE_PATH_TO_CLONE_TO which will organize your clones into specific folders by the scm provider. e.g. /github.com/kuberentes")
cloneCmd.Flags().BoolVar(&ghorgPruneUntouched, "prune-untouched", false, "GHORG_PRUNE_UNTOUCHED - Prune repositories that don't have any local changes, see sample-conf.yaml for more details")
cloneCmd.Flags().BoolVar(&ghorgPruneUntouchedNoConfirm, "prune-untouched-no-confirm", false, "GHORG_PRUNE_UNTOUCHED_NO_CONFIRM - Automatically delete repos without showing an interactive confirmation prompt.")
cloneCmd.Flags().StringVarP(&baseURL, "base-url", "", "", "GHORG_SCM_BASE_URL - Change SCM base url, for on self hosted instances (currently gitlab, gitea and github (use format of https://git.mydomain.com/api/v3))")
cloneCmd.Flags().StringVarP(&concurrency, "concurrency", "", "", "GHORG_CONCURRENCY - Max goroutines to spin up while cloning (default 25)")
cloneCmd.Flags().StringVarP(&cloneDepth, "clone-depth", "", "", "GHORG_CLONE_DEPTH - Create a shallow clone with a history truncated to the specified number of commits")
Expand Down
51 changes: 51 additions & 0 deletions git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type Gitter interface {
SetOriginWithCredentials(scm.Repo) error
Clean(scm.Repo) error
Checkout(scm.Repo) error
RevListCompare(scm.Repo, string, string) (string, error)
ShortStatus(scm.Repo) (string, error)
Branch(scm.Repo) (string, error)
UpdateRemote(scm.Repo) error
FetchAll(scm.Repo) error
FetchCloneBranch(scm.Repo) error
Expand Down Expand Up @@ -182,6 +185,35 @@ func (g GitClient) FetchAll(repo scm.Repo) error {
return cmd.Run()
}

func (g GitClient) Branch(repo scm.Repo) (string, error) {
args := []string{"branch"}

cmd := exec.Command("git", args...)
cmd.Dir = repo.HostPath
if os.Getenv("GHORG_DEBUG") != "" {
if err := printDebugCmd(cmd, repo); err != nil {
return "", err
}
}

output, err := cmd.Output()
if err != nil {
return "", err
}

return strings.TrimSpace(string(output)), nil
}

// RevListCompare returns the list of commits in the local branch that are not in the remote branch.
func (g GitClient) RevListCompare(repo scm.Repo, localBranch string, remoteBranch string) (string, error) {
cmd := exec.Command("git", "-C", repo.HostPath, "rev-list", localBranch, "^"+remoteBranch)
output, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}

func (g GitClient) FetchCloneBranch(repo scm.Repo) error {
args := []string{"fetch", "origin", repo.CloneBranch}

Expand All @@ -198,6 +230,25 @@ func (g GitClient) FetchCloneBranch(repo scm.Repo) error {
return cmd.Run()
}

func (g GitClient) ShortStatus(repo scm.Repo) (string, error) {
args := []string{"status", "--short"}

cmd := exec.Command("git", args...)
cmd.Dir = repo.HostPath
if os.Getenv("GHORG_DEBUG") != "" {
if err := printDebugCmd(cmd, repo); err != nil {
return "", err
}
}

output, err := cmd.Output()
if err != nil {
return "", err
}

return strings.TrimSpace(string(output)), nil
}

func (g GitClient) RepoCommitCount(repo scm.Repo) (int, error) {
args := []string{"rev-list", "--count", repo.CloneBranch}
cmd := exec.Command("git", args...)
Expand Down
12 changes: 12 additions & 0 deletions sample-conf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ GHORG_PRUNE: false
# flag (--prune-no-confirm)
GHORG_PRUNE_NO_CONFIRM: false

# Prune repositories that are considered untouched. A repository is considered untouched if:
# 1. There are no new branches, ghorg only clones one branch so if there are more then the user has done something in the repo
# 2. If there are no branches locally, this means the repo is empty or all commits have been removed
# 3. If there are any commits on the default branch locally that are not on the remote
# 4. There are any modified changes locally
# flag (--prune-untouched)
GHORG_PRUNE_UNTOUCHED: false

# Automatically delete repos without showing an interactive confirmation prompt.
# flag (--prune-untouched-no-confirm)
GHORG_PRUNE_UNTOUCHED_NO_CONFIRM: false

# Color output (enabled, disabled)
# flag( --color) eg: --color=enabled eg: --color=disabled
GHORG_COLOR: disabled
Expand Down
11 changes: 11 additions & 0 deletions scripts/github_cloud_integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ else
exit 1
fi

# clone an org preserving scm hostname
ghorg clone $GITHUB_ORG --token=$GITHUB_TOKEN --preserve-scm-hostname --prune-untouched --prune-untouched-no-confirm

if [ -z "$(ls -A $HOME/ghorg/github.com/$GITHUB_ORG)" ]
then
echo "Pass: github org clone preserving scm hostname prune untouched"
else
echo "Fail: github org clone preserving scm hostnamey prune untouched"
exit 1
fi

# clone an org with no config file to a specific path
ghorg clone $GITHUB_ORG --token=$GITHUB_TOKEN --path=/tmp --output-dir=testing_output_dir

Expand Down
Loading
Loading