status: Show ahead/behind info for local branches

When viewing `repo status`, it is difficult to distinguish between branches that have active unpushed changes and stale branches that are fully synced. Previously, developers had to run commands like `repo forall -c "git status"` to see their ahead/behind counts.

This change updates Project.PrintWorkTreeStatus to automatically calculate and display the number of commits a branch is ahead and/or behind its upstream tracking branch. We use `git rev-list --left-right --count` to fetch this information natively and efficiently.

If the branch is completely synced with upstream, no extra text is shown.

Added tests for ahead-only, behind-only, diverged, no-tracking, and fully-synced branch states.

Bug: 319412954
Change-Id: I23879b2d472c7a7e11d01b565428a84b1b4f09c1
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/602423
Reviewed-by: Gavin Mak <gavinmak@google.com>
Tested-by: Brian Gan <brgan@google.com>
Commit-Queue: Brian Gan <brgan@google.com>
diff --git a/man/repo-status.1 b/man/repo-status.1
index 06cfae3..617548a 100644
--- a/man/repo-status.1
+++ b/man/repo-status.1
@@ -68,6 +68,15 @@
 \fB\-m\fR
 subcmds/status.py
 .PP
+If the branch is tracking an upstream branch, the number of commits ahead and/or
+behind is also shown:
+.TP
+project repo/
+branch devwork [ahead 1, behind 2]
+.TP
+\fB\-m\fR
+subcmds/status.py
+.PP
 The first column explains how the staging area (index) differs from the last
 commit (HEAD). Its values are always displayed in upper case and have the
 following meanings:
diff --git a/project.py b/project.py
index 0671940..ec645bb 100644
--- a/project.py
+++ b/project.py
@@ -996,11 +996,32 @@
             out.nl()
             return "DIRTY"
 
-        branch = self.CurrentBranch
-        if branch is None:
+        branch_name = self.CurrentBranch
+        if branch_name is None:
             out.nobranch("(*** NO BRANCH ***)")
         else:
-            out.branch("branch %s", branch)
+            branch_obj = self.GetBranch(branch_name)
+            ahead_behind = ""
+            try:
+                local_merge = branch_obj.LocalMerge
+                if local_merge:
+                    left_right = self.work_git.rev_list(
+                        "--left-right",
+                        "--count",
+                        f"{local_merge}...{R_HEADS}{branch_name}",
+                    )
+                    left, right = left_right[0].split()
+                    behind = int(left)
+                    ahead = int(right)
+                    if ahead and behind:
+                        ahead_behind = f" [ahead {ahead}, behind {behind}]"
+                    elif ahead:
+                        ahead_behind = f" [ahead {ahead}]"
+                    elif behind:
+                        ahead_behind = f" [behind {behind}]"
+            except GitError:
+                pass
+            out.branch("branch %s%s", branch_name, ahead_behind)
         out.nl()
 
         if rb:
diff --git a/subcmds/status.py b/subcmds/status.py
index a9852b3..1434eea 100644
--- a/subcmds/status.py
+++ b/subcmds/status.py
@@ -54,6 +54,12 @@
   project repo/                                   branch devwork
    -m     subcmds/status.py
 
+If the branch is tracking an upstream branch, the number of commits
+ahead and/or behind is also shown:
+
+  project repo/                           branch devwork [ahead 1, behind 2]
+   -m     subcmds/status.py
+
 The first column explains how the staging area (index) differs from
 the last commit (HEAD).  Its values are always displayed in upper
 case and have the following meanings:
diff --git a/tests/test_subcmds_status.py b/tests/test_subcmds_status.py
index 6007878..1501b34 100644
--- a/tests/test_subcmds_status.py
+++ b/tests/test_subcmds_status.py
@@ -107,6 +107,18 @@
     assert line == expected
 
 
+def _assert_project_header_with_ahead_behind(
+    line: str,
+    project_path: str,
+    branch: str,
+    ahead_behind: str,
+) -> None:
+    """Assert a status project header line includes ahead/behind info."""
+    suffix = f"branch {branch}{ahead_behind}"
+    expected = f"project {(project_path + '/ '):<40}{suffix}"
+    assert line == expected
+
+
 def _assert_orphan_block(lines: List[str], expected: List[str]) -> None:
     """Assert orphan block header and entries, independent of entry ordering."""
     assert lines
@@ -218,3 +230,224 @@
     lines = _status_lines(stdout.getvalue())
     assert len(lines) == 1
     _assert_project_header(lines[0], project_path, started_branch)
+
+
+def _setup_remote_tracking_branch(
+    manifest: manifest_xml.XmlManifest,
+    branch_name: str,
+) -> None:
+    """Create a branch tracking a remote ref, like ``repo start``.
+
+    In a real repo checkout, ``repo start`` creates a branch that
+    tracks a remote tracking ref (e.g. refs/remotes/origin/main).
+    This sets up the same config in both the worktree and the
+    project gitdir (where repo reads its config from).
+    """
+    proj = list(manifest.paths.values())[0]
+    worktree = Path(proj.worktree)
+    proj_gitdir = Path(proj.gitdir)
+
+    # Create the remote tracking ref in the worktree.
+    subprocess.check_call(
+        ["git", "update-ref", "refs/remotes/origin/main", "main"],
+        cwd=worktree,
+    )
+    # Create the new branch from main in the worktree.
+    subprocess.check_call(
+        ["git", "checkout", "-q", "-b", branch_name, "main"],
+        cwd=worktree,
+    )
+    # Write remote and branch config into the *project gitdir* config,
+    # which is where repo's Project.config reads from.
+    cfg = str(proj_gitdir / "config")
+    subprocess.check_call(
+        [
+            "git",
+            "config",
+            "-f",
+            cfg,
+            "remote.origin.url",
+            "http://localhost/fake",
+        ],
+    )
+    subprocess.check_call(
+        [
+            "git",
+            "config",
+            "-f",
+            cfg,
+            "remote.origin.fetch",
+            "+refs/heads/*:refs/remotes/origin/*",
+        ],
+    )
+    subprocess.check_call(
+        ["git", "config", "-f", cfg, f"branch.{branch_name}.remote", "origin"],
+    )
+    subprocess.check_call(
+        [
+            "git",
+            "config",
+            "-f",
+            cfg,
+            f"branch.{branch_name}.merge",
+            "refs/heads/main",
+        ],
+    )
+
+
+def test_status_branch_ahead_of_upstream(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Verify status shows [ahead N] for local commits."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+
+    _setup_remote_tracking_branch(manifest, "feature")
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "c1"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "c2"],
+        cwd=project_worktree,
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert len(lines) == 1
+    _assert_project_header_with_ahead_behind(
+        lines[0], project_path, "feature", " [ahead 2]"
+    )
+
+
+def test_status_branch_behind_upstream(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Verify status shows [behind N] when upstream is ahead."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+
+    _setup_remote_tracking_branch(manifest, "feature")
+    # Advance the remote tracking ref past the feature branch.
+    subprocess.check_call(
+        ["git", "checkout", "-q", "main"], cwd=project_worktree
+    )
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "upstream"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "update-ref", "refs/remotes/origin/main", "main"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "checkout", "-q", "feature"],
+        cwd=project_worktree,
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert len(lines) == 1
+    _assert_project_header_with_ahead_behind(
+        lines[0], project_path, "feature", " [behind 1]"
+    )
+
+
+def test_status_branch_ahead_and_behind(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Verify [ahead N, behind M] when branch has diverged."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+
+    _setup_remote_tracking_branch(manifest, "feature")
+    # Add a local commit on feature.
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "local"],
+        cwd=project_worktree,
+    )
+    # Advance the remote tracking ref independently.
+    subprocess.check_call(
+        ["git", "checkout", "-q", "main"], cwd=project_worktree
+    )
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "upstream"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "update-ref", "refs/remotes/origin/main", "main"],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "checkout", "-q", "feature"],
+        cwd=project_worktree,
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert len(lines) == 1
+    _assert_project_header_with_ahead_behind(
+        lines[0],
+        project_path,
+        "feature",
+        " [ahead 1, behind 1]",
+    )
+
+
+def test_status_branch_no_tracking_no_ahead_behind(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Verify no ahead/behind when branch has no upstream."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+    project_worktree = topdir / project_path
+
+    subprocess.check_call(
+        [
+            "git",
+            "checkout",
+            "-q",
+            "-b",
+            "orphan-branch",
+            "--no-track",
+            "main",
+        ],
+        cwd=project_worktree,
+    )
+    subprocess.check_call(
+        ["git", "commit", "-q", "--allow-empty", "-m", "c1"],
+        cwd=project_worktree,
+    )
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert len(lines) == 1
+    _assert_project_header(lines[0], project_path, "orphan-branch")
+
+
+def test_status_branch_synced_no_ahead_behind(
+    repo_client_checkout: Tuple[Path, manifest_xml.XmlManifest],
+) -> None:
+    """Verify no ahead/behind when branch is fully synced."""
+    topdir, manifest = repo_client_checkout
+    project_path = next(iter(manifest.paths.keys()))
+
+    _setup_remote_tracking_branch(manifest, "synced")
+
+    with contextlib.redirect_stdout(io.StringIO()) as stdout:
+        _run_status(manifest, [])
+
+    lines = _status_lines(stdout.getvalue())
+    assert len(lines) == 1
+    _assert_project_header(lines[0], project_path, "synced")