From 0982d65abfa476d11c11578140858363da0fc7bd Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 20 May 2025 16:28:13 +0000 Subject: [PATCH 1/4] Refactor: Convert application from Ruby to Golang This commit rewrites the GitHub backup application from Ruby to Golang. The core functionality of backing up repositories, including handling Git LFS, remains the same. Key changes include: - Replaced `ghbackup.rb` with `main.go`. - Updated `Dockerfile` to use a Go build environment, compile the Go application, and include necessary tools (git, git-lfs). - The `entrypoint.sh` script remains largely unchanged, now executing the compiled Go binary. - Introduced an `App` struct in `main.go` to manage dependencies like the GitHub client, command runner, and filesystem operations, improving testability. - Added a comprehensive suite of unit tests in `main_test.go` using mocking for external dependencies (GitHub API, git commands, filesystem). This covers various scenarios including initial clone, repository updates, and error handling. - Removed Ruby-specific files (`.ruby-version`, `ghbackup.rb`). - Updated `README.md` to reflect the language change. --- .ruby-version | 1 - Dockerfile | 15 +- README.md | 2 + ghbackup.rb | 50 ----- go.mod | 12 + go.sum | 10 + main.go | 238 ++++++++++++++++++++ main_test.go | 601 ++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 874 insertions(+), 55 deletions(-) delete mode 100644 .ruby-version delete mode 100755 ghbackup.rb create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 main_test.go diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 3ad0595..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -3.1.5 diff --git a/Dockerfile b/Dockerfile index 1a985d1..a2cca68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,15 @@ -FROM alpine:3.16 +FROM golang:1.22-alpine -RUN apk add --no-cache ruby ruby-json git git-lfs -RUN gem install octokit faraday-retry +RUN apk add --no-cache git git-lfs + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o /usr/local/bin/ghbackup main.go ENV GITHUB_SECRET="" ENV CRON_EXPRESSION="0 0 * * *" @@ -9,7 +17,6 @@ ENV CRON_EXPRESSION="0 0 * * *" VOLUME ["/ghbackup"] COPY ["entrypoint.sh", "/entrypoint.sh"] -COPY ["ghbackup.rb", "/usr/local/bin/ghbackup"] ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 52fd503..7347cc9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ Performs regular backups of all the GitHub repositories you have access to (it'll exclude organisations it doesn't have specific permission to access), automatically downloading any new repositories and updating any existing ones. +This application is written in Golang. + You can generate a personal access token here [https://github.com/settings/tokens](https://github.com/settings/tokens). ## Usage diff --git a/ghbackup.rb b/ghbackup.rb deleted file mode 100755 index 1f8692a..0000000 --- a/ghbackup.rb +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env ruby - -require 'octokit' -require 'uri' - -puts "Starting ghbackup..." - -begin - Octokit.configure do |c| - c.auto_paginate = true - end - - github_secret = ENV["GITHUB_SECRET"] - backup_folder = ENV["BACKUP_FOLDER"] || "/ghbackup" - - client = Octokit::Client.new(access_token: github_secret) - - login = client.user[:login] - - client.repos.each do |repo| - uri = URI.parse(repo[:clone_url]) - authenitcated_clone_url = "#{uri.scheme}://#{login}:#{github_secret}@#{uri.host}#{uri.path}" - unauthenticated_clone_url = "#{uri.scheme}://#{uri.host}#{uri.path}" - - backup_path = "#{backup_folder}/#{repo[:full_name]}.git" - - puts "\nBacking up #{repo[:full_name]}..." - - system('git', 'config', '--global', '--add', 'safe.directory', '*') - - if Dir.exist?(backup_path) - Dir.chdir(backup_path) { - system('git', 'remote', 'set-url', 'origin', authenitcated_clone_url) - system('git', 'remote', 'update') - system('git', 'lfs', 'fetch', '--all') - system('git', 'remote', 'set-url', 'origin', unauthenticated_clone_url) - } - else - system('git', 'clone', '--mirror', '--no-checkout', '--progress', authenitcated_clone_url, backup_path) - Dir.chdir(backup_path) { - system('git', 'lfs', 'fetch', '--all') - system('git', 'remote', 'set-url', 'origin', unauthenticated_clone_url) - } - end - end - - puts "\nBackup complete!" -rescue => e - puts "Error: #{e}" -end diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..481ef4c --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/example/ghbackup + +go 1.23.0 + +toolchain go1.23.9 + +require ( + github.com/google/go-github/v62 v62.0.0 + golang.org/x/oauth2 v0.30.0 +) + +require github.com/google/go-querystring v1.1.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3545dea --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go new file mode 100644 index 0000000..755bdb1 --- /dev/null +++ b/main.go @@ -0,0 +1,238 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + + "github.com/google/go-github/v62/github" + "golang.org/x/oauth2" +) + +// CommandRunner defines an interface for running external commands. +type CommandRunner interface { + Run(dir string, name string, args ...string) ([]byte, error) + RunAndOutput(dir string, name string, args ...string) error +} + +// DefaultCommandRunner is the default implementation of CommandRunner using os/exec. +type DefaultCommandRunner struct{} + +func (dcr *DefaultCommandRunner) Run(dir string, name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + if dir != "" { + cmd.Dir = dir + } + return cmd.CombinedOutput() +} + +func (dcr *DefaultCommandRunner) RunAndOutput(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + if dir != "" { + cmd.Dir = dir + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// GitHubClient defines an interface for interacting with the GitHub API. +// This helps in mocking the client for tests. +type GitHubClient interface { + GetAuthenticatedUser(ctx context.Context) (*github.User, error) + ListUserRepositories(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) +} + +// RealGitHubClient is a wrapper around the go-github client. +type RealGitHubClient struct { + client *github.Client +} + +func NewRealGitHubClient(token string) *RealGitHubClient { + ctx := context.Background() + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + return &RealGitHubClient{client: github.NewClient(tc)} +} + +func (rgc *RealGitHubClient) GetAuthenticatedUser(ctx context.Context) (*github.User, error) { + user, _, err := rgc.client.Users.Get(ctx, "") + return user, err +} + +func (rgc *RealGitHubClient) ListUserRepositories(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + return rgc.client.Repositories.List(ctx, user, opts) +} + +// App holds application dependencies and configuration. +type App struct { + GithubToken string + BackupFolder string + GhClient GitHubClient + CmdRunner CommandRunner + // Functions for filesystem operations, allowing them to be mocked + Stat func(name string) (os.FileInfo, error) + MkdirAll func(path string, perm os.FileMode) error + Getwd func() (string, error) + Chdir func(dir string) error +} + +// runApp contains the core logic of the application. +func (app *App) runApp(ctx context.Context) error { + log.Println("Starting GitHub backup...") + + if app.GithubToken == "" { + return fmt.Errorf("Error: GITHUB_SECRET environment variable is not set.") + } + + if app.BackupFolder == "" { + app.BackupFolder = "/ghbackup" // Default if not set by caller + } + + if err := app.MkdirAll(app.BackupFolder, 0755); err != nil { + return fmt.Errorf("Error creating backup folder %s: %v", app.BackupFolder, err) + } + + if output, err := app.CmdRunner.Run("", "git", "config", "--global", "--add", "safe.directory", "*"); err != nil { + return fmt.Errorf("Error setting global git config: %v\nOutput: %s", err, string(output)) + } + + user, err := app.GhClient.GetAuthenticatedUser(ctx) + if err != nil { + return fmt.Errorf("Error getting authenticated user: %v", err) + } + username := *user.Login + + log.Println("Fetching repositories...") + opt := &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + var allRepos []*github.Repository + for { + repos, resp, err := app.GhClient.ListUserRepositories(ctx, "", opt) + if err != nil { + return fmt.Errorf("Error listing repositories: %v", err) + } + allRepos = append(allRepos, repos...) + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + log.Printf("Found %d repositories to backup.\n", len(allRepos)) + + for _, repo := range allRepos { + repoFullName := *repo.FullName + // repoName := *repo.Name // Not used, can be removed if not needed elsewhere + log.Printf("Backing up repository: %s\n", repoFullName) + + authenticatedCloneURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repoFullName) + unauthenticatedCloneURL := fmt.Sprintf("https://github.com/%s.git", repoFullName) + backupPath := filepath.Join(app.BackupFolder, repoFullName+".git") + + if _, err := app.Stat(backupPath); os.IsNotExist(err) { + log.Printf("Backup for %s does not exist, cloning...\n", repoFullName) + if err := app.CmdRunner.RunAndOutput("", "git", "clone", "--mirror", "--no-checkout", "--progress", authenticatedCloneURL, backupPath); err != nil { + log.Printf("Error cloning repository %s: %v\n", repoFullName, err) + continue // Continue with the next repository + } + + originalWd, err := app.Getwd() + if err != nil { + log.Printf("Error getting current working directory for %s: %v\n", repoFullName, err) + continue + } + if err := app.Chdir(backupPath); err != nil { + log.Printf("Error changing directory to %s: %v\n", backupPath, err) + continue + } + + log.Printf("Fetching LFS objects for %s\n", repoFullName) + if err := app.CmdRunner.RunAndOutput(backupPath, "git", "lfs", "fetch", "--all"); err != nil { + log.Printf("Error fetching LFS objects for %s: %v\n", repoFullName, err) + // Non-fatal, continue to set remote + } + + log.Printf("Setting remote URL to unauthenticated for %s\n", repoFullName) + if output, err := app.CmdRunner.Run(backupPath, "git", "remote", "set-url", "origin", unauthenticatedCloneURL); err != nil { + log.Printf("Error setting remote URL to unauthenticated for %s: %v\nOutput: %s\n", repoFullName, err, string(output)) + } + + if err := app.Chdir(originalWd); err != nil { + log.Printf("Error changing directory back to original for %s: %v\n", repoFullName, err) + } + + } else if err == nil { // Backup exists + log.Printf("Backup for %s exists, updating...\n", repoFullName) + originalWd, err := app.Getwd() + if err != nil { + log.Printf("Error getting current working directory for update of %s: %v\n", repoFullName, err) + continue + } + if err := app.Chdir(backupPath); err != nil { + log.Printf("Error changing directory to %s for update: %v\n", backupPath, err) + continue + } + + log.Printf("Setting remote URL to authenticated for %s for update\n", repoFullName) + if output, err := app.CmdRunner.Run(backupPath, "git", "remote", "set-url", "origin", authenticatedCloneURL); err != nil { + log.Printf("Error setting remote URL to authenticated for %s: %v\nOutput: %s\n", repoFullName, err, string(output)) + if err := app.Chdir(originalWd); err != nil { // Try to change back even if set-url failed + log.Printf("Error changing directory back to original for %s after auth set-url fail: %v\n", repoFullName, err) + } + continue + } + + log.Printf("Updating remote for %s\n", repoFullName) + if err := app.CmdRunner.RunAndOutput(backupPath, "git", "remote", "update"); err != nil { + log.Printf("Error updating remote for %s: %v\n", repoFullName, err) + // Non-fatal, continue to fetch LFS and set unauthenticated remote + } + + log.Printf("Fetching LFS objects for %s\n", repoFullName) + if err := app.CmdRunner.RunAndOutput(backupPath, "git", "lfs", "fetch", "--all"); err != nil { + log.Printf("Error fetching LFS objects for %s: %v\n", repoFullName, err) + } + + log.Printf("Setting remote URL to unauthenticated for %s\n", repoFullName) + if output, err := app.CmdRunner.Run(backupPath, "git", "remote", "set-url", "origin", unauthenticatedCloneURL); err != nil { + log.Printf("Error setting remote URL to unauthenticated for %s: %v\nOutput: %s\n", repoFullName, err, string(output)) + } + + if err := app.Chdir(originalWd); err != nil { + log.Printf("Error changing directory back to original for %s after update: %v\n", repoFullName, err) + } + } else { // Some other error with os.Stat + log.Printf("Error checking backup status for %s: %v\n", repoFullName, err) + continue + } + log.Printf("Finished backing up repository: %s\n", repoFullName) + } + + log.Println("GitHub backup completed.") + return nil +} + +func main() { + githubToken := os.Getenv("GITHUB_SECRET") + backupFolder := os.Getenv("BACKUP_FOLDER") + + app := &App{ + GithubToken: githubToken, + BackupFolder: backupFolder, + GhClient: NewRealGitHubClient(githubToken), + CmdRunner: &DefaultCommandRunner{}, + Stat: os.Stat, + MkdirAll: os.MkdirAll, + Getwd: os.Getwd, + Chdir: os.Chdir, + } + + if err := app.runApp(context.Background()); err != nil { + log.Fatal(err) // log.Fatal will print the error and exit(1) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..da99f1d --- /dev/null +++ b/main_test.go @@ -0,0 +1,601 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/google/go-github/v62/github" +) + +// --- Mocks for main.GitHubClient --- + +type mockGhClient struct { + GetAuthenticatedUserFunc func(ctx context.Context) (*github.User, error) + ListUserRepositoriesFunc func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) +} + +func (m *mockGhClient) GetAuthenticatedUser(ctx context.Context) (*github.User, error) { + if m.GetAuthenticatedUserFunc != nil { + return m.GetAuthenticatedUserFunc(ctx) + } + login := "testuser" + return &github.User{Login: &login}, nil +} + +func (m *mockGhClient) ListUserRepositories(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + if m.ListUserRepositoriesFunc != nil { + return m.ListUserRepositoriesFunc(ctx, user, opts) + } + return []*github.Repository{}, &github.Response{NextPage: 0}, nil +} + +// --- Mocks for main.CommandRunner --- + +type mockCmdRunner struct { + RunFunc func(dir string, name string, args ...string) ([]byte, error) + RunAndOutputFunc func(dir string, name string, args ...string) error + executedCmds []string // To store executed commands for verification + commandDetails []struct { // To store more details about executed commands + Dir string + Name string + Args []string + } +} + +func newMockCmdRunner() *mockCmdRunner { + return &mockCmdRunner{ + executedCmds: []string{}, + commandDetails: []struct { + Dir string + Name string + Args []string + }{}, + } +} + +func (mcr *mockCmdRunner) Run(dir string, name string, args ...string) ([]byte, error) { + cmdString := fmt.Sprintf("dir: '%s', cmd: %s %s", dir, name, strings.Join(args, " ")) + mcr.executedCmds = append(mcr.executedCmds, cmdString) + mcr.commandDetails = append(mcr.commandDetails, struct { + Dir string + Name string + Args []string + }{Dir: dir, Name: name, Args: args}) + + if mcr.RunFunc != nil { + return mcr.RunFunc(dir, name, args...) + } + // Default behavior: success, no output + return []byte{}, nil +} + +func (mcr *mockCmdRunner) RunAndOutput(dir string, name string, args ...string) error { + cmdString := fmt.Sprintf("dir: '%s', cmd: %s %s (interactive)", dir, name, strings.Join(args, " ")) + mcr.executedCmds = append(mcr.executedCmds, cmdString) + mcr.commandDetails = append(mcr.commandDetails, struct { + Dir string + Name string + Args []string + }{Dir: dir, Name: name, Args: args}) + + if mcr.RunAndOutputFunc != nil { + return mcr.RunAndOutputFunc(dir, name, args...) + } + // Default behavior: success + return nil +} + +func (mcr *mockCmdRunner) findCommand(name string, argsPrefix ...string) bool { + for _, detail := range mcr.commandDetails { + if detail.Name == name { + match := true + if len(argsPrefix) > 0 { + if len(detail.Args) < len(argsPrefix) { + match = false + } else { + for i, prefix := range argsPrefix { + if detail.Args[i] != prefix { + match = false + break + } + } + } + } + if match { + return true + } + } + } + return false +} + + +// --- Mocks for Filesystem Operations --- +type mockFileInfo struct { + name string + isDir bool +} +func (mfi *mockFileInfo) Name() string { return mfi.name } +func (mfi *mockFileInfo) Size() int64 { return 0 } +func (mfi *mockFileInfo) Mode() os.FileMode { if mfi.isDir { return os.ModeDir } ; return 0 } +func (mfi *mockFileInfo) ModTime() time.Time { return time.Now() } +func (mfi *mockFileInfo) IsDir() bool { return mfi.isDir } +func (mfi *mockFileInfo) Sys() interface{} { return nil } + + +var mockFilesystem = make(map[string]*mockFileInfo) +var mockMkdirAllPaths []string +var mockCurrentDir = "/app" // Default mock current directory + +func mockStat(name string) (os.FileInfo, error) { + if fi, ok := mockFilesystem[name]; ok { + return fi, nil + } + return nil, os.ErrNotExist +} + +func mockMkdirAll(path string, perm os.FileMode) error { + mockMkdirAllPaths = append(mockMkdirAllPaths, path) + // Simulate creating the directory in our mock filesystem + mockFilesystem[path] = &mockFileInfo{name: filepath.Base(path), isDir: true} + return nil +} +func mockGetwd() (string, error) { + return mockCurrentDir, nil +} + +func mockChdir(dir string) error { + // Check if dir exists in mock filesystem or is a subpath of an existing one + exists := false + for path := range mockFilesystem { + if strings.HasPrefix(dir, path) && mockFilesystem[path].IsDir() { + exists = true + break + } + } + // Also check if it's the backup folder itself, which might be created by MkdirAll + for _, p := range mockMkdirAllPaths { + if p == dir { + exists = true + break + } + } + + + if !exists && dir != "/" && dir != "." && !strings.HasPrefix(dir, "/tmp/") { // Allow /tmp for t.TempDir() + // A more sophisticated mock would check if 'dir' is a valid path based on mockFilesystem + // For now, if it's not explicitly in mockFilesystem, assume it's an issue unless it's a root/temp. + // This part is tricky because git clone creates the directory. + // Let's assume for tests that target directories for Chdir either exist or are valid. + } + mockCurrentDir = dir + return nil +} + +func resetMocks() { + mockFilesystem = make(map[string]*mockFileInfo) + mockMkdirAllPaths = []string{} + mockCurrentDir = "/app" // Reset to a sensible default +} + + +// --- Test Cases --- + +func TestEnvVarParsing(t *testing.T) { + // These tests are for the main() function's handling of env vars before calling runApp. + // The App struct itself receives these as direct string values. + t.Run("GITHUB_SECRET is required by main", func(t *testing.T) { + // This is implicitly tested by runApp returning an error if GithubToken is empty. + // A direct test of main() is harder due to log.Fatal. + app := App{GithubToken: "", BackupFolder: "/some/folder"} + err := app.runApp(context.Background()) + if err == nil || !strings.Contains(err.Error(), "GITHUB_SECRET environment variable is not set") { + t.Errorf("Expected error about GITHUB_SECRET, got %v", err) + } + }) + + t.Run("BACKUP_FOLDER defaults in App if empty", func(t *testing.T) { + // Test the default logic within App struct/runApp + app := App{ + GithubToken: "token", + BackupFolder: "", // Empty, should default + GhClient: &mockGhClient{}, + CmdRunner: newMockCmdRunner(), + Stat: func(name string) (os.FileInfo, error) { return nil, os.ErrNotExist }, // Mock Stat + MkdirAll: func(path string, perm os.FileMode) error { return nil }, // Mock MkdirAll + Getwd: mockGetwd, + Chdir: mockChdir, + } + // Minimal setup to pass initial checks + mockGh := app.GhClient.(*mockGhClient) + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + login := "user" + return &github.User{Login: &login}, nil + } + mockCmd := app.CmdRunner.(*mockCmdRunner) + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } // Default success for git config + + app.runApp(context.Background()) // Ignore error for this specific default check + if app.BackupFolder != "/ghbackup" { + t.Errorf("Expected BackupFolder to default to /ghbackup, got %s", app.BackupFolder) + } + }) +} + + +func TestAppRun_ClonePath(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + tempBackupDir := t.TempDir() + + app := App{ + GithubToken: "test-token", + BackupFolder: tempBackupDir, + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, // Use our mock Stat + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + // Mock GitHub API responses + username := "testuser" + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + return &github.User{Login: &username}, nil + } + repo1FullName := "testuser/repo1" + repo1Name := "repo1" + mockGh.ListUserRepositoriesFunc = func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + return []*github.Repository{ + {Name: &repo1Name, FullName: &repo1FullName}, + }, &github.Response{NextPage: 0}, nil + } + + // Mock command runner behavior (all commands succeed by default if not specified) + + // --- Act --- + err := app.runApp(ctx) + if err != nil { + t.Fatalf("runApp failed: %v", err) + } + + // --- Assert --- + // Check MkdirAll was called for backup folder + foundMkdir := false + for _, p := range mockMkdirAllPaths { + if p == tempBackupDir { + foundMkdir = true + break + } + } + if !foundMkdir { + t.Errorf("Expected MkdirAll to be called for %s", tempBackupDir) + } + + + // Check git commands + expectedRepoPath := filepath.Join(tempBackupDir, repo1FullName+".git") + authenticatedCloneURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo1FullName) + unauthenticatedCloneURL := fmt.Sprintf("https://github.com/%s.git", repo1FullName) + + expectedCommands := []struct{Dir string; Name string; Args []string} { + {"", "git", []string{"config", "--global", "--add", "safe.directory", "*"}}, + {"", "git", []string{"clone", "--mirror", "--no-checkout", "--progress", authenticatedCloneURL, expectedRepoPath}}, + {expectedRepoPath, "git", []string{"lfs", "fetch", "--all"}}, + {expectedRepoPath, "git", []string{"remote", "set-url", "origin", unauthenticatedCloneURL}}, + } + + if len(mockCmd.commandDetails) != len(expectedCommands) { + t.Errorf("Expected %d git commands, got %d. Executed: %v", len(expectedCommands), len(mockCmd.commandDetails), mockCmd.executedCmds) + } + + for i, expCmd := range expectedCommands { + if i >= len(mockCmd.commandDetails) { + t.Errorf("Missing expected command: %v", expCmd) + continue + } + actualCmd := mockCmd.commandDetails[i] + if actualCmd.Dir != expCmd.Dir || actualCmd.Name != expCmd.Name || !reflect.DeepEqual(actualCmd.Args, expCmd.Args) { + t.Errorf("Command %d mismatch.\nExpected: Dir='%s', Name='%s', Args=%v\nActual: Dir='%s', Name='%s', Args=%v", + i, expCmd.Dir, expCmd.Name, expCmd.Args, actualCmd.Dir, actualCmd.Name, actualCmd.Args) + } + } +} + +func TestAppRun_UpdatePath(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + tempBackupDir := t.TempDir() + + repo1FullName := "testuser/repo-exists" + expectedRepoPath := filepath.Join(tempBackupDir, repo1FullName+".git") + + // Simulate existing backup by adding it to our mock filesystem + mockFilesystem[expectedRepoPath] = &mockFileInfo{name: repo1FullName+".git", isDir: true} + + app := App{ + GithubToken: "test-token", + BackupFolder: tempBackupDir, + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, // Use our mock Stat + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + username := "testuser" + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + return &github.User{Login: &username}, nil + } + repo1Name := "repo-exists" + mockGh.ListUserRepositoriesFunc = func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + return []*github.Repository{ + {Name: &repo1Name, FullName: &repo1FullName}, + }, &github.Response{NextPage: 0}, nil + } + // Mock command runner behavior (all commands succeed by default) + + // --- Act --- + err := app.runApp(ctx) + if err != nil { + t.Fatalf("runApp failed for update path: %v", err) + } + + // --- Assert --- + authenticatedCloneURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo1FullName) + unauthenticatedCloneURL := fmt.Sprintf("https://github.com/%s.git", repo1FullName) + + expectedCommands := []struct{Dir string; Name string; Args []string} { + {"", "git", []string{"config", "--global", "--add", "safe.directory", "*"}}, + {expectedRepoPath, "git", []string{"remote", "set-url", "origin", authenticatedCloneURL}}, + {expectedRepoPath, "git", []string{"remote", "update"}}, + {expectedRepoPath, "git", []string{"lfs", "fetch", "--all"}}, + {expectedRepoPath, "git", []string{"remote", "set-url", "origin", unauthenticatedCloneURL}}, + } + if len(mockCmd.commandDetails) != len(expectedCommands) { + t.Errorf("Expected %d git commands for update, got %d. Executed: %v", len(expectedCommands), len(mockCmd.commandDetails), mockCmd.executedCmds) + } + for i, expCmd := range expectedCommands { + if i >= len(mockCmd.commandDetails) { + t.Errorf("Missing expected command (update path): %v", expCmd) + continue + } + actualCmd := mockCmd.commandDetails[i] + if actualCmd.Dir != expCmd.Dir || actualCmd.Name != expCmd.Name || !reflect.DeepEqual(actualCmd.Args, expCmd.Args) { + t.Errorf("Command %d mismatch (update path).\nExpected: Dir='%s', Name='%s', Args=%v\nActual: Dir='%s', Name='%s', Args=%v", + i, expCmd.Dir, expCmd.Name, expCmd.Args, actualCmd.Dir, actualCmd.Name, actualCmd.Args) + } + } +} + + +func TestAppRun_GitHubUserError(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + + app := App{ + GithubToken: "test-token", + BackupFolder: t.TempDir(), + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + expectedError := "failed to get user" + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + return nil, errors.New(expectedError) + } + // Mock git config to succeed + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } + + + err := app.runApp(ctx) + if err == nil { + t.Fatalf("runApp should have failed due to GitHub user error") + } + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got '%v'", expectedError, err) + } +} + +func TestAppRun_GitHubListReposError(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + + app := App{ + GithubToken: "test-token", + BackupFolder: t.TempDir(), + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + username := "testuser" + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + return &github.User{Login: &username}, nil + } + expectedError := "failed to list repos" + mockGh.ListUserRepositoriesFunc = func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + return nil, nil, errors.New(expectedError) + } + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } + + + err := app.runApp(ctx) + if err == nil { + t.Fatalf("runApp should have failed due to GitHub list repos error") + } + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got '%v'", expectedError, err) + } +} + +func TestAppRun_GitConfigError(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + + app := App{ + GithubToken: "test-token", + BackupFolder: t.TempDir(), + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + expectedError := "git config failed" + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { + if name == "git" && args[0] == "config" { + return nil, errors.New(expectedError) + } + return []byte{}, nil + } + + err := app.runApp(ctx) + if err == nil { + t.Fatalf("runApp should have failed due to git config error") + } + if !strings.Contains(err.Error(), expectedError) { + t.Errorf("Expected error message to contain '%s', got '%v'", expectedError, err) + } +} + + +func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { + resetMocks() + ctx := context.Background() + mockGh := &mockGhClient{} + mockCmd := newMockCmdRunner() + tempBackupDir := t.TempDir() + + app := App{ + GithubToken: "test-token", + BackupFolder: tempBackupDir, + GhClient: mockGh, + CmdRunner: mockCmd, + Stat: mockStat, + MkdirAll: mockMkdirAll, + Getwd: mockGetwd, + Chdir: mockChdir, + } + + username := "testuser" + mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { + return &github.User{Login: &username}, nil + } + repo1FullName := "testuser/repo1-clone-fails" + repo1Name := "repo1-clone-fails" + repo2FullName := "testuser/repo2-should-succeed" + repo2Name := "repo2-should-succeed" + + mockGh.ListUserRepositoriesFunc = func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { + return []*github.Repository{ + {Name: &repo1Name, FullName: &repo1FullName}, + {Name: &repo2Name, FullName: &repo2FullName}, + }, &github.Response{NextPage: 0}, nil + } + + cloneError := errors.New("git clone intentional error") + mockCmd.RunAndOutputFunc = func(dir string, name string, args ...string) error { + if name == "git" && args[0] == "clone" && strings.Contains(args[3], repo1FullName) { + return cloneError + } + return nil // Success for other commands (like LFS for repo2) + } + // git config and other non-RunAndOutput commands succeed + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } + + + // --- Act --- + err := app.runApp(ctx) // This error will be nil if any repo succeeds and errors are logged. + if err != nil { + t.Fatalf("runApp returned an unexpected error: %v. Expected errors to be logged and skipped.", err) + } + + // --- Assert --- + // Check that repo2 was attempted (e.g. its clone command was issued) + // The mockCmdRunner.RunAndOutputFunc will only be called for clone, remote update, lfs + // We expect clone for repo1 (fails), then clone for repo2 (succeeds in mock) + // Then LFS for repo2, then remote set-url for repo2. + + // Check that clone for repo2 was attempted and "succeeded" (mock success) + repo2Path := filepath.Join(tempBackupDir, repo2FullName+".git") + authenticatedCloneURLRepo2 := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo2FullName) + + foundCloneRepo1 := false + foundCloneRepo2 := false + + for _, detail := range mockCmd.commandDetails { + if detail.Name == "git" && detail.Args[0] == "clone" { + if strings.Contains(detail.Args[3], repo1FullName) { + foundCloneRepo1 = true + } + if strings.Contains(detail.Args[3], repo2FullName) { + foundCloneRepo2 = true + } + } + } + + if !foundCloneRepo1 { + t.Error("Expected clone attempt for repo1 (which fails)") + } + if !foundCloneRepo2 { + t.Error("Expected clone attempt for repo2") + } + + // Check LFS fetch for repo2 was attempted + if !mockCmd.findCommand("git", "lfs", "fetch", "--all") { + // This check is a bit broad, better to check with dir + foundLFSForRepo2 := false + for _, detail := range mockCmd.commandDetails { + if detail.Dir == repo2Path && detail.Name == "git" && detail.Args[0] == "lfs" && detail.Args[1] == "fetch" { + foundLFSForRepo2 = true + break + } + } + if !foundLFSForRepo2 { + t.Errorf("Expected 'git lfs fetch --all' for repo2 in dir %s", repo2Path) + } + } +} + +// Minimal main for TestMain to run. +func TestMain(m *testing.M) { + // No specific setup needed for TestMain itself as tests manage their own mocks. + // The main.main() is not directly called by tests. + os.Exit(m.Run()) +} + +// Note: More error cases could be tested: +// - LFS fetch errors (should be non-fatal for the specific repo) +// - `git remote set-url` errors (both for auth and unauth) +// - `os.Getwd`, `os.Chdir` errors (should skip the repo or handle gracefully) +// - `os.MkdirAll` failure for the main backup folder (should be fatal for runApp) +// - Pagination in ListUserRepositories +``` From 809578711af40010dfa37816f98ba2cc174488f1 Mon Sep 17 00:00:00 2001 From: Alex Pardoe Date: Tue, 20 May 2025 20:58:26 +0100 Subject: [PATCH 2/4] Fix Dockerfile. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a2cca68..4d7bccc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22-alpine +FROM golang:1.23-alpine RUN apk add --no-cache git git-lfs From 6c26618f669a1726200e928cca657b3fe0222891 Mon Sep 17 00:00:00 2001 From: Alex Pardoe Date: Tue, 20 May 2025 20:59:22 +0100 Subject: [PATCH 3/4] Fix test formatting. --- main_test.go | 75 +++++++++++++++++++++++++++------------------------- 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/main_test.go b/main_test.go index da99f1d..2c2ed2a 100644 --- a/main_test.go +++ b/main_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/google/go-github/v62/github" ) @@ -40,7 +41,7 @@ func (m *mockGhClient) ListUserRepositories(ctx context.Context, user string, op type mockCmdRunner struct { RunFunc func(dir string, name string, args ...string) ([]byte, error) RunAndOutputFunc func(dir string, name string, args ...string) error - executedCmds []string // To store executed commands for verification + executedCmds []string // To store executed commands for verification commandDetails []struct { // To store more details about executed commands Dir string Name string @@ -115,19 +116,23 @@ func (mcr *mockCmdRunner) findCommand(name string, argsPrefix ...string) bool { return false } - // --- Mocks for Filesystem Operations --- type mockFileInfo struct { - name string + name string isDir bool } + func (mfi *mockFileInfo) Name() string { return mfi.name } -func (mfi *mockFileInfo) Size() int64 { return 0 } -func (mfi *mockFileInfo) Mode() os.FileMode { if mfi.isDir { return os.ModeDir } ; return 0 } +func (mfi *mockFileInfo) Size() int64 { return 0 } +func (mfi *mockFileInfo) Mode() os.FileMode { + if mfi.isDir { + return os.ModeDir + } + return 0 +} func (mfi *mockFileInfo) ModTime() time.Time { return time.Now() } -func (mfi *mockFileInfo) IsDir() bool { return mfi.isDir } -func (mfi *mockFileInfo) Sys() interface{} { return nil } - +func (mfi *mockFileInfo) IsDir() bool { return mfi.isDir } +func (mfi *mockFileInfo) Sys() interface{} { return nil } var mockFilesystem = make(map[string]*mockFileInfo) var mockMkdirAllPaths []string @@ -159,14 +164,13 @@ func mockChdir(dir string) error { break } } - // Also check if it's the backup folder itself, which might be created by MkdirAll - for _, p := range mockMkdirAllPaths { - if p == dir { - exists = true - break - } - } - + // Also check if it's the backup folder itself, which might be created by MkdirAll + for _, p := range mockMkdirAllPaths { + if p == dir { + exists = true + break + } + } if !exists && dir != "/" && dir != "." && !strings.HasPrefix(dir, "/tmp/") { // Allow /tmp for t.TempDir() // A more sophisticated mock would check if 'dir' is a valid path based on mockFilesystem @@ -184,7 +188,6 @@ func resetMocks() { mockCurrentDir = "/app" // Reset to a sensible default } - // --- Test Cases --- func TestEnvVarParsing(t *testing.T) { @@ -228,7 +231,6 @@ func TestEnvVarParsing(t *testing.T) { }) } - func TestAppRun_ClonePath(t *testing.T) { resetMocks() ctx := context.Background() @@ -281,13 +283,16 @@ func TestAppRun_ClonePath(t *testing.T) { t.Errorf("Expected MkdirAll to be called for %s", tempBackupDir) } - // Check git commands expectedRepoPath := filepath.Join(tempBackupDir, repo1FullName+".git") authenticatedCloneURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo1FullName) unauthenticatedCloneURL := fmt.Sprintf("https://github.com/%s.git", repo1FullName) - expectedCommands := []struct{Dir string; Name string; Args []string} { + expectedCommands := []struct { + Dir string + Name string + Args []string + }{ {"", "git", []string{"config", "--global", "--add", "safe.directory", "*"}}, {"", "git", []string{"clone", "--mirror", "--no-checkout", "--progress", authenticatedCloneURL, expectedRepoPath}}, {expectedRepoPath, "git", []string{"lfs", "fetch", "--all"}}, @@ -322,7 +327,7 @@ func TestAppRun_UpdatePath(t *testing.T) { expectedRepoPath := filepath.Join(tempBackupDir, repo1FullName+".git") // Simulate existing backup by adding it to our mock filesystem - mockFilesystem[expectedRepoPath] = &mockFileInfo{name: repo1FullName+".git", isDir: true} + mockFilesystem[expectedRepoPath] = &mockFileInfo{name: repo1FullName + ".git", isDir: true} app := App{ GithubToken: "test-token", @@ -357,14 +362,18 @@ func TestAppRun_UpdatePath(t *testing.T) { authenticatedCloneURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo1FullName) unauthenticatedCloneURL := fmt.Sprintf("https://github.com/%s.git", repo1FullName) - expectedCommands := []struct{Dir string; Name string; Args []string} { + expectedCommands := []struct { + Dir string + Name string + Args []string + }{ {"", "git", []string{"config", "--global", "--add", "safe.directory", "*"}}, {expectedRepoPath, "git", []string{"remote", "set-url", "origin", authenticatedCloneURL}}, {expectedRepoPath, "git", []string{"remote", "update"}}, {expectedRepoPath, "git", []string{"lfs", "fetch", "--all"}}, {expectedRepoPath, "git", []string{"remote", "set-url", "origin", unauthenticatedCloneURL}}, } - if len(mockCmd.commandDetails) != len(expectedCommands) { + if len(mockCmd.commandDetails) != len(expectedCommands) { t.Errorf("Expected %d git commands for update, got %d. Executed: %v", len(expectedCommands), len(mockCmd.commandDetails), mockCmd.executedCmds) } for i, expCmd := range expectedCommands { @@ -380,7 +389,6 @@ func TestAppRun_UpdatePath(t *testing.T) { } } - func TestAppRun_GitHubUserError(t *testing.T) { resetMocks() ctx := context.Background() @@ -402,9 +410,8 @@ func TestAppRun_GitHubUserError(t *testing.T) { mockGh.GetAuthenticatedUserFunc = func(ctx context.Context) (*github.User, error) { return nil, errors.New(expectedError) } - // Mock git config to succeed - mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } - + // Mock git config to succeed + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } err := app.runApp(ctx) if err == nil { @@ -440,8 +447,7 @@ func TestAppRun_GitHubListReposError(t *testing.T) { mockGh.ListUserRepositoriesFunc = func(ctx context.Context, user string, opts *github.RepositoryListOptions) ([]*github.Repository, *github.Response, error) { return nil, nil, errors.New(expectedError) } - mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } - + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } err := app.runApp(ctx) if err == nil { @@ -486,7 +492,6 @@ func TestAppRun_GitConfigError(t *testing.T) { } } - func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { resetMocks() ctx := context.Background() @@ -528,9 +533,8 @@ func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { } return nil // Success for other commands (like LFS for repo2) } - // git config and other non-RunAndOutput commands succeed - mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } - + // git config and other non-RunAndOutput commands succeed + mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } // --- Act --- err := app.runApp(ctx) // This error will be nil if any repo succeeds and errors are logged. @@ -543,7 +547,7 @@ func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { // The mockCmdRunner.RunAndOutputFunc will only be called for clone, remote update, lfs // We expect clone for repo1 (fails), then clone for repo2 (succeeds in mock) // Then LFS for repo2, then remote set-url for repo2. - + // Check that clone for repo2 was attempted and "succeeded" (mock success) repo2Path := filepath.Join(tempBackupDir, repo2FullName+".git") authenticatedCloneURLRepo2 := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo2FullName) @@ -568,7 +572,7 @@ func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { if !foundCloneRepo2 { t.Error("Expected clone attempt for repo2") } - + // Check LFS fetch for repo2 was attempted if !mockCmd.findCommand("git", "lfs", "fetch", "--all") { // This check is a bit broad, better to check with dir @@ -598,4 +602,3 @@ func TestMain(m *testing.M) { // - `os.Getwd`, `os.Chdir` errors (should skip the repo or handle gracefully) // - `os.MkdirAll` failure for the main backup folder (should be fatal for runApp) // - Pagination in ListUserRepositories -``` From e1e22dd12dbe1575489e474d8308505af3d37f72 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 07:49:58 +0000 Subject: [PATCH 4/4] Fix: Correct failing Go tests This commit addresses issues in `main_test.go` that caused tests to fail: 1. The unused variable `authenticatedCloneURLRepo2` in the `TestAppRun_CloneErrorSkipsRepo` test was commented out to resolve a build error. 2. The mock function `mockCmd.RunAndOutputFunc` within `TestAppRun_CloneErrorSkipsRepo` was updated. It was previously using an incorrect argument index (`args[3]`) to identify the repository URL for simulating a clone failure. This has been corrected to use the proper index (`args[4]`), allowing the test to accurately simulate the intended scenario and pass. All tests now pass after these changes. --- main_test.go | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/main_test.go b/main_test.go index 2c2ed2a..f70081d 100644 --- a/main_test.go +++ b/main_test.go @@ -528,10 +528,13 @@ func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { cloneError := errors.New("git clone intentional error") mockCmd.RunAndOutputFunc = func(dir string, name string, args ...string) error { - if name == "git" && args[0] == "clone" && strings.Contains(args[3], repo1FullName) { + // args for "git clone..." are: + // args[0]="clone", args[1]="--mirror", args[2]="--no-checkout", + // args[3]="--progress", args[4]=authenticatedCloneURL, args[5]=repoPath + if name == "git" && args[0] == "clone" && len(args) > 4 && strings.Contains(args[4], repo1FullName) { return cloneError } - return nil // Success for other commands (like LFS for repo2) + return nil // Success for other commands } // git config and other non-RunAndOutput commands succeed mockCmd.RunFunc = func(dir, name string, args ...string) ([]byte, error) { return []byte{}, nil } @@ -550,18 +553,28 @@ func TestAppRun_CloneErrorSkipsRepo(t *testing.T) { // Check that clone for repo2 was attempted and "succeeded" (mock success) repo2Path := filepath.Join(tempBackupDir, repo2FullName+".git") - authenticatedCloneURLRepo2 := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo2FullName) + // authenticatedCloneURLRepo2 := fmt.Sprintf("https://%s:%s@github.com/%s.git", username, app.GithubToken, repo2FullName) foundCloneRepo1 := false foundCloneRepo2 := false - for _, detail := range mockCmd.commandDetails { - if detail.Name == "git" && detail.Args[0] == "clone" { - if strings.Contains(detail.Args[3], repo1FullName) { - foundCloneRepo1 = true - } - if strings.Contains(detail.Args[3], repo2FullName) { - foundCloneRepo2 = true + for i, detail := range mockCmd.commandDetails { + t.Logf("Checking command #%d: Dir='%s', Name='%s', Args=%v", i, detail.Dir, detail.Name, detail.Args) // DEBUG LOG + if detail.Name == "git" && len(detail.Args) > 0 && detail.Args[0] == "clone" { + // Args for clone: args[0]="clone", args[1]="--mirror", args[2]="--no-checkout", + // args[3]="--progress", args[4]=authenticatedCloneURL, args[5]=repoPath + if len(detail.Args) > 4 { // Ensure Args[4] (URL) exists + t.Logf("Found 'git clone' command. URL (detail.Args[4]) = %s", detail.Args[4]) // DEBUG LOG + if strings.Contains(detail.Args[4], repo1FullName) { + t.Logf("Matched repo1FullName (%s) in URL (%s)", repo1FullName, detail.Args[4]) // DEBUG LOG + foundCloneRepo1 = true + } + if strings.Contains(detail.Args[4], repo2FullName) { + t.Logf("Matched repo2FullName (%s) in URL (%s)", repo2FullName, detail.Args[4]) // DEBUG LOG + foundCloneRepo2 = true + } + } else { + t.Logf("Found 'git clone' command but len(detail.Args) <= 4. Args: %v", detail.Args) } } }