Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions src/browser/components/hooks/useGitBranchDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/browser/stories/App.sidebar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function createGitStatusExecutor(gitStatus?: Map<string, GitStatusFixture>) {
}

// 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 });
}
Expand Down
75 changes: 43 additions & 32 deletions src/common/utils/git/gitStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,36 +9,51 @@
*
* @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
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

Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
55 changes: 54 additions & 1 deletion src/common/utils/git/parseGitStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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='+'
});
});
Loading