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")