diff --git a/src/browser/components/hooks/useGitBranchDetails.ts b/src/browser/components/hooks/useGitBranchDetails.ts index 681cd250a5..16cc44e43d 100644 --- a/src/browser/components/hooks/useGitBranchDetails.ts +++ b/src/browser/components/hooks/useGitBranchDetails.ts @@ -184,10 +184,12 @@ export function useGitBranchDetails( # Get current branch CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main") -# Get primary branch (main or master) -PRIMARY_BRANCH=$(git branch -r 2>/dev/null | grep -E 'origin/(main|master)$' | head -1 | sed 's@^.*origin/@@' || echo "main") - -if [ -z "$PRIMARY_BRANCH" ]; then +# Get primary branch (main or master) - check refs directly for reliability +if git rev-parse --verify "refs/remotes/origin/main" >/dev/null 2>&1; then + PRIMARY_BRANCH="main" +elif git rev-parse --verify "refs/remotes/origin/master" >/dev/null 2>&1; then + PRIMARY_BRANCH="master" +else PRIMARY_BRANCH="main" fi diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index fad068d115..62d4f84ff9 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -113,7 +113,7 @@ function createGitStatusExecutor(gitStatus?: Map) { } // GitStatusStore consolidated status script - if (script.includes("PRIMARY_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD")) { + if (script.includes("---PRIMARY---") && script.includes("---SHOW_BRANCH---")) { const output = createGitStatusOutput(status); return Promise.resolve({ success: true as const, output, exitCode: 0, wall_duration_ms: 50 }); } diff --git a/src/common/utils/git/gitStatus.ts b/src/common/utils/git/gitStatus.ts index 046d03aa05..594eb40e0d 100644 --- a/src/common/utils/git/gitStatus.ts +++ b/src/common/utils/git/gitStatus.ts @@ -9,10 +9,14 @@ * * @param baseRef - The ref to compare against (e.g., "origin/main"). * If not provided or not an origin/ ref, auto-detects. + * "origin/HEAD" is treated as auto-detect because it can be stale + * (e.g. in repos cloned from bundles). */ export function generateGitStatusScript(baseRef?: string): string { - // Extract branch name if it's an origin/ ref, otherwise empty for auto-detect - const preferredBranch = baseRef?.startsWith("origin/") ? baseRef.replace(/^origin\//, "") : ""; + // Extract branch name if it's an origin/ ref, otherwise empty for auto-detect. + // Note: origin/HEAD is ignored because it may point at a stale feature branch. + const rawPreferredBranch = baseRef?.startsWith("origin/") ? baseRef.replace(/^origin\//, "") : ""; + const preferredBranch = rawPreferredBranch === "HEAD" ? "" : rawPreferredBranch; return ` # Determine primary branch to compare against @@ -20,25 +24,36 @@ PRIMARY_BRANCH="" PREFERRED_BRANCH="${preferredBranch}" # Try preferred branch first if specified -if [ -n "$PREFERRED_BRANCH" ]; then - if git rev-parse --verify "refs/remotes/origin/$PREFERRED_BRANCH" >/dev/null 2>&1; then - PRIMARY_BRANCH="$PREFERRED_BRANCH" - fi +if [ -n "$PREFERRED_BRANCH" ] && git rev-parse --verify "refs/remotes/origin/$PREFERRED_BRANCH" >/dev/null 2>&1; then + PRIMARY_BRANCH="$PREFERRED_BRANCH" fi # Fall back to auto-detection if [ -z "$PRIMARY_BRANCH" ]; then - # Method 1: symbolic-ref (fastest) - PRIMARY_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') - - # Method 2: remote show origin (fallback) + # symbolic-ref can be stale (e.g., when cloned from a bundle) + SYMREF_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') + + # Trust symbolic-ref only if it looks like a default branch name + case "$SYMREF_BRANCH" in + main|master|develop|trunk|default|release) + if git rev-parse --verify "refs/remotes/origin/$SYMREF_BRANCH" >/dev/null 2>&1; then + PRIMARY_BRANCH="$SYMREF_BRANCH" + fi + ;; + esac + + # Prefer origin/main or origin/master if present (handles stale origin/HEAD) if [ -z "$PRIMARY_BRANCH" ]; then - PRIMARY_BRANCH=$(git remote show origin 2>/dev/null | grep 'HEAD branch' | cut -d' ' -f5) + if git rev-parse --verify "refs/remotes/origin/main" >/dev/null 2>&1; then + PRIMARY_BRANCH="main" + elif git rev-parse --verify "refs/remotes/origin/master" >/dev/null 2>&1; then + PRIMARY_BRANCH="master" + fi fi - # Method 3: check for main or master + # Fallback: ask origin (may require network) if [ -z "$PRIMARY_BRANCH" ]; then - PRIMARY_BRANCH=$(git branch -r 2>/dev/null | grep -E 'origin/(main|master)$' | head -1 | sed 's@^.*origin/@@') + PRIMARY_BRANCH=$(git remote show origin 2>/dev/null | grep 'HEAD branch' | cut -d' ' -f5) fi fi @@ -48,8 +63,10 @@ if [ -z "$PRIMARY_BRANCH" ]; then exit 1 fi +BASE_REF="origin/$PRIMARY_BRANCH" + # Get show-branch output for ahead/behind counts -SHOW_BRANCH=$(git show-branch --sha1-name HEAD "origin/$PRIMARY_BRANCH" 2>/dev/null) +SHOW_BRANCH=$(git show-branch --sha1-name HEAD "$BASE_REF" 2>/dev/null) if [ $? -ne 0 ]; then echo "ERROR: git show-branch failed" @@ -62,7 +79,7 @@ DIRTY_COUNT=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') # Compute line deltas (additions/deletions) vs merge-base with origin's primary branch. # # We emit *only* totals to keep output tiny (avoid output truncation in large repos). -MERGE_BASE=$(git merge-base HEAD "origin/$PRIMARY_BRANCH" 2>/dev/null || echo "") +MERGE_BASE=$(git merge-base HEAD "$BASE_REF" 2>/dev/null || echo "") # Outgoing: local changes vs merge-base (working tree vs base, includes uncommitted changes) OUTGOING_STATS="0 0" @@ -76,7 +93,7 @@ fi # Incoming: remote primary branch changes vs merge-base INCOMING_STATS="0 0" if [ -n "$MERGE_BASE" ]; then - INCOMING_STATS=$(git diff --numstat "$MERGE_BASE" "origin/$PRIMARY_BRANCH" 2>/dev/null | awk '{ if ($1 == "-" || $2 == "-") next; add += $1; del += $2 } END { printf "%d %d", add+0, del+0 }') + INCOMING_STATS=$(git diff --numstat "$MERGE_BASE" "$BASE_REF" 2>/dev/null | awk '{ if ($1 == "-" || $2 == "-") next; add += $1; del += $2 } END { printf "%d %d", add+0, del+0 }') if [ -z "$INCOMING_STATS" ]; then INCOMING_STATS="0 0" fi @@ -153,10 +170,10 @@ export function parseGitStatusScriptOutput(output: string): ParsedGitStatusOutpu * (e.g., IDE or user already fetched). * * Flow: - * 1. ls-remote to get remote SHA (no lock, network only) - * 2. cat-file to check if SHA exists locally (no lock) - * 3. If local: skip fetch (no lock needed) - * 4. If not local: fetch to get new commits (lock, but rare) + * 1. ls-remote --symref origin HEAD to get default branch + SHA (no lock, network only) + * 2. rev-parse to check local remote-tracking SHA (no lock) + * 3. If local already matches: skip fetch (no lock needed) + * 4. If not: fetch updates (lock, but rare) */ export const GIT_FETCH_SCRIPT = ` # Disable ALL prompts @@ -165,19 +182,13 @@ export GIT_ASKPASS=echo export SSH_ASKPASS=echo export GIT_SSH_COMMAND="\${GIT_SSH_COMMAND:-ssh} -o BatchMode=yes -o StrictHostKeyChecking=accept-new" -# Get primary branch name -PRIMARY_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') -if [ -z "$PRIMARY_BRANCH" ]; then - PRIMARY_BRANCH=$(git remote show origin 2>/dev/null | grep 'HEAD branch' | cut -d' ' -f5) -fi -if [ -z "$PRIMARY_BRANCH" ]; then - PRIMARY_BRANCH="main" -fi +# Determine remote default branch + SHA via ls-remote (no lock, network only) +REMOTE_INFO=$(git ls-remote --symref origin HEAD 2>/dev/null || echo "") +PRIMARY_BRANCH=$(printf '%s\n' "$REMOTE_INFO" | awk '$1=="ref:" && $3=="HEAD" {sub("^refs/heads/","",$2); print $2; exit}') +REMOTE_SHA=$(printf '%s\n' "$REMOTE_INFO" | awk '$2=="HEAD" && $1!="ref:" {print $1; exit}') -# Check remote SHA via ls-remote (no lock, network only) -REMOTE_SHA=$(git ls-remote origin "refs/heads/$PRIMARY_BRANCH" 2>/dev/null | cut -f1) -if [ -z "$REMOTE_SHA" ]; then - echo "SKIP: Could not get remote SHA" +if [ -z "$PRIMARY_BRANCH" ] || [ -z "$REMOTE_SHA" ]; then + echo "SKIP: Could not get remote HEAD" exit 0 fi diff --git a/src/common/utils/git/parseGitStatus.test.ts b/src/common/utils/git/parseGitStatus.test.ts index 3c0257b90d..93acc5f93b 100644 --- a/src/common/utils/git/parseGitStatus.test.ts +++ b/src/common/utils/git/parseGitStatus.test.ts @@ -1,4 +1,4 @@ -import { parseGitRevList } from "./parseGitStatus"; +import { parseGitRevList, parseGitShowBranchForStatus } from "./parseGitStatus"; // Base result shape with zero line deltas (parseGitRevList doesn't compute these) const base = { @@ -39,3 +39,56 @@ describe("parseGitRevList", () => { expect(parseGitRevList("\t")).toBe(null); }); }); + +function makeBehindCommitLines(count: number): string { + return Array.from({ length: count }, (_, i) => ` + [${i + 1}] behind commit ${i + 1}`).join("\n"); +} + +describe("parseGitShowBranchForStatus", () => { + test("parses 2-branch output correctly", () => { + const output = `! [HEAD] feat: add feature + ! [origin/main] fix: bug fix +-- ++ [cf9cbfb7] feat: add feature +++ [306f4968] fix: bug fix`; + + const result = parseGitShowBranchForStatus(output); + expect(result).not.toBeNull(); + expect(result!.ahead).toBe(1); + expect(result!.behind).toBe(0); + }); + + test("parses 2-branch output with behind commits", () => { + const output = `! [HEAD] feat: add feature + ! [origin/main] latest on main +-- ++ [cf9cbfb7] feat: add feature +${makeBehindCommitLines(9)} +++ [base] common ancestor`; + + const result = parseGitShowBranchForStatus(output); + expect(result).not.toBeNull(); + expect(result!.ahead).toBe(1); + expect(result!.behind).toBe(9); + }); + + test("handles 3-branch output (misuse case)", () => { + // This tests what happens if 3-branch output is accidentally fed to the parser. + // The parser uses columns 0 and 1, ignoring column 2. + const output = `! [HEAD] feat: add feature + ! [origin/main] fix: bug fix + ! [origin/feature] feat: add feature +--- ++ + [cf9cbfb7] feat: add feature + ++ [306f4968] fix: bug fix`; + + // With 3 columns: "+ +" means col0='+', col1=' ', col2='+' + // Parser sees col0='+', col1=' ' -> ahead + // With 3 columns: " ++" means col0=' ', col1='+', col2='+' + // Parser sees col0=' ', col1='+' -> behind + const result = parseGitShowBranchForStatus(output); + expect(result).not.toBeNull(); + expect(result!.ahead).toBe(1); // "+ +" has col0='+', col1=' ' + expect(result!.behind).toBe(1); // " ++" has col0=' ', col1='+' + }); +});