From e3a4dbcb49b056f8af7ebec19c6400a473aa4b6e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 18:30:08 -0700 Subject: [PATCH] Add GHORG_GITHUB_USER_GISTS flag to clone a GitHub user's gists (#626) --- cmd/clone.go | 79 +++++++++++++++-- cmd/clone_test.go | 75 ++++++++++++++++ cmd/root.go | 5 ++ sample-conf.yaml | 5 ++ scm/github.go | 116 +++++++++++++++++++++++++ scm/github_test.go | 208 +++++++++++++++++++++++++++++++++++++++++++++ scm/structs.go | 2 + 7 files changed, 481 insertions(+), 9 deletions(-) diff --git a/cmd/clone.go b/cmd/clone.go index cf95105..7cfd6d4 100644 --- a/cmd/clone.go +++ b/cmd/clone.go @@ -303,6 +303,10 @@ func cloneFunc(cmd *cobra.Command, argz []string) { os.Setenv("GHORG_CLONE_SNIPPETS", "true") } + if cmd.Flags().Changed("github-user-gists") { + os.Setenv("GHORG_GITHUB_USER_GISTS", "true") + } + if cmd.Flags().Changed("insecure-gitlab-client") { os.Setenv("GHORG_INSECURE_GITLAB_CLIENT", "true") } @@ -434,6 +438,35 @@ func setupRepoClone() { cachedDirSizeMB = 0 isDirSizeCached = false + if os.Getenv("GHORG_GITHUB_USER_GISTS") == "true" { + if os.Getenv("GHORG_SCM_TYPE") != "github" { + colorlog.PrintErrorAndExit("GHORG_GITHUB_USER_GISTS is only supported for GitHub, please set --scm=github") + } + if os.Getenv("GHORG_CLONE_TYPE") != "user" { + colorlog.PrintErrorAndExit("GHORG_GITHUB_USER_GISTS is only supported for user clones, please set --clone-type=user") + } + + gistTargets, err := getAllUserGistCloneUrls() + if err != nil { + colorlog.PrintError("Encountered an error fetching gists, aborting") + fmt.Println(err) + os.Exit(1) + } + + if len(gistTargets) == 0 { + colorlog.PrintInfo("No gists found for github user: " + targetCloneSource + ", please verify you have sufficient permissions, double check spelling and try again.") + os.Exit(0) + } + + // Clone gists into a ghorg-gists subdirectory within the user's clone directory + originalOutputDirAbsolutePath := outputDirAbsolutePath + outputDirAbsolutePath = filepath.Join(originalOutputDirAbsolutePath, "ghorg-gists") + g := git.NewGit() + CloneAllRepos(g, gistTargets) + outputDirAbsolutePath = originalOutputDirAbsolutePath + return + } + var cloneTargets []scm.Repo var err error @@ -456,8 +489,8 @@ func setupRepoClone() { colorlog.PrintInfo("No repos found for " + os.Getenv("GHORG_SCM_TYPE") + " " + os.Getenv("GHORG_CLONE_TYPE") + ": " + targetCloneSource + ", please verify you have sufficient permissions to clone target repos, double check spelling and try again.") os.Exit(0) } - git := git.NewGit() - CloneAllRepos(git, cloneTargets) + g := git.NewGit() + CloneAllRepos(g, cloneTargets) } func getAllOrgCloneUrls() ([]scm.Repo, error) { @@ -468,6 +501,23 @@ func getAllUserCloneUrls() ([]scm.Repo, error) { return getCloneUrls(false) } +func getAllUserGistCloneUrls() ([]scm.Repo, error) { + asciiTime() + PrintConfigs() + client, err := scm.GetClient("github") + if err != nil { + colorlog.PrintError(err) + os.Exit(1) + } + + githubClient, ok := client.(scm.Github) + if !ok { + colorlog.PrintErrorAndExit("Unable to cast client to GitHub client for gist fetching") + } + + return githubClient.GetUserGists(targetCloneSource) +} + func getCloneUrls(isOrg bool) ([]scm.Repo, error) { asciiTime() PrintConfigs() @@ -658,19 +708,21 @@ func trimCollisionFilename(filename string) string { return filename } -func getCloneableInventory(allRepos []scm.Repo) (int, int, int, int) { - var wikis, snippets, repos, total int +func getCloneableInventory(allRepos []scm.Repo) (int, int, int, int, int) { + var wikis, snippets, repos, gists, total int for _, repo := range allRepos { if repo.IsGitLabSnippet { snippets++ } else if repo.IsWiki { wikis++ + } else if repo.IsGitHubGist { + gists++ } else { repos++ } } - total = repos + snippets + wikis - return total, repos, snippets, wikis + total = repos + snippets + wikis + gists + return total, repos, snippets, wikis, gists } func isGitRepository(path string) bool { @@ -702,10 +754,12 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) { filter := NewRepositoryFilter() cloneTargets = filter.ApplyAllFilters(cloneTargets) - totalResourcesToClone, reposToCloneCount, snippetToCloneCount, wikisToCloneCount := getCloneableInventory(cloneTargets) + totalResourcesToClone, reposToCloneCount, snippetToCloneCount, wikisToCloneCount, gistsToCloneCount := getCloneableInventory(cloneTargets) - if os.Getenv("GHORG_CLONE_WIKI") == "true" && os.Getenv("GHORG_CLONE_SNIPPETS") == "true" { - m := fmt.Sprintf("%v resources to clone found in %v, %v repos, %v snippets, and %v wikis\n", totalResourcesToClone, targetCloneSource, snippetToCloneCount, reposToCloneCount, wikisToCloneCount) + if os.Getenv("GHORG_GITHUB_USER_GISTS") == "true" { + colorlog.PrintInfo(fmt.Sprintf("%v gists found for %v\n", gistsToCloneCount, targetCloneSource)) + } else if os.Getenv("GHORG_CLONE_WIKI") == "true" && os.Getenv("GHORG_CLONE_SNIPPETS") == "true" { + m := fmt.Sprintf("%v resources to clone found in %v, %v repos, %v snippets, and %v wikis\n", totalResourcesToClone, targetCloneSource, reposToCloneCount, snippetToCloneCount, wikisToCloneCount) colorlog.PrintInfo(m) } else if os.Getenv("GHORG_CLONE_WIKI") == "true" { m := fmt.Sprintf("%v resources to clone found in %v, %v repos and %v wikis\n", totalResourcesToClone, targetCloneSource, reposToCloneCount, wikisToCloneCount) @@ -757,6 +811,10 @@ func CloneAllRepos(git git.Gitter, cloneTargets []scm.Repo) { // The URL handling in getAppNameFromURL makes strong presumptions that the URL will end in an // extension like '.git', but this is not the case for sourcehut (and possibly other forges). repoSlug = repo.Name + } else if repo.IsGitHubGist { + // Gist folder names are pre-computed in filterGists from the primary filename + // (without extension, lowercased, with collision suffix appended when needed). + repoSlug = repo.Name } else if repo.IsGitLabSnippet && !repo.IsGitLabRootLevelSnippet { repoSlug = getAppNameFromURL(repo.GitLabSnippetInfo.URLOfRepo) } else if repo.IsGitLabRootLevelSnippet { @@ -1277,6 +1335,9 @@ func PrintConfigs() { if os.Getenv("GHORG_CLONE_SNIPPETS") == "true" { colorlog.PrintInfo("* Snippets : " + os.Getenv("GHORG_CLONE_SNIPPETS")) } + if os.Getenv("GHORG_GITHUB_USER_GISTS") == "true" { + colorlog.PrintInfo("* Gists : " + os.Getenv("GHORG_GITHUB_USER_GISTS")) + } if configs.GhorgIgnoreDetected() { colorlog.PrintInfo("* Ghorgignore : " + configs.GhorgIgnoreLocation()) } diff --git a/cmd/clone_test.go b/cmd/clone_test.go index 9fa15ba..8020623 100644 --- a/cmd/clone_test.go +++ b/cmd/clone_test.go @@ -1113,3 +1113,78 @@ func TestPrintFinishedWithDirSize_NoTiming(t *testing.T) { // The function should complete without error and not include timing info } + +func TestGetCloneableInventoryWithGists(t *testing.T) { + repos := []scm.Repo{ + {Name: "repo1"}, + {Name: "repo2"}, + {Name: "wiki1", IsWiki: true}, + {Name: "gist1", IsGitHubGist: true}, + {Name: "gist2", IsGitHubGist: true}, + } + + total, repoCount, snippetCount, wikiCount, gistCount := getCloneableInventory(repos) + + if total != 5 { + t.Errorf("Expected total 5, got %d", total) + } + if repoCount != 2 { + t.Errorf("Expected repoCount 2, got %d", repoCount) + } + if snippetCount != 0 { + t.Errorf("Expected snippetCount 0, got %d", snippetCount) + } + if wikiCount != 1 { + t.Errorf("Expected wikiCount 1, got %d", wikiCount) + } + if gistCount != 2 { + t.Errorf("Expected gistCount 2, got %d", gistCount) + } +} + +func TestGithubUserGistsValidation(t *testing.T) { + t.Run("GHORG_GITHUB_USER_GISTS requires github scm type", func(tt *testing.T) { + defer UnsetEnv("GHORG_")() + + os.Setenv("GHORG_GITHUB_USER_GISTS", "true") + os.Setenv("GHORG_SCM_TYPE", "gitlab") + os.Setenv("GHORG_CLONE_TYPE", "user") + + // Verify the condition that setupRepoClone checks + if os.Getenv("GHORG_GITHUB_USER_GISTS") == "true" && os.Getenv("GHORG_SCM_TYPE") != "github" { + // This is the expected invalid state - gists + non-github scm should error + // The setupRepoClone function calls colorlog.PrintErrorAndExit in this case + } else { + tt.Errorf("Expected GHORG_GITHUB_USER_GISTS=true with non-github SCM to be an invalid combination") + } + }) + + t.Run("GHORG_GITHUB_USER_GISTS requires user clone type", func(tt *testing.T) { + defer UnsetEnv("GHORG_")() + + os.Setenv("GHORG_GITHUB_USER_GISTS", "true") + os.Setenv("GHORG_SCM_TYPE", "github") + os.Setenv("GHORG_CLONE_TYPE", "org") + + // Verify the condition that setupRepoClone checks + if os.Getenv("GHORG_GITHUB_USER_GISTS") == "true" && os.Getenv("GHORG_CLONE_TYPE") != "user" { + // This is the expected invalid state - gists + org clone type should error + // The setupRepoClone function calls colorlog.PrintErrorAndExit in this case + } else { + tt.Errorf("Expected GHORG_GITHUB_USER_GISTS=true with org clone type to be an invalid combination") + } + }) + + t.Run("GHORG_GITHUB_USER_GISTS with valid github user combination", func(tt *testing.T) { + defer UnsetEnv("GHORG_")() + + os.Setenv("GHORG_GITHUB_USER_GISTS", "true") + os.Setenv("GHORG_SCM_TYPE", "github") + os.Setenv("GHORG_CLONE_TYPE", "user") + + // Verify the valid combination passes the check + if os.Getenv("GHORG_GITHUB_USER_GISTS") != "true" || os.Getenv("GHORG_SCM_TYPE") != "github" || os.Getenv("GHORG_CLONE_TYPE") != "user" { + tt.Errorf("Expected valid github user gists combination to be accepted") + } + }) +} diff --git a/cmd/root.go b/cmd/root.go index 94c1b4f..00c37d8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,6 +75,7 @@ var ( ghorgReCloneList bool ghorgReCloneEnvConfigOnly bool githubTokenFromGithubApp bool + githubUserGists bool noToken bool quietMode bool noDirSize bool @@ -232,6 +233,8 @@ func getOrSetDefaults(envVar string) { os.Setenv(envVar, "1") case "GHORG_GITHUB_TOKEN_FROM_GITHUB_APP": os.Setenv(envVar, "false") + case "GHORG_GITHUB_USER_GISTS": + os.Setenv(envVar, "false") case "GHORG_SSH_HOSTNAME": os.Setenv(envVar, "") } @@ -330,6 +333,7 @@ func InitConfig() { getOrSetDefaults("GHORG_CLONE_DEPTH") getOrSetDefaults("GHORG_GITHUB_TOKEN") getOrSetDefaults("GHORG_GITHUB_TOKEN_FROM_GITHUB_APP") + getOrSetDefaults("GHORG_GITHUB_USER_GISTS") getOrSetDefaults("GHORG_GITHUB_FILTER_LANGUAGE") getOrSetDefaults("GHORG_COLOR") getOrSetDefaults("GHORG_TOPICS") @@ -429,6 +433,7 @@ func init() { cloneCmd.Flags().StringVarP(&exitCodeOnCloneIssues, "exit-code-on-clone-issues", "", "", "GHORG_EXIT_CODE_ON_CLONE_ISSUES - Exit code when issues/errors occur during cloning. Useful for CI/CD failure detection (default: 1)") cloneCmd.Flags().StringVarP(&gitFilter, "git-filter", "", "", "GHORG_GIT_FILTER - Arguments to pass to git's --filter flag. Use --git-filter=blob:none to exclude binary objects and reduce clone size. Requires git 2.19+") cloneCmd.Flags().BoolVarP(&githubTokenFromGithubApp, "github-token-from-github-app", "", false, "GHORG_GITHUB_TOKEN_FROM_GITHUB_APP - GitHub only: Treat the provided token as a GitHub App token (when obtained outside ghorg). Use with pre-generated app tokens") + cloneCmd.Flags().BoolVarP(&githubUserGists, "github-user-gists", "", false, "GHORG_GITHUB_USER_GISTS - GitHub only: Clone all of a user's gists into clone-dir/ghorg-gists. Requires --clone-type=user and --scm=github") cloneCmd.Flags().StringVarP(&githubAppPemPath, "github-app-pem-path", "", "", "GHORG_GITHUB_APP_PEM_PATH - GitHub only: Path to GitHub App private key (.pem file) for app-based authentication. Requires --github-app-id and --github-app-installation-id") cloneCmd.Flags().StringVarP(&githubAppInstallationID, "github-app-installation-id", "", "", "GHORG_GITHUB_APP_INSTALLATION_ID - GitHub only: Installation ID for GitHub App authentication. Find in org settings URL") cloneCmd.Flags().StringVarP(&githubFilterLanguage, "github-filter-language", "", "", "GHORG_GITHUB_FILTER_LANGUAGE - GitHub only: Filter repositories by programming language. Comma-separated values (e.g., --github-filter-language=go,python)") diff --git a/sample-conf.yaml b/sample-conf.yaml index d03eb3b..62796e4 100644 --- a/sample-conf.yaml +++ b/sample-conf.yaml @@ -240,6 +240,11 @@ GHORG_GITHUB_APP_ID: # Can be one of: all, owner, member (default: owner) GHORG_GITHUB_USER_OPTION: +# Clone all of a user's gists into clone-dir/ghorg-gists +# Only available when using GHORG_SCM_TYPE: github and GHORG_CLONE_TYPE: user +# flag (--github-user-gists) +GHORG_GITHUB_USER_GISTS: false + # Filter repos by a language # Can be a comma separated value with no spaces # falg (--github-filter-language) e.g.: --github-filter-language=go,ruby,elixir diff --git a/scm/github.go b/scm/github.go index ccb6e60..ad81836 100644 --- a/scm/github.go +++ b/scm/github.go @@ -6,6 +6,8 @@ import ( "net/http" "net/url" "os" + "path/filepath" + "sort" "strconv" "strings" @@ -266,6 +268,120 @@ func (c Github) filter(allRepos []*github.Repository) []Repo { return repoData } +// GetUserGists gets all gists for a GitHub user +func (c Github) GetUserGists(targetUser string) ([]Repo, error) { + c.SetTokensUsername() + + spinningSpinner.Start() + defer spinningSpinner.Stop() + + opt := &github.GistListOptions{ + ListOptions: github.ListOptions{PerPage: reposPerPage, Page: 1}, + } + + gists, resp, err := c.Gists.List(context.Background(), targetUser, opt) + if err != nil { + return nil, err + } + + allGists := gists + + for page := 2; page <= resp.LastPage; page++ { + opt.Page = page + g, _, err := c.Gists.List(context.Background(), targetUser, opt) + if err != nil { + return nil, err + } + allGists = append(allGists, g...) + } + + return c.filterGists(allGists), nil +} + +// gistFolderName returns the folder name to use for a gist, derived from the +// gist's primary filename (first filename alphabetically). The extension is +// stripped and the result is lowercased. Falls back to the gist ID when the +// gist has no files or the derived base name is empty. +func gistFolderName(gist *github.Gist) string { + if len(gist.Files) == 0 { + return strings.ToLower(*gist.ID) + } + + // Collect and sort filenames for a stable, deterministic result. + filenames := make([]string, 0, len(gist.Files)) + for fn := range gist.Files { + filenames = append(filenames, string(fn)) + } + sort.Strings(filenames) + + firstName := filenames[0] + ext := filepath.Ext(firstName) + base := strings.ToLower(firstName[:len(firstName)-len(ext)]) + if base == "" { + return strings.ToLower(*gist.ID) + } + return base +} + +func (c Github) filterGists(allGists []*github.Gist) []Repo { + // First pass: compute a derived folder name for every valid gist and + // count how many gists share the same name so we can detect collisions. + type gistEntry struct { + gist *github.Gist + folderName string + } + + entries := make([]gistEntry, 0, len(allGists)) + nameCount := make(map[string]int) + + for _, gist := range allGists { + if gist.ID == nil || gist.GitPullURL == nil { + continue + } + name := gistFolderName(gist) + entries = append(entries, gistEntry{gist: gist, folderName: name}) + nameCount[name]++ + } + + // Second pass: build Repo records, appending the gist ID to the folder + // name whenever two or more gists share the same derived name. + var repoData []Repo + + for _, entry := range entries { + gist := entry.gist + folderName := entry.folderName + + if nameCount[folderName] > 1 { + folderName = folderName + "-" + *gist.ID + } + + r := Repo{} + r.ID = *gist.ID + r.Name = folderName + r.Path = folderName + r.IsGitHubGist = true + if os.Getenv("GHORG_BRANCH") != "" { + r.CloneBranch = os.Getenv("GHORG_BRANCH") + } else { + r.CloneBranch = "master" + } + r.URL = *gist.GitPullURL + + if os.Getenv("GHORG_CLONE_PROTOCOL") == "https" || os.Getenv("GHORG_GITHUB_APP_PEM_PATH") != "" { + r.CloneURL = c.addTokenToHTTPSCloneURL(*gist.GitPullURL, os.Getenv("GHORG_GITHUB_TOKEN")) + } else { + // Convert HTTPS gist pull URL to SSH URL + // https://gist.github.com/.git → git@gist.github.com:.git + sshURL := strings.Replace(*gist.GitPullURL, "https://gist.github.com/", "git@gist.github.com:", 1) + r.CloneURL = ReplaceSSHHostname(sshURL, os.Getenv("GHORG_SSH_HOSTNAME")) + } + + repoData = append(repoData, r) + } + + return repoData +} + // Sets the GitHub username tied to the github token to the package variable tokenUsername // Then if https clone method is used the clone url will be https://username:token@github.com/org/repo.git // The username is now needed when using the new fine-grained tokens for github diff --git a/scm/github_test.go b/scm/github_test.go index adf7124..168ac04 100644 --- a/scm/github_test.go +++ b/scm/github_test.go @@ -134,3 +134,211 @@ func TestGetOrgRepos(t *testing.T) { os.Setenv("GHORG_TOPICS", "") }) } + +func TestGetUserGists(t *testing.T) { + client, mux, _, teardown := setup() + + github := Github{Client: client} + + defer teardown() + + mux.HandleFunc("/users/testuser/gists", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[ + {"id":"abc123", "git_pull_url": "https://gist.github.com/abc123.git", "public": true, "files": {"foobar.md": {"filename": "foobar.md"}}}, + {"id":"def456", "git_pull_url": "https://gist.github.com/def456.git", "public": true, "files": {"script.sh": {"filename": "script.sh"}}}, + {"id":"ghi789", "git_pull_url": "https://gist.github.com/ghi789.git", "public": false, "files": {"README.txt": {"filename": "README.txt"}}} + ]`) + }) + + t.Run("Should return all gists with HTTPS protocol", func(tt *testing.T) { + os.Setenv("GHORG_CLONE_PROTOCOL", "https") + os.Setenv("GHORG_GITHUB_TOKEN", "testtoken") + + resp, err := github.GetUserGists("testuser") + + if err != nil { + tt.Fatal(err) + } + + want := 3 + got := len(resp) + if want != got { + tt.Errorf("Expected %v gists, got: %v", want, got) + } + + for _, repo := range resp { + if !repo.IsGitHubGist { + tt.Errorf("Expected IsGitHubGist to be true for gist %s", repo.Name) + } + } + + os.Unsetenv("GHORG_CLONE_PROTOCOL") + os.Unsetenv("GHORG_GITHUB_TOKEN") + }) + + t.Run("Should use filename without extension as folder name", func(tt *testing.T) { + os.Setenv("GHORG_CLONE_PROTOCOL", "https") + os.Setenv("GHORG_GITHUB_TOKEN", "testtoken") + + resp, err := github.GetUserGists("testuser") + if err != nil { + tt.Fatal(err) + } + + // Build a map of ID→Name for easy lookup + names := make(map[string]string) + for _, repo := range resp { + names[repo.ID] = repo.Name + } + + tests := []struct{ id, wantName string }{ + {"abc123", "foobar"}, // foobar.md → foobar + {"def456", "script"}, // script.sh → script + {"ghi789", "readme"}, // README.txt → readme (lowercased) + } + for _, tc := range tests { + if got := names[tc.id]; got != tc.wantName { + tt.Errorf("gist %s: expected folder name %q, got %q", tc.id, tc.wantName, got) + } + } + + os.Unsetenv("GHORG_CLONE_PROTOCOL") + os.Unsetenv("GHORG_GITHUB_TOKEN") + }) + + t.Run("Should set correct clone branch for gists", func(tt *testing.T) { + os.Setenv("GHORG_CLONE_PROTOCOL", "https") + os.Setenv("GHORG_GITHUB_TOKEN", "testtoken") + + resp, err := github.GetUserGists("testuser") + + if err != nil { + tt.Fatal(err) + } + + // When GHORG_BRANCH is not set, gists default to "master" + for _, repo := range resp { + if repo.CloneBranch != "master" { + tt.Errorf("Expected CloneBranch to be 'master' when GHORG_BRANCH is unset, got: %s", repo.CloneBranch) + } + } + + os.Unsetenv("GHORG_CLONE_PROTOCOL") + os.Unsetenv("GHORG_GITHUB_TOKEN") + }) + + t.Run("Should respect GHORG_BRANCH for gists", func(tt *testing.T) { + os.Setenv("GHORG_CLONE_PROTOCOL", "https") + os.Setenv("GHORG_GITHUB_TOKEN", "testtoken") + os.Setenv("GHORG_BRANCH", "main") + + resp, err := github.GetUserGists("testuser") + + if err != nil { + tt.Fatal(err) + } + + for _, repo := range resp { + if repo.CloneBranch != "main" { + tt.Errorf("Expected CloneBranch to be 'main', got: %s", repo.CloneBranch) + } + } + + os.Unsetenv("GHORG_CLONE_PROTOCOL") + os.Unsetenv("GHORG_GITHUB_TOKEN") + os.Unsetenv("GHORG_BRANCH") + }) +} + +func TestGistFolderName(t *testing.T) { + strPtr := func(s string) *string { return &s } + + tests := []struct { + name string + gist *ghpkg.Gist + wantName string + }{ + { + name: "single file with extension", + gist: &ghpkg.Gist{ID: strPtr("id1"), Files: map[ghpkg.GistFilename]ghpkg.GistFile{"foobar.md": {}}}, + wantName: "foobar", + }, + { + name: "uppercase extension is lowercased", + gist: &ghpkg.Gist{ID: strPtr("id2"), Files: map[ghpkg.GistFilename]ghpkg.GistFile{"README.txt": {}}}, + wantName: "readme", + }, + { + name: "no extension", + gist: &ghpkg.Gist{ID: strPtr("id3"), Files: map[ghpkg.GistFilename]ghpkg.GistFile{"Makefile": {}}}, + wantName: "makefile", + }, + { + name: "multiple files uses first alphabetically", + gist: &ghpkg.Gist{ID: strPtr("id4"), Files: map[ghpkg.GistFilename]ghpkg.GistFile{"zebra.go": {}, "alpha.go": {}, "middle.go": {}}}, + wantName: "alpha", + }, + { + name: "no files falls back to gist id", + gist: &ghpkg.Gist{ID: strPtr("abc999"), Files: map[ghpkg.GistFilename]ghpkg.GistFile{}}, + wantName: "abc999", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(tt *testing.T) { + got := gistFolderName(tc.gist) + if got != tc.wantName { + tt.Errorf("gistFolderName: expected %q, got %q", tc.wantName, got) + } + }) + } +} + +func TestFilterGistsCollisions(t *testing.T) { + client, mux, _, teardown := setup() + gh := Github{Client: client} + defer teardown() + + // Two gists share the same derived folder name ("foobar") + mux.HandleFunc("/users/collisionuser/gists", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `[ + {"id":"aaa111", "git_pull_url": "https://gist.github.com/aaa111.git", "files": {"foobar.md": {"filename": "foobar.md"}}}, + {"id":"bbb222", "git_pull_url": "https://gist.github.com/bbb222.git", "files": {"foobar.sh": {"filename": "foobar.sh"}}}, + {"id":"ccc333", "git_pull_url": "https://gist.github.com/ccc333.git", "files": {"unique.py": {"filename": "unique.py"}}} + ]`) + }) + + os.Setenv("GHORG_CLONE_PROTOCOL", "https") + os.Setenv("GHORG_GITHUB_TOKEN", "testtoken") + defer func() { + os.Unsetenv("GHORG_CLONE_PROTOCOL") + os.Unsetenv("GHORG_GITHUB_TOKEN") + }() + + resp, err := gh.GetUserGists("collisionuser") + if err != nil { + t.Fatal(err) + } + + if len(resp) != 3 { + t.Fatalf("Expected 3 repos, got %d", len(resp)) + } + + names := make(map[string]string) // id → Name + for _, repo := range resp { + names[repo.ID] = repo.Name + } + + // Both "foobar.md" and "foobar.sh" collide on "foobar", so both get the gist ID appended + if got := names["aaa111"]; got != "foobar-aaa111" { + t.Errorf("aaa111: expected 'foobar-aaa111', got %q", got) + } + if got := names["bbb222"]; got != "foobar-bbb222" { + t.Errorf("bbb222: expected 'foobar-bbb222', got %q", got) + } + // No collision for "unique" + if got := names["ccc333"]; got != "unique" { + t.Errorf("ccc333: expected 'unique', got %q", got) + } +} diff --git a/scm/structs.go b/scm/structs.go index d8831c5..815b1c0 100644 --- a/scm/structs.go +++ b/scm/structs.go @@ -22,6 +22,8 @@ type Repo struct { IsGitLabSnippet bool // IsGitLabRootLevelSnippet is set to true when a snippet was not created for a repo IsGitLabRootLevelSnippet bool + // IsGitHubGist is set to true when the data is for a github gist + IsGitHubGist bool // GitLabSnippetInfo provides additional information when the thing we are cloning is a gitlab snippet GitLabSnippetInfo GitLabSnippet Commits RepoCommits