upload: add --ignore-remote-commits

When uploading a branch that merges another remote-tracking branch (such
as an upstream branch into the destination branch), ReviewableBranch
only excludes the destination tracking branch (^base). As a result, git
rev-list walks the merged parent and counts all merged commits even
though they already exist on a remote branch, triggering the unusual
commit count warning in _VerifyPendingCommits.

Add --ignore-remote-commits to `repo upload` to pass --not --remotes to
git rev-list in ReviewableBranch so commits already reachable from
refs/remotes/* are excluded from the pending commit list.

Test: ./run_tests tests/test_project.py tests/test_subcmds_upload.py
Change-Id: Ib1fb864982729e20a621144580fe95daa478eaee
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/651284
Tested-by: Romaric Jodin <rjodin@google.com>
Reviewed-by: Brian Gan <brgan@google.com>
Commit-Queue: Romaric Jodin <rjodin@google.com>
diff --git a/completion.zsh b/completion.zsh
index 2c37f46..9d6e871 100644
--- a/completion.zsh
+++ b/completion.zsh
@@ -411,6 +411,7 @@
         '(-y --yes)'{-y,--yes}'[Answer yes to all prompts]' \
         '--ignore-untracked-files[Ignore untracked files]' \
         '--no-ignore-untracked-files[Do not ignore untracked files]' \
+        '--ignore-remote-commits[Ignore commits already in remote branches]' \
         '--no-cert-checks[Do not check certificates]' \
         '--no-verify[Do not verify]' \
         '--verify[Verify]' \
diff --git a/man/repo-upload.1 b/man/repo-upload.1
index ad17b69..3d7abb9 100644
--- a/man/repo-upload.1
+++ b/man/repo-upload.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "August 2026" "repo upload" "Repo Manual"
+.TH REPO "1" "October 2026" "repo upload" "Repo Manual"
 .SH NAME
 repo \- repo upload - manual page for repo upload
 .SH SYNOPSIS
@@ -80,6 +80,9 @@
 \fB\-\-no\-ignore\-untracked\-files\fR
 always ask about untracked files in the working copy
 .TP
+\fB\-\-ignore\-remote\-commits\fR
+ignore commits already in remote branches
+.TP
 \fB\-\-no\-cert\-checks\fR
 disable verifying ssl certs (unsafe)
 .SS Logging options:
diff --git a/project.py b/project.py
index 13361d1..4b23e8a 100644
--- a/project.py
+++ b/project.py
@@ -36,6 +36,7 @@
 import error
 import fetch
 import git_command
+from git_config import Branch
 from git_config import GetSchemeFromUrl
 from git_config import GetUrlCookieFile
 from git_config import GitConfig
@@ -180,10 +181,17 @@
     _commit_cache = None
     _base_exists = None
 
-    def __init__(self, project, branch, base):
+    def __init__(
+        self,
+        project: "Project",
+        branch: Branch,
+        base: str,
+        ignore_remote_commits: bool = False,
+    ) -> None:
         self.project = project
         self.branch = branch
         self.base = base
+        self.ignore_remote_commits = ignore_remote_commits
 
     @property
     def name(self):
@@ -204,6 +212,7 @@
                 "--date-order",
                 not_rev(self.base),
                 git_refs.R_HEADS + self.name,
+                *(("--not", "--remotes") if self.ignore_remote_commits else ()),
                 "--",
             )
             try:
@@ -226,7 +235,10 @@
     def unabbrev_commits(self):
         r = {}
         for commit in self.project.bare_git.rev_list(
-            not_rev(self.base), git_refs.R_HEADS + self.name, "--"
+            not_rev(self.base),
+            git_refs.R_HEADS + self.name,
+            *(("--not", "--remotes") if self.ignore_remote_commits else ()),
+            "--",
         ):
             r[commit[0:8]] = commit
         return r
@@ -1424,7 +1436,11 @@
             if git_refs.R_HEADS + n not in heads:
                 self.bare_git.DeleteRef(name, ref_id)
 
-    def GetUploadableBranches(self, selected_branch=None):
+    def GetUploadableBranches(
+        self,
+        selected_branch: Optional[str] = None,
+        ignore_remote_commits: bool = False,
+    ) -> List[ReviewableBranch]:
         """List any branches which can be uploaded for review."""
         if selected_branch:
             branch = self.GetBranch(selected_branch)
@@ -1436,7 +1452,9 @@
             pub_id = self.bare_ref.get(git_refs.R_PUB + selected_branch)
             if pub_id and pub_id == head_id:
                 return []
-            rb = self.GetUploadableBranch(selected_branch)
+            rb = self.GetUploadableBranch(
+                selected_branch, ignore_remote_commits=ignore_remote_commits
+            )
             if rb:
                 rb.branch.current = selected_branch == self.CurrentBranch
                 return [rb]
@@ -1454,18 +1472,26 @@
             if branch_config.published == branch_config.revision:
                 continue
 
-            rb = self.GetUploadableBranch(branch)
+            rb = self.GetUploadableBranch(
+                branch, ignore_remote_commits=ignore_remote_commits
+            )
             if rb:
                 rb.branch.current = branch_config.current
                 ready.append(rb)
         return ready
 
-    def GetUploadableBranch(self, branch_name):
+    def GetUploadableBranch(
+        self,
+        branch_name: Optional[str],
+        ignore_remote_commits: bool = False,
+    ) -> Optional[ReviewableBranch]:
         """Get a single uploadable branch, or None."""
         branch = self.GetBranch(branch_name)
         base = branch.LocalMerge
         if branch.LocalMerge:
-            rb = ReviewableBranch(self, branch, base)
+            rb = ReviewableBranch(
+                self, branch, base, ignore_remote_commits=ignore_remote_commits
+            )
             if rb.commits:
                 return rb
         return None
diff --git a/subcmds/upload.py b/subcmds/upload.py
index bb4390d..2484869 100644
--- a/subcmds/upload.py
+++ b/subcmds/upload.py
@@ -370,6 +370,12 @@
             help="always ask about untracked files in the working copy",
         )
         p.add_option(
+            "--ignore-remote-commits",
+            action="store_true",
+            default=False,
+            help="ignore commits already in remote branches",
+        )
+        p.add_option(
             "--no-cert-checks",
             dest="validate_certs",
             action="store_false",
@@ -738,10 +744,14 @@
         cbr = None
         if opt.current_branch:
             cbr = project.CurrentBranch
-            up_branch = project.GetUploadableBranch(cbr)
+            up_branch = project.GetUploadableBranch(
+                cbr, ignore_remote_commits=opt.ignore_remote_commits
+            )
             avail = [up_branch] if up_branch else None
         else:
-            avail = project.GetUploadableBranches(opt.branch)
+            avail = project.GetUploadableBranches(
+                opt.branch, ignore_remote_commits=opt.ignore_remote_commits
+            )
         return (project_idx, avail, cbr)
 
     def ValidateOptions(self, opt: optparse.Values, args: List[str]) -> None:
diff --git a/tests/test_project.py b/tests/test_project.py
index f65e83f..82a90a6 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -137,6 +137,48 @@
 
         self.assertTrue(rb.current)
 
+    def test_ignore_remote_commits(self) -> None:
+        """Check ignore_remote_commits excludes commits in refs/remotes/*."""
+        with utils_for_test.TempGitTree() as tempdir:
+            fakeproj = FakeProject(tempdir)
+
+            with open(os.path.join(tempdir, "readme"), "w") as fp:
+                fp.write("init")
+            fakeproj.work_git.add("readme")
+            fakeproj.work_git.commit("-mInit")
+
+            # Create an upstream commit and record it under refs/remotes/.
+            fakeproj.work_git.checkout("-b", "upstream")
+            with open(os.path.join(tempdir, "upstream.txt"), "w") as fp:
+                fp.write("upstream")
+            fakeproj.work_git.add("upstream.txt")
+            fakeproj.work_git.commit("-mUpstream change")
+            fakeproj.work_git.update_ref(
+                "refs/remotes/upstream/main", "refs/heads/upstream"
+            )
+
+            # Merge the remote branch into work on top of main.
+            fakeproj.work_git.checkout("-b", "work", "main")
+            fakeproj.work_git.merge(
+                "--no-ff", "-mMerge upstream", "refs/remotes/upstream/main"
+            )
+
+            rb_default = project.ReviewableBranch(
+                fakeproj, fakeproj.config.GetBranch("work"), "main"
+            )
+            self.assertEqual(2, len(rb_default.commits))
+            self.assertEqual(2, len(rb_default.unabbrev_commits))
+
+            rb_ignored = project.ReviewableBranch(
+                fakeproj,
+                fakeproj.config.GetBranch("work"),
+                "main",
+                ignore_remote_commits=True,
+            )
+            self.assertEqual(1, len(rb_ignored.commits))
+            self.assertIn("Merge upstream", rb_ignored.commits[0])
+            self.assertEqual(1, len(rb_ignored.unabbrev_commits))
+
 
 class ProjectTests(unittest.TestCase):
     """Check Project behavior."""
@@ -1277,7 +1319,9 @@
             ) as mock_get_up:
                 res = proj.GetUploadableBranches("feature")
                 self.assertEqual(res, [mock_rb])
-                mock_get_up.assert_called_once_with("feature")
+                mock_get_up.assert_called_once_with(
+                    "feature", ignore_remote_commits=False
+                )
 
     def _get_derived_subproject_url(self, submodule_url):
         with tempfile.TemporaryDirectory(prefix="repo-tests") as tempdir:
diff --git a/tests/test_subcmds_upload.py b/tests/test_subcmds_upload.py
index 4c9008f..53cc651 100644
--- a/tests/test_subcmds_upload.py
+++ b/tests/test_subcmds_upload.py
@@ -116,11 +116,46 @@
         "get_parallel_context",
         lambda: {"projects": [project]},
     )
-    opt = mock.MagicMock(current_branch=True)
+    opt = mock.MagicMock(current_branch=True, ignore_remote_commits=False)
 
     assert upload.Upload._GatherOne(opt, 0) == (0, [branch], "topic")
 
-    project.GetUploadableBranch.assert_called_once_with("topic")
+    project.GetUploadableBranch.assert_called_once_with(
+        "topic", ignore_remote_commits=False
+    )
+
+
+def test_GatherOne_forwards_ignore_remote_commits(
+    cmd: upload.Upload,
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    """_GatherOne forwards --ignore-remote-commits to project methods."""
+    project = mock.MagicMock()
+    project.CurrentBranch = "topic"
+    branch = mock.sentinel.branch
+    project.GetUploadableBranch.return_value = branch
+    project.GetUploadableBranches.return_value = [branch]
+    monkeypatch.setattr(
+        upload.Upload,
+        "get_parallel_context",
+        lambda: {"projects": [project]},
+    )
+
+    opt_cbr, _ = cmd.OptionParser.parse_args(
+        ["--cbr", "--ignore-remote-commits"]
+    )
+    assert upload.Upload._GatherOne(opt_cbr, 0) == (0, [branch], "topic")
+    project.GetUploadableBranch.assert_called_once_with(
+        "topic", ignore_remote_commits=True
+    )
+
+    opt_br, _ = cmd.OptionParser.parse_args(
+        ["--br", "topic", "--ignore-remote-commits"]
+    )
+    assert upload.Upload._GatherOne(opt_br, 0) == (0, [branch], None)
+    project.GetUploadableBranches.assert_called_once_with(
+        "topic", ignore_remote_commits=True
+    )
 
 
 def _create_mock_branch(