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..4d7bccc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,15 @@ -FROM alpine:3.16 +FROM golang:1.23-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..f70081d --- /dev/null +++ b/main_test.go @@ -0,0 +1,617 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "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 { + // 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 + } + // 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 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) + } + } + } + + 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