package worker import ( "context" "log/slog" "os" "path/filepath" "strings" "testing" ) func testGitOps(token string) *GitOperations { return NewGitOperations(GitOperationsConfig{ GiteaToken: token, GitUser: "test-user", GitEmail: "test@example.com", Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})), }) } func TestNewGitOperations_Defaults(t *testing.T) { g := NewGitOperations(GitOperationsConfig{}) if g.gitUser != "rdev-worker" { t.Errorf("expected default gitUser 'rdev-worker', got %q", g.gitUser) } if g.gitEmail != "worker@threesix.ai" { t.Errorf("expected default gitEmail 'worker@threesix.ai', got %q", g.gitEmail) } if g.logger == nil { t.Error("expected non-nil logger") } } func TestNewGitOperations_CustomValues(t *testing.T) { g := NewGitOperations(GitOperationsConfig{ GiteaToken: "my-token", GitUser: "custom-user", GitEmail: "custom@example.com", }) if g.giteaToken != "my-token" { t.Errorf("expected token 'my-token', got %q", g.giteaToken) } if g.gitUser != "custom-user" { t.Errorf("expected gitUser 'custom-user', got %q", g.gitUser) } if g.gitEmail != "custom@example.com" { t.Errorf("expected gitEmail 'custom@example.com', got %q", g.gitEmail) } } func TestInjectToken(t *testing.T) { tests := []struct { name string token string url string expect string }{ { name: "https URL with token", token: "ghp_abc123", url: "https://git.example.com/org/repo.git", expect: "https://ghp_abc123@git.example.com/org/repo.git", }, { name: "http URL with token", token: "ghp_abc123", url: "http://git.example.com/org/repo.git", expect: "http://ghp_abc123@git.example.com/org/repo.git", }, { name: "no token", token: "", url: "https://git.example.com/org/repo.git", expect: "https://git.example.com/org/repo.git", }, { name: "ssh URL unchanged", token: "ghp_abc123", url: "git@git.example.com:org/repo.git", expect: "git@git.example.com:org/repo.git", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := testGitOps(tt.token) got := g.injectToken(tt.url) if got != tt.expect { t.Errorf("injectToken(%q) = %q, want %q", tt.url, got, tt.expect) } }) } } func TestRedactToken(t *testing.T) { tests := []struct { name string token string input string expect string }{ { name: "redacts token from message", token: "secret123", input: "fatal: Authentication failed for 'https://secret123@git.example.com/repo.git'", expect: "fatal: Authentication failed for 'https://[REDACTED]@git.example.com/repo.git'", }, { name: "no token to redact", token: "", input: "fatal: repository not found", expect: "fatal: repository not found", }, { name: "token not present in message", token: "secret123", input: "fatal: repository not found", expect: "fatal: repository not found", }, { name: "multiple occurrences", token: "tok", input: "tok appears twice: tok", expect: "[REDACTED] appears twice: [REDACTED]", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { g := testGitOps(tt.token) got := g.redactToken(tt.input) if got != tt.expect { t.Errorf("redactToken(%q) = %q, want %q", tt.input, got, tt.expect) } }) } } func TestEnsureGitDir(t *testing.T) { g := testGitOps("") t.Run("valid git directory", func(t *testing.T) { dir := t.TempDir() if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { t.Fatal(err) } if err := g.EnsureGitDir(dir); err != nil { t.Errorf("expected no error for valid git dir, got: %v", err) } }) t.Run("no .git directory", func(t *testing.T) { dir := t.TempDir() err := g.EnsureGitDir(dir) if err == nil { t.Error("expected error for non-git directory") } }) t.Run(".git is a file not directory", func(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: .."), 0o644); err != nil { t.Fatal(err) } err := g.EnsureGitDir(dir) if err == nil { t.Error("expected error when .git is a file") } }) } // TestCommitAndPush_NoChanges tests that CommitAndPush returns nil when // there are no staged changes in the repository. func TestCommitAndPush_NoChanges(t *testing.T) { g := testGitOps("") ctx := context.Background() // Create a real git repo with an initial commit dir := t.TempDir() if err := g.runGit(ctx, dir, "init"); err != nil { t.Fatal("git init:", err) } if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil { t.Fatal("git config user.name:", err) } if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil { t.Fatal("git config user.email:", err) } // Create initial commit so HEAD exists if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil { t.Fatal(err) } if err := g.runGit(ctx, dir, "add", "-A"); err != nil { t.Fatal("git add:", err) } if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil { t.Fatal("git commit:", err) } // No new changes — should return empty with no error sha, files, err := g.CommitAndPush(ctx, dir, "no changes", false) if err != nil { t.Errorf("expected no error, got: %v", err) } if sha != "" { t.Errorf("expected empty SHA, got: %q", sha) } if len(files) != 0 { t.Errorf("expected no files, got: %v", files) } } // TestCommitAndPush_WithChanges tests that CommitAndPush correctly stages, // commits, and returns SHA and changed file list. func TestCommitAndPush_WithChanges(t *testing.T) { g := testGitOps("") ctx := context.Background() // Create a real git repo dir := t.TempDir() if err := g.runGit(ctx, dir, "init"); err != nil { t.Fatal("git init:", err) } if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil { t.Fatal("git config user.name:", err) } if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil { t.Fatal("git config user.email:", err) } // Initial commit if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("init"), 0o644); err != nil { t.Fatal(err) } if err := g.runGit(ctx, dir, "add", "-A"); err != nil { t.Fatal("git add:", err) } if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil { t.Fatal("git commit:", err) } // Create new files to commit if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0o644); err != nil { t.Fatal(err) } // CommitAndPush without push (no remote) sha, files, err := g.CommitAndPush(ctx, dir, "add go files", false) if err != nil { t.Errorf("expected no error, got: %v", err) } if sha == "" { t.Error("expected non-empty SHA") } if len(sha) < 7 { t.Errorf("expected SHA to be at least 7 chars, got: %q", sha) } if len(files) != 2 { t.Errorf("expected 2 changed files, got %d: %v", len(files), files) } // Verify the files are in the list fileSet := make(map[string]bool) for _, f := range files { fileSet[f] = true } if !fileSet["main.go"] { t.Error("expected main.go in changed files") } if !fileSet["go.mod"] { t.Error("expected go.mod in changed files") } } // TestCommitAndPush_PushWithoutRemote tests that push fails gracefully // when there's no remote configured. func TestCommitAndPush_PushWithoutRemote(t *testing.T) { g := testGitOps("") ctx := context.Background() dir := t.TempDir() if err := g.runGit(ctx, dir, "init"); err != nil { t.Fatal("git init:", err) } if err := g.runGit(ctx, dir, "config", "user.name", "test"); err != nil { t.Fatal("git config:", err) } if err := g.runGit(ctx, dir, "config", "user.email", "test@test.com"); err != nil { t.Fatal("git config:", err) } if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("init"), 0o644); err != nil { t.Fatal(err) } if err := g.runGit(ctx, dir, "add", "-A"); err != nil { t.Fatal("git add:", err) } if err := g.runGit(ctx, dir, "commit", "-m", "initial"); err != nil { t.Fatal("git commit:", err) } // Add a new file if err := os.WriteFile(filepath.Join(dir, "new.txt"), []byte("new"), 0o644); err != nil { t.Fatal(err) } // Push should fail (no remote) but commit succeeds — SHA is returned sha, files, err := g.CommitAndPush(ctx, dir, "test push", true) if err == nil { t.Error("expected push error when no remote configured") } // Even though push failed, commit should have succeeded if sha == "" { t.Error("expected SHA from successful commit before push failure") } if len(files) != 1 || files[0] != "new.txt" { t.Errorf("expected [new.txt], got: %v", files) } } // TestCloneToTemp_InvalidURL tests that CloneToTemp fails on a bad URL. func TestCloneToTemp_InvalidURL(t *testing.T) { g := testGitOps("") ctx := context.Background() _, _, err := g.CloneToTemp(ctx, "https://invalid.example.com/no-such-repo.git") if err == nil { t.Error("expected error cloning invalid URL") } } // TestCloneToTemp_LocalRepo tests cloning a local bare repository. func TestCloneToTemp_LocalRepo(t *testing.T) { g := testGitOps("") ctx := context.Background() // Create a bare repo to clone from bareDir := t.TempDir() if err := g.runGit(ctx, bareDir, "init", "--bare"); err != nil { t.Fatal("git init --bare:", err) } // Create a source repo and push to the bare repo srcDir := t.TempDir() if err := g.runGit(ctx, srcDir, "init"); err != nil { t.Fatal("git init:", err) } if err := g.runGit(ctx, srcDir, "config", "user.name", "test"); err != nil { t.Fatal(err) } if err := g.runGit(ctx, srcDir, "config", "user.email", "test@test.com"); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(srcDir, "hello.txt"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } if err := g.runGit(ctx, srcDir, "add", "-A"); err != nil { t.Fatal(err) } if err := g.runGit(ctx, srcDir, "commit", "-m", "initial"); err != nil { t.Fatal(err) } if err := g.runGit(ctx, srcDir, "remote", "add", "origin", bareDir); err != nil { t.Fatal(err) } if err := g.runGit(ctx, srcDir, "push", "origin", "master"); err != nil { // Some git versions use "main" as default branch if err2 := g.runGit(ctx, srcDir, "push", "origin", "main"); err2 != nil { t.Fatalf("push failed for both master and main: master=%v, main=%v", err, err2) } } // Clone the bare repo using file:// protocol cloneDir, cleanup, err := g.CloneToTemp(ctx, "file://"+bareDir) if err != nil { t.Fatalf("CloneToTemp failed: %v", err) } defer cleanup() // Verify the cloned file exists content, err := os.ReadFile(filepath.Join(cloneDir, "hello.txt")) if err != nil { t.Fatalf("failed to read cloned file: %v", err) } if string(content) != "hello" { t.Errorf("expected file content 'hello', got %q", string(content)) } // Verify .git dir exists if err := g.EnsureGitDir(cloneDir); err != nil { t.Errorf("cloned dir should be a git repo: %v", err) } // Verify git config was set userName, err := g.runGitOutput(ctx, cloneDir, "config", "user.name") if err != nil { t.Fatalf("failed to get user.name: %v", err) } if got := strings.TrimSpace(userName); got != "test-user" { t.Errorf("expected user.name 'test-user', got %q", got) } } func TestRunGit_ContextCancellation(t *testing.T) { g := testGitOps("") ctx, cancel := context.WithCancel(context.Background()) cancel() // Cancel immediately dir := t.TempDir() err := g.runGit(ctx, dir, "status") if err == nil { t.Error("expected error when context is cancelled") } }