package gitengine import ( "context" "errors" "os" "os/exec" "path/filepath" "reflect" "strings" "testing" "time" ) type fakeRunner struct { outputs []string errs []error calls []fakeCall } type fakeCall struct { workdir string args []string } func (f *fakeRunner) Run(workdir string, args ...string) (string, error) { f.calls = append(f.calls, fakeCall{ workdir: workdir, args: append([]string(nil), args...), }) callIndex := len(f.calls) - 1 var output string if callIndex < len(f.outputs) { output = f.outputs[callIndex] } var err error if callIndex < len(f.errs) { err = f.errs[callIndex] } return output, err } func TestCLIRunTimesOut(t *testing.T) { _, err := (CLI{Timeout: 10 * time.Millisecond}).Run("", "-c", "alias.wait=!sleep 1", "wait") if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("expected context deadline exceeded, got %v", err) } } func TestStatusClean(t *testing.T) { runner := &fakeRunner{outputs: []string{"\n"}} result, err := Status(runner, "/repo") if err != nil { t.Fatalf("Status: %v", err) } if !result.Clean { t.Fatal("expected clean status") } if !reflect.DeepEqual(runner.calls[0].args, []string{"status", "--porcelain"}) { t.Fatalf("args: %#v", runner.calls[0].args) } } func TestDiff(t *testing.T) { runner := &fakeRunner{outputs: []string{"diff output"}} got, err := Diff(runner, "/repo", DiffOptions{}) if err != nil { t.Fatalf("Diff: %v", err) } if got != "diff output" { t.Fatalf("diff output: got %q", got) } want := []fakeCall{ {workdir: "/repo", args: []string{"diff"}}, } if !reflect.DeepEqual(runner.calls, want) { t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) } } func TestDiffStagedPathspecs(t *testing.T) { runner := &fakeRunner{} _, err := Diff(runner, "/repo", DiffOptions{ Staged: true, Pathspecs: []string{" README.md ", "", "services/core/main.go"}, }) if err != nil { t.Fatalf("Diff: %v", err) } want := []fakeCall{ {workdir: "/repo", args: []string{"diff", "--cached", "--", "README.md", "services/core/main.go"}}, } if !reflect.DeepEqual(runner.calls, want) { t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) } } func TestDiffRequiresWorkdir(t *testing.T) { _, err := Diff(&fakeRunner{}, "", DiffOptions{}) if !errors.Is(err, ErrInvalidGitInput) { t.Fatalf("expected ErrInvalidGitInput, got %v", err) } } func TestCloneFetchCheckout(t *testing.T) { runner := &fakeRunner{} if err := Clone(runner, CloneOptions{ RemoteURL: "ssh://git.example/nomadcode.git", WorktreePath: "/workspace/nomadcode/000", Branch: "develop", }); err != nil { t.Fatalf("Clone: %v", err) } if err := Fetch(runner, "/workspace/nomadcode/000", ""); err != nil { t.Fatalf("Fetch: %v", err) } if err := Checkout(runner, "/workspace/nomadcode/000", "feature/a"); err != nil { t.Fatalf("Checkout: %v", err) } want := []fakeCall{ {args: []string{"clone", "--branch", "develop", "ssh://git.example/nomadcode.git", "/workspace/nomadcode/000"}}, {workdir: "/workspace/nomadcode/000", args: []string{"fetch", "--prune", "origin"}}, {workdir: "/workspace/nomadcode/000", args: []string{"checkout", "feature/a"}}, } if !reflect.DeepEqual(runner.calls, want) { t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) } } func TestCommitAndPush(t *testing.T) { runner := &fakeRunner{} if err := Commit(runner, "/repo", CommitOptions{Message: "sync roadmap", All: true}); err != nil { t.Fatalf("Commit: %v", err) } if err := Push(runner, "/repo", "", "develop"); err != nil { t.Fatalf("Push: %v", err) } want := []fakeCall{ {workdir: "/repo", args: []string{"add", "-A"}}, {workdir: "/repo", args: []string{"commit", "-m", "sync roadmap"}}, {workdir: "/repo", args: []string{"push", "origin", "develop"}}, } if !reflect.DeepEqual(runner.calls, want) { t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) } } func TestHeadRevision(t *testing.T) { runner := &fakeRunner{outputs: []string{"abc123\n"}} got, err := HeadRevision(runner, "/repo") if err != nil { t.Fatalf("HeadRevision: %v", err) } if got != "abc123" { t.Fatalf("revision: got %q", got) } } func TestChangedFiles(t *testing.T) { runner := &fakeRunner{outputs: []string{"README.md\nservices/core/main.go\n\n"}} files, err := ChangedFiles(runner, "/repo", "abc", "def") if err != nil { t.Fatalf("ChangedFiles: %v", err) } want := []string{"README.md", "services/core/main.go"} if !reflect.DeepEqual(files, want) { t.Fatalf("files: got %#v want %#v", files, want) } } func TestChangedFilesRequiresRevisions(t *testing.T) { _, err := ChangedFiles(&fakeRunner{}, "/repo", "", "def") if !errors.Is(err, ErrInvalidGitInput) { t.Fatalf("expected ErrInvalidGitInput, got %v", err) } } func TestChangedFilesWithStatus(t *testing.T) { runner := &fakeRunner{outputs: []string{"A\tREADME.md\nM\tservices/core/main.go\nD\told.md\nR100\tfrom.md\tto.md\n"}} files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def") if err != nil { t.Fatalf("ChangedFilesWithStatus: %v", err) } want := []ChangedFile{ {Path: "README.md", ChangeType: "added"}, {Path: "services/core/main.go", ChangeType: "modified"}, {Path: "old.md", ChangeType: "deleted"}, {Path: "to.md", ChangeType: "renamed"}, } if !reflect.DeepEqual(files, want) { t.Fatalf("files:\n got %#v\nwant %#v", files, want) } } func TestChangedFilesWithStatusPreservesWhitespaceInPaths(t *testing.T) { runner := &fakeRunner{outputs: []string{ "A\tdocs/user guide.md\nM\tpath with spaces/file name.go\nR100\told name.md\tnew name.md\n", }} files, err := ChangedFilesWithStatus(runner, "/repo", "abc", "def") if err != nil { t.Fatalf("ChangedFilesWithStatus: %v", err) } want := []ChangedFile{ {Path: "docs/user guide.md", ChangeType: "added"}, {Path: "path with spaces/file name.go", ChangeType: "modified"}, {Path: "new name.md", ChangeType: "renamed"}, } if !reflect.DeepEqual(files, want) { t.Fatalf("files:\n got %#v\nwant %#v", files, want) } } func TestChangedFilesWithStatusLocalCommits(t *testing.T) { _, seedPath, initialRev := setupBareRemote(t) writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n\nupdated\n") writeFile(t, filepath.Join(seedPath, "app.txt"), "app\n") runGit(t, seedPath, "add", "README.md", "app.txt") runGit(t, seedPath, "commit", "-m", "update readme and add app") docsDir := filepath.Join(seedPath, "docs") if err := os.MkdirAll(docsDir, 0755); err != nil { t.Fatalf("MkdirAll %s failed: %v", docsDir, err) } writeFile(t, filepath.Join(docsDir, "guide.md"), "# Guide\n") runGit(t, seedPath, "add", "docs/guide.md") runGit(t, seedPath, "commit", "-m", "add guide") afterRev := gitOutput(t, seedPath, "rev-parse", "HEAD") names, err := ChangedFiles(CLI{}, seedPath, initialRev, afterRev) if err != nil { t.Fatalf("ChangedFiles: %v", err) } wantNames := []string{"README.md", "app.txt", "docs/guide.md"} if !sameStringSet(names, wantNames) { t.Fatalf("changed file names: got %#v want %#v", names, wantNames) } files, err := ChangedFilesWithStatus(CLI{}, seedPath, initialRev, afterRev) if err != nil { t.Fatalf("ChangedFilesWithStatus: %v", err) } wantStatus := map[string]string{ "README.md": "modified", "app.txt": "added", "docs/guide.md": "added", } if !sameChangedFileStatus(files, wantStatus) { t.Fatalf("changed file status: got %#v want %#v", files, wantStatus) } } func sameStringSet(got, want []string) bool { if len(got) != len(want) { return false } seen := make(map[string]int, len(got)) for _, item := range got { seen[item]++ } for _, item := range want { if seen[item] == 0 { return false } seen[item]-- } return true } func sameChangedFileStatus(got []ChangedFile, want map[string]string) bool { if len(got) != len(want) { return false } for _, file := range got { if want[file.Path] != file.ChangeType { return false } } return true } func runGit(t *testing.T, dir string, args ...string) { cmd := exec.Command("git", args...) cmd.Dir = dir if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out)) } } func gitOutput(t *testing.T, dir string, args ...string) string { cmd := exec.Command("git", args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %s failed in %s: %v\nOutput: %s", strings.Join(args, " "), dir, err, string(out)) } return strings.TrimSpace(string(out)) } func writeFile(t *testing.T, path, content string) { if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("WriteFile %s failed: %v", path, err) } } func setupBareRemote(t *testing.T) (barePath, seedPath string, initialRev string) { tmpDir := t.TempDir() // 1. Create bare remote repo barePath = filepath.Join(tmpDir, "remote.git") if err := os.MkdirAll(barePath, 0755); err != nil { t.Fatalf("failed to create bare remote dir: %v", err) } runGit(t, barePath, "init", "--bare") // Make sure default branch is main runGit(t, barePath, "symbolic-ref", "HEAD", "refs/heads/main") // 2. Create seed repo to initialize the remote seedPath = filepath.Join(tmpDir, "seed") if err := os.MkdirAll(seedPath, 0755); err != nil { t.Fatalf("failed to create seed dir: %v", err) } runGit(t, seedPath, "init") runGit(t, seedPath, "config", "user.name", "Test User") runGit(t, seedPath, "config", "user.email", "test@example.com") runGit(t, seedPath, "config", "commit.gpgsign", "false") // Create first commit writeFile(t, filepath.Join(seedPath, "README.md"), "# Seed Repository\n") runGit(t, seedPath, "add", "README.md") runGit(t, seedPath, "commit", "-m", "initial commit") runGit(t, seedPath, "branch", "-M", "main") // Push to bare remote runGit(t, seedPath, "remote", "add", "origin", barePath) runGit(t, seedPath, "push", "-u", "origin", "main") initialRev = gitOutput(t, seedPath, "rev-parse", "HEAD") return barePath, seedPath, initialRev } func TestCloneFetchWithLocalBareRemote(t *testing.T) { barePath, seedPath, initialRev := setupBareRemote(t) tmpDir := t.TempDir() worktreePath := filepath.Join(tmpDir, "worktree") // 1. Clone cli := CLI{} err := Clone(cli, CloneOptions{ RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main", }) if err != nil { t.Fatalf("Clone failed: %v", err) } // Verify cloned commit rev, err := HeadRevision(cli, worktreePath) if err != nil { t.Fatalf("HeadRevision failed: %v", err) } if rev != initialRev { t.Fatalf("expected cloned rev %q, got %q", initialRev, rev) } // 2. Make new commit in seed and push to bare remote writeFile(t, filepath.Join(seedPath, "file.txt"), "hello") runGit(t, seedPath, "add", "file.txt") runGit(t, seedPath, "commit", "-m", "second commit") runGit(t, seedPath, "push", "origin", "main") newRev := gitOutput(t, seedPath, "rev-parse", "HEAD") // 3. Fetch in worktree err = Fetch(cli, worktreePath, "origin") if err != nil { t.Fatalf("Fetch failed: %v", err) } // Verify origin/main has the new commit fetchedRev := gitOutput(t, worktreePath, "rev-parse", "origin/main") if fetchedRev != newRev { t.Fatalf("expected fetched rev %q, got %q", newRev, fetchedRev) } } func TestStatusDiffWithCleanStagedUnstagedWorkspace(t *testing.T) { barePath, _, _ := setupBareRemote(t) tmpDir := t.TempDir() worktreePath := filepath.Join(tmpDir, "worktree") cli := CLI{} if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { t.Fatalf("Clone failed: %v", err) } // 1. Clean workspace status, err := Status(cli, worktreePath) if err != nil { t.Fatalf("Status failed: %v", err) } if !status.Clean { t.Fatalf("expected clean status, got porcelain: %q", status.Output) } diff, err := Diff(cli, worktreePath, DiffOptions{}) if err != nil { t.Fatalf("Diff failed: %v", err) } if diff != "" { t.Fatalf("expected empty unstaged diff, got: %q", diff) } // 2. Unstaged changes (modified file) writeFile(t, filepath.Join(worktreePath, "README.md"), "# Seed Repository\nmodified content\n") status, err = Status(cli, worktreePath) if err != nil { t.Fatalf("Status failed: %v", err) } if status.Clean { t.Fatal("expected status to be dirty (unstaged changes)") } if !strings.Contains(status.Output, " README.md") { t.Fatalf("expected README.md in status, got: %q", status.Output) } diff, err = Diff(cli, worktreePath, DiffOptions{}) if err != nil { t.Fatalf("Diff failed: %v", err) } if !strings.Contains(diff, "+modified content") { t.Fatalf("expected diff to show modifications, got: %q", diff) } // Staged diff should be empty stagedDiff, err := Diff(cli, worktreePath, DiffOptions{Staged: true}) if err != nil { t.Fatalf("Diff staged failed: %v", err) } if stagedDiff != "" { t.Fatalf("expected empty staged diff, got: %q", stagedDiff) } // 3. Staged changes runGit(t, worktreePath, "add", "README.md") status, err = Status(cli, worktreePath) if err != nil { t.Fatalf("Status failed: %v", err) } if status.Clean { t.Fatal("expected status to be dirty (staged changes)") } // Unstaged diff should be empty now diff, err = Diff(cli, worktreePath, DiffOptions{}) if err != nil { t.Fatalf("Diff failed: %v", err) } if diff != "" { t.Fatalf("expected empty unstaged diff after staging, got: %q", diff) } // Staged diff should show modifications stagedDiff, err = Diff(cli, worktreePath, DiffOptions{Staged: true}) if err != nil { t.Fatalf("Diff staged failed: %v", err) } if !strings.Contains(stagedDiff, "+modified content") { t.Fatalf("expected staged diff to show modifications, got: %q", stagedDiff) } } func TestCommitPushToLocalBareRemote(t *testing.T) { barePath, _, _ := setupBareRemote(t) tmpDir := t.TempDir() worktreePath := filepath.Join(tmpDir, "worktree") cli := CLI{} if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { t.Fatalf("Clone failed: %v", err) } // Configure local git user for the worktree as well runGit(t, worktreePath, "config", "user.name", "Test User") runGit(t, worktreePath, "config", "user.email", "test@example.com") runGit(t, worktreePath, "config", "commit.gpgsign", "false") // Make changes and commit writeFile(t, filepath.Join(worktreePath, "newfile.txt"), "hello commit") err := Commit(cli, worktreePath, CommitOptions{ Message: "add newfile", All: true, }) if err != nil { t.Fatalf("Commit failed: %v", err) } localRev, err := HeadRevision(cli, worktreePath) if err != nil { t.Fatalf("HeadRevision failed: %v", err) } // Push err = Push(cli, worktreePath, "origin", "main") if err != nil { t.Fatalf("Push failed: %v", err) } // Verify bare remote branch has updated to local commit revision remoteRev := gitOutput(t, barePath, "rev-parse", "main") if remoteRev != localRev { t.Fatalf("expected remote revision %q, got %q", localRev, remoteRev) } } func TestCheckoutBranchFromSourceArgs(t *testing.T) { runner := &fakeRunner{} // 1. Simple checkout if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "main"}); err != nil { t.Fatalf("CheckoutBranch failed: %v", err) } // 2. Checkout with start point if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/a", StartPoint: "main"}); err != nil { t.Fatalf("CheckoutBranch failed: %v", err) } // 3. Checkout reset with start point if err := CheckoutBranch(runner, "/repo", CheckoutOptions{Branch: "feature/b", StartPoint: "main", Reset: true}); err != nil { t.Fatalf("CheckoutBranch failed: %v", err) } want := []fakeCall{ {workdir: "/repo", args: []string{"checkout", "main"}}, {workdir: "/repo", args: []string{"checkout", "-b", "feature/a", "main"}}, {workdir: "/repo", args: []string{"checkout", "-B", "feature/b", "main"}}, } if !reflect.DeepEqual(runner.calls, want) { t.Fatalf("calls:\n got %#v\nwant %#v", runner.calls, want) } } func TestCheckoutBranchFromSource(t *testing.T) { barePath, _, initialRev := setupBareRemote(t) tmpDir := t.TempDir() worktreePath := filepath.Join(tmpDir, "worktree") cli := CLI{} if err := Clone(cli, CloneOptions{RemoteURL: barePath, WorktreePath: worktreePath, Branch: "main"}); err != nil { t.Fatalf("Clone failed: %v", err) } // Configure local user runGit(t, worktreePath, "config", "user.name", "Test User") runGit(t, worktreePath, "config", "user.email", "test@example.com") runGit(t, worktreePath, "config", "commit.gpgsign", "false") // Make another commit in main writeFile(t, filepath.Join(worktreePath, "main_only.txt"), "main data") if err := Commit(cli, worktreePath, CommitOptions{Message: "commit on main", All: true}); err != nil { t.Fatalf("Commit failed: %v", err) } mainRev, err := HeadRevision(cli, worktreePath) if err != nil { t.Fatalf("HeadRevision failed: %v", err) } // 1. Checkout new branch from main's initialRev (start point) err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ Branch: "feature/from-start", StartPoint: initialRev, }) if err != nil { t.Fatalf("CheckoutBranch from initialRev failed: %v", err) } // Verify we are on feature/from-start, and revision is initialRev (not mainRev) currentRev, err := HeadRevision(cli, worktreePath) if err != nil { t.Fatalf("HeadRevision failed: %v", err) } if currentRev != initialRev { t.Fatalf("expected HEAD revision to be %q, got %q", initialRev, currentRev) } // 2. Checkout new branch from main's current mainRev (start point) with Reset: true // To test Reset flag (-B), checkout main again if err := Checkout(cli, worktreePath, "main"); err != nil { t.Fatalf("Checkout main failed: %v", err) } // Create branch feature/reset with initialRev first err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ Branch: "feature/reset", StartPoint: initialRev, }) if err != nil { t.Fatalf("CheckoutBranch feature/reset failed: %v", err) } // Now reset it to mainRev err = CheckoutBranch(cli, worktreePath, CheckoutOptions{ Branch: "feature/reset", StartPoint: mainRev, Reset: true, }) if err != nil { t.Fatalf("CheckoutBranch reset failed: %v", err) } currentRev, err = HeadRevision(cli, worktreePath) if err != nil { t.Fatalf("HeadRevision failed: %v", err) } if currentRev != mainRev { t.Fatalf("expected HEAD revision to be reset to %q, got %q", mainRev, currentRev) } }