package process import ( "context" "strings" "testing" ) // TestGetLog_NoBaseReturnsFullGraph guards against a regression where // --first-parent gets applied unconditionally to GetLog. The flag is only // correct for the divergence-range path (baseCommit != ""); the open-ended // "recent N commits" path must keep returning the full commit graph so // future history-view callers (activity widgets, etc.) aren't silently // filtered. func TestGetLog_NoBaseReturnsFullGraph(t *testing.T) { repoDir, cleanup := setupTestRepo(t) t.Cleanup(cleanup) log := newTestLogger(t) ctx := context.Background() // Build a graph where main has commits brought in via a merge from a // side branch. Without --first-parent, GetLog returns all commits; // with --first-parent, only the merge commit's first-parent line. branchPoint := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) runGit(t, repoDir, "checkout", "-b", "side", branchPoint) writeFile(t, repoDir, "side.txt", "side\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "side: commit a") sideCommit := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) runGit(t, repoDir, "checkout", "main") runGit(t, repoDir, "merge", "--no-ff", "-m", "Merge side", "side") gitOp := NewGitOperator(repoDir, log, nil) result, err := gitOp.GetLog(ctx, "", 0) if err != nil { t.Fatalf("GetLog returned error: %v", err) } if !result.Success { t.Fatalf("GetLog failed: %s", result.Error) } gotSHAs := make(map[string]bool, len(result.Commits)) for _, c := range result.Commits { gotSHAs[c.CommitSHA] = true } if !gotSHAs[sideCommit] { t.Errorf("expected side-branch commit %s in no-base log (full graph), missing — --first-parent likely leaked into this path", sideCommit) } } // TestGetLog_StaleLocalBranchScenario reproduces the bug where a worktree's // local main has fallen behind origin/main. If the merge-base is computed // against the local ref, it returns an old SHA and the log range sweeps in // dozens of commits that aren't actually on the feature branch. The fix is // to compute merge-base against origin/ — exercised at the API layer // in TestRunGitLogForRepo_PrefersOriginRef. This test documents the shape of // the underlying git scenario. func TestGetLog_StaleLocalBranchScenario(t *testing.T) { repoDir, cleanup := setupTestRepo(t) t.Cleanup(cleanup) log := newTestLogger(t) ctx := context.Background() // Capture initial main tip — local main will stay pinned here while // the remote moves forward (simulating a worktree that hasn't fetched). staleLocalMain := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) // Push the staleLocalMain so origin has it, then advance both local main // and origin/main with extra commits, then reset local main back so it // looks stale. for i := 0; i < 3; i++ { writeFile(t, repoDir, "main-x.txt", strings.Repeat("main x\n", i+1)) runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "chore: main forward") } advancedMain := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) runGit(t, repoDir, "push", "origin", "main") // Reset local main back to staleLocalMain (simulates "didn't fetch"). runGit(t, repoDir, "reset", "--hard", staleLocalMain) // Now refetch so origin/main is ahead but local main isn't. runGit(t, repoDir, "fetch", "origin", "refs/heads/main:refs/remotes/origin/main") // Branch from origin/main (the current upstream tip). runGit(t, repoDir, "checkout", "-b", "feature/x", advancedMain) writeFile(t, repoDir, "feature.txt", "feature work\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "feat: feature work") gitOp := NewGitOperator(repoDir, log, nil) // Computing merge-base against the stale LOCAL main returns the older // SHA, and the log range balloons to include the 3 main commits plus // the branch's own commit. staleMergeBase, err := gitOp.GetMergeBase(ctx, "HEAD", "main") if err != nil { t.Fatalf("merge-base local main failed: %v", err) } if staleMergeBase != staleLocalMain { t.Fatalf("expected stale merge-base = %s, got %s", staleLocalMain, staleMergeBase) } staleResult, err := gitOp.GetLog(ctx, staleMergeBase, 0) if err != nil { t.Fatalf("GetLog with stale base failed: %v", err) } if len(staleResult.Commits) != 4 { t.Errorf("stale local main: expected 4 commits leaked in, got %d", len(staleResult.Commits)) } // Computing against origin/main returns the correct base — only the // branch's own commit shows up. correctMergeBase, err := gitOp.GetMergeBase(ctx, "HEAD", "origin/main") if err != nil { t.Fatalf("merge-base origin/main failed: %v", err) } if correctMergeBase != advancedMain { t.Fatalf("expected correct merge-base = %s, got %s", advancedMain, correctMergeBase) } correctResult, err := gitOp.GetLog(ctx, correctMergeBase, 0) if err != nil { t.Fatalf("GetLog with correct base failed: %v", err) } if len(correctResult.Commits) != 1 { t.Errorf("origin/main: expected 1 branch-only commit, got %d", len(correctResult.Commits)) } } // TestGetLog_FirstParentSkipsMergedInCommits verifies that GetLog uses // --first-parent so commits brought in via a merge from main do not appear in // the branch's commit list. Without --first-parent, "git log ..HEAD" // follows both parents of the merge commit and surfaces main's commits, which // confuses users expecting "what this branch did". func TestGetLog_FirstParentSkipsMergedInCommits(t *testing.T) { repoDir, cleanup := setupTestRepo(t) t.Cleanup(cleanup) log := newTestLogger(t) ctx := context.Background() // Capture the original main tip — used as the branch point. branchPoint := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) // Create a feature branch and make one commit. runGit(t, repoDir, "checkout", "-b", "feature/x") writeFile(t, repoDir, "feature.txt", "feature work\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "feat: feature work") featureCommit := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) // Move main forward with two unrelated commits. runGit(t, repoDir, "checkout", "main") writeFile(t, repoDir, "main-a.txt", "main a\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "chore: main a") mainA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) writeFile(t, repoDir, "main-b.txt", "main b\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "chore: main b") mainB := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) // Merge main into feature/x with --no-ff so a real merge commit is created. runGit(t, repoDir, "checkout", "feature/x") runGit(t, repoDir, "merge", "--no-ff", "-m", "Merge main into feature/x", "main") mergeCommit := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) // One more commit on the feature branch after the merge. writeFile(t, repoDir, "feature2.txt", "more feature work\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "feat: more feature work") postMerge := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) gitOp := NewGitOperator(repoDir, log, nil) result, err := gitOp.GetLog(ctx, branchPoint, 0) if err != nil { t.Fatalf("GetLog returned error: %v", err) } if !result.Success { t.Fatalf("GetLog failed: %s", result.Error) } gotSHAs := make(map[string]bool, len(result.Commits)) for _, c := range result.Commits { gotSHAs[c.CommitSHA] = true } wantPresent := []struct { sha string label string }{ {postMerge, "post-merge feature commit"}, {mergeCommit, "merge commit"}, {featureCommit, "initial feature commit"}, } for _, w := range wantPresent { if !gotSHAs[w.sha] { t.Errorf("expected %s (%s) in log, missing", w.label, w.sha) } } wantAbsent := []struct { sha string label string }{ {mainA, "main commit a (came in via merge)"}, {mainB, "main commit b (came in via merge)"}, } for _, w := range wantAbsent { if gotSHAs[w.sha] { t.Errorf("expected %s (%s) to be absent (--first-parent should skip it)", w.label, w.sha) } } if len(result.Commits) != 3 { shas := make([]string, 0, len(result.Commits)) for _, c := range result.Commits { shas = append(shas, c.CommitSHA[:7]+" "+c.CommitMessage) } t.Errorf("expected exactly 3 commits with --first-parent, got %d:\n%s", len(result.Commits), strings.Join(shas, "\n")) } } // TestIsAncestor covers the exit-code contract of GitOperator.IsAncestor: // a true ancestor returns true, a diverged sibling returns false, an equal // SHA returns true (git treats a commit as its own ancestor), an unknown SHA // is a genuine error, and empty inputs short-circuit to (false, nil). func TestIsAncestor(t *testing.T) { repoDir, cleanup := setupTestRepo(t) t.Cleanup(cleanup) log := newTestLogger(t) ctx := context.Background() // base -> a on main; a side branch diverges at base. base := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) writeFile(t, repoDir, "a.txt", "a\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "chore: a") mainA := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) runGit(t, repoDir, "checkout", "-b", "side", base) writeFile(t, repoDir, "side.txt", "side\n") runGit(t, repoDir, "add", ".") runGit(t, repoDir, "commit", "-m", "chore: side") sideCommit := strings.TrimSpace(runGit(t, repoDir, "rev-parse", "HEAD")) gitOp := NewGitOperator(repoDir, log, nil) tests := []struct { name string ancestor string descendant string want bool wantErr bool }{ {name: "true ancestor", ancestor: base, descendant: mainA, want: true}, {name: "diverged sibling", ancestor: mainA, descendant: sideCommit, want: false}, {name: "equal SHA is ancestor of itself", ancestor: mainA, descendant: mainA, want: true}, {name: "empty ancestor short-circuits", ancestor: "", descendant: mainA, want: false}, {name: "empty descendant short-circuits", ancestor: base, descendant: "", want: false}, {name: "unknown SHA errors", ancestor: base, descendant: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", want: false, wantErr: true}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := gitOp.IsAncestor(ctx, tc.ancestor, tc.descendant) if tc.wantErr { if err == nil { t.Fatalf("expected error, got nil (result %v)", got) } return } if err != nil { t.Fatalf("unexpected error: %v", err) } if got != tc.want { t.Errorf("IsAncestor(%s, %s) = %v, want %v", tc.ancestor, tc.descendant, got, tc.want) } }) } }