diff --git a/apps/backend/internal/agentctl/server/process/workspace_git_status.go b/apps/backend/internal/agentctl/server/process/workspace_git_status.go index 81428864ff..37f1b4a162 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_git_status.go +++ b/apps/backend/internal/agentctl/server/process/workspace_git_status.go @@ -126,24 +126,21 @@ func (wt *WorkspaceTracker) getGitBranchInfo(ctx context.Context, update *types. return nil } -// getAheadBehindCounts populates the Ahead/Behind fields relative to the tracking or -// origin branch. Falls back to origin/main then origin/master when no upstream is set. +// getAheadBehindCounts populates the Ahead/Behind fields relative to the base branch +// (origin/main or origin/master). Always compares against the base branch rather than +// the remote tracking branch, because after a rebase the tracking branch has stale +// commit SHAs that produce inflated counts. func (wt *WorkspaceTracker) getAheadBehindCounts(ctx context.Context, update *types.GitStatusUpdate) { - // Use remote branch if available, otherwise fall back to origin/main or origin/master - // This handles worktree branches that don't have an upstream tracking branch set - compareRef := update.RemoteBranch - if compareRef == "" { - // Try origin/main first, then origin/master - checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/main") + // Always compare against the base branch (origin/main or origin/master). + // Using the remote tracking branch (origin/) gives wrong counts + // after rebase because rebased commits have new SHAs. + var compareRef string + for _, candidate := range []string{"origin/main", "origin/master"} { + checkCmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", candidate) checkCmd.Dir = wt.workDir if err := checkCmd.Run(); err == nil { - compareRef = "origin/main" - } else { - checkCmd2 := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "origin/master") - checkCmd2.Dir = wt.workDir - if err := checkCmd2.Run(); err == nil { - compareRef = "origin/master" - } + compareRef = candidate + break } } if compareRef == "" { diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go index 838d540da0..5e8df6ee4d 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker_test.go @@ -272,6 +272,69 @@ func TestGetGitStatus_AheadBehindWithoutUpstream(t *testing.T) { } } +// TestGetGitStatus_AheadBehindAfterRebase verifies that ahead/behind counts +// reflect divergence from the base branch (origin/main), not the remote tracking +// branch. After a rebase, the remote tracking branch has stale SHAs, so comparing +// against it produces inflated counts. +func TestGetGitStatus_AheadBehindAfterRebase(t *testing.T) { + repoDir, cleanup := setupTestRepo(t) + defer cleanup() + + log := newTestLogger(t) + wt := NewWorkspaceTracker(repoDir, log) + ctx := context.Background() + + // Create a feature branch with upstream tracking + runGit(t, repoDir, "checkout", "-b", "feature-rebase") + runGit(t, repoDir, "push", "-u", "origin", "feature-rebase") + + // Make 2 commits on the feature branch + writeFile(t, repoDir, "feature1.txt", "feature 1") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "Feature commit 1") + + writeFile(t, repoDir, "feature2.txt", "feature 2") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "Feature commit 2") + + // Push feature commits so remote tracking branch has them + runGit(t, repoDir, "push") + + // Switch to main, add commits, push + runGit(t, repoDir, "checkout", "main") + for i := 0; i < 5; i++ { + writeFile(t, repoDir, "main"+string(rune('0'+i))+".txt", "main content") + runGit(t, repoDir, "add", ".") + runGit(t, repoDir, "commit", "-m", "Main commit") + } + runGit(t, repoDir, "push") + + // Switch back to feature and rebase onto main + runGit(t, repoDir, "checkout", "feature-rebase") + runGit(t, repoDir, "fetch", "origin") + runGit(t, repoDir, "rebase", "origin/main") + + // Now: local feature-rebase has 2 feature commits on top of main (rebased) + // But origin/feature-rebase still points to old pre-rebase commits + // git rev-list --left-right --count feature-rebase...origin/feature-rebase + // would show ~7 ahead (2 rebased + 5 main commits), not 2 + + status, err := wt.getGitStatus(ctx) + if err != nil { + t.Fatalf("failed to get git status: %v", err) + } + + // Should be 2 ahead of origin/main (the 2 feature commits) + if status.Ahead != 2 { + t.Errorf("expected ahead=2, got %d", status.Ahead) + } + + // Should be 0 behind origin/main (just rebased) + if status.Behind != 0 { + t.Errorf("expected behind=0, got %d", status.Behind) + } +} + // TestFilterLocalCommits_PullAndResetScenario tests the exact scenario where: // 1. Session starts at commit X // 2. Upstream (origin/main) gets new commits diff --git a/apps/backend/internal/agentctl/types/streams/git.go b/apps/backend/internal/agentctl/types/streams/git.go index 9d531b9049..b9201ae972 100644 --- a/apps/backend/internal/agentctl/types/streams/git.go +++ b/apps/backend/internal/agentctl/types/streams/git.go @@ -25,10 +25,10 @@ type GitStatusUpdate struct { // Renamed contains paths of renamed files. Renamed []string `json:"renamed"` - // Ahead is the number of commits ahead of the remote branch. + // Ahead is the number of commits ahead of the base branch (origin/main). Ahead int `json:"ahead"` - // Behind is the number of commits behind the remote branch. + // Behind is the number of commits behind the base branch (origin/main). Behind int `json:"behind"` // Branch is the current local branch name. diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 63703b5f18..222ad08a03 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -251,13 +251,15 @@ function TopBarLeft({ /** Ahead/Behind commit status badges */ function GitAheadBehindBadges({ gitStatus, + baseBranch, }: { - gitStatus: { ahead: number; behind: number; remote_branch?: string | null }; + gitStatus: { ahead: number; behind: number }; + baseBranch?: string; }) { const ahead = gitStatus?.ahead ?? 0; const behind = gitStatus?.behind ?? 0; if (ahead === 0 && behind === 0) return null; - const remoteBranch = gitStatus?.remote_branch || "remote"; + const compareRef = baseBranch || "main"; return (
{ahead > 0 && ( @@ -268,7 +270,7 @@ function GitAheadBehindBadges({ - {ahead} commit{ahead !== 1 ? "s" : ""} ahead of {remoteBranch} + {ahead} commit{ahead !== 1 ? "s" : ""} ahead of {compareRef} )} @@ -280,7 +282,7 @@ function GitAheadBehindBadges({ - {behind} commit{behind !== 1 ? "s" : ""} behind {remoteBranch} + {behind} commit{behind !== 1 ? "s" : ""} behind {compareRef} )} @@ -302,7 +304,7 @@ function TopBarRight({ }: { activeSessionId?: string | null; baseBranch?: string; - gitStatus: { ahead: number; behind: number; remote_branch?: string | null }; + gitStatus: { ahead: number; behind: number }; showDebugOverlay?: boolean; onToggleDebugOverlay?: () => void; isArchived?: boolean; @@ -329,7 +331,7 @@ function TopBarRight({ )} - + {!isArchived && ( <>