Merge tag 'v2.46'

* tag 'v2.46':
  project: ignore more curl failure modes
  logging: Fix log formatting with colored output
  upload: add a --topic option for setting topic explicitly
  sync: Abort rebase in progress if force-checkout is set
  ssh: Set git protocol version 2 on SSH ControlMaster
  upload: drop check for uncommitted local changes
  git: raise hard version to 1.9.1
  release: update-hooks: helper for automatically syncing hooks
  gitc: delete a few more dead references
  man: regenerate man pages
  Remove platform_utils.realpath
  Fix drive mounted directory on Windows
  git_command: unify soft/hard versions with requirements.json

Change-Id: I40ea02e54c0106265a34f88c65398c7519325b01
diff --git a/git_command.py b/git_command.py
index 09ed1a7..1ec7c3e 100644
--- a/git_command.py
+++ b/git_command.py
@@ -33,17 +33,6 @@
 
 
 GIT = "git"
-# NB: These do not need to be kept in sync with the repo launcher script.
-# These may be much newer as it allows the repo launcher to roll between
-# different repo releases while source versions might require a newer git.
-#
-# The soft version is when we start warning users that the version is old and
-# we'll be dropping support for it.  We'll refuse to work with versions older
-# than the hard version.
-#
-# git-1.7 is in (EOL) Ubuntu Precise.  git-1.9 is in Ubuntu Trusty.
-MIN_GIT_VERSION_SOFT = (1, 9, 1)
-MIN_GIT_VERSION_HARD = (1, 7, 2)
 GIT_DIR = "GIT_DIR"
 
 LAST_GITDIR = None
diff --git a/hooks/commit-msg b/hooks/commit-msg
index 112df63..a6721d4 100755
--- a/hooks/commit-msg
+++ b/hooks/commit-msg
@@ -1,5 +1,8 @@
 #!/bin/sh
-# From Gerrit Code Review 3.10.0 d5403dbf335ba7d48977fc95170c3f7027c34659
+# DO NOT EDIT THIS FILE
+# All updates should be sent upstream: https://gerrit.googlesource.com/gerrit/
+# This is synced from commit: 62f5bbea67f6dafa6e22a601a0c298214c510caf
+# DO NOT EDIT THIS FILE
 #
 # Part of Gerrit Code Review (https://www.gerritcodereview.com/)
 #
@@ -31,8 +34,7 @@
 fi
 
 # Do not create a change id if requested
-create_setting=$(git config --get gerrit.createChangeId)
-case "$create_setting" in
+case "$(git config --get gerrit.createChangeId)" in
   false)
     exit 0
     ;;
diff --git a/hooks/pre-auto-gc b/hooks/pre-auto-gc
index ec29be4..fb7b763 100755
--- a/hooks/pre-auto-gc
+++ b/hooks/pre-auto-gc
@@ -1,33 +1,25 @@
 #!/bin/sh
+# DO NOT EDIT THIS FILE
+# All updates should be sent upstream: https://github.com/git/git
+# This is synced from commit: 00e10ef10e161a913893b8cb33aa080d4ca5baa6
+# DO NOT EDIT THIS FILE
 #
 # An example hook script to verify if you are on battery, in case you
-# are running Windows, Linux or OS X. Called by git-gc --auto with no
-# arguments. The hook should exit with non-zero status after issuing an
-# appropriate message if it wants to stop the auto repacking.
-
-# This program is free software; you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation; either version 2 of the License, or
-# (at your option) any later version.
+# are running Linux or OS X. Called by git-gc --auto with no arguments.
+# The hook should exit with non-zero status after issuing an appropriate
+# message if it wants to stop the auto repacking.
 #
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU General Public License for more details.
+# This hook is stored in the contrib/hooks directory. Your distribution
+# may have put this somewhere else. If you want to use this hook, you
+# should make this script executable then link to it in the repository
+# you would like to use it in.
 #
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
-
-if uname -s | grep -q "_NT-"
-then
-	if test -x $SYSTEMROOT/System32/Wbem/wmic
-	then
-		STATUS=$(wmic path win32_battery get batterystatus /format:list | tr -d '\r\n')
-		[ "$STATUS" = "BatteryStatus=2" ] && exit 0 || exit 1
-	fi
-	exit 0
-fi
+# For example, if the hook is stored in
+# /usr/share/git-core/contrib/hooks/pre-auto-gc-battery:
+#
+# cd /path/to/your/repository.git
+# ln -sf /usr/share/git-core/contrib/hooks/pre-auto-gc-battery \
+#	hooks/pre-auto-gc
 
 if test -x /sbin/on_ac_power && (/sbin/on_ac_power;test $? -ne 1)
 then
@@ -48,11 +40,6 @@
 	grep -q "drawing from 'AC Power'"
 then
 	exit 0
-elif test -d /sys/bus/acpi/drivers/battery && test 0 = \
-  "$(find /sys/bus/acpi/drivers/battery/ -type l | wc -l)";
-then
-	# No battery exists.
-	exit 0
 fi
 
 echo "Auto packing deferred; not on AC"
diff --git a/man/repo-gitc-delete.1 b/man/repo-gitc-delete.1
deleted file mode 100644
index 6158bd3..0000000
--- a/man/repo-gitc-delete.1
+++ /dev/null
@@ -1,44 +0,0 @@
-.\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "July 2022" "repo gitc-delete" "Repo Manual"
-.SH NAME
-repo \- repo gitc-delete - manual page for repo gitc-delete
-.SH SYNOPSIS
-.B repo
-\fI\,gitc-delete\/\fR
-.SH DESCRIPTION
-Summary
-.PP
-Delete a GITC Client.
-.SH OPTIONS
-.TP
-\fB\-h\fR, \fB\-\-help\fR
-show this help message and exit
-.TP
-\fB\-f\fR, \fB\-\-force\fR
-force the deletion (no prompt)
-.SS Logging options:
-.TP
-\fB\-v\fR, \fB\-\-verbose\fR
-show all output
-.TP
-\fB\-q\fR, \fB\-\-quiet\fR
-only show errors
-.SS Multi\-manifest options:
-.TP
-\fB\-\-outer\-manifest\fR
-operate starting at the outermost manifest
-.TP
-\fB\-\-no\-outer\-manifest\fR
-do not operate on outer manifests
-.TP
-\fB\-\-this\-manifest\-only\fR
-only operate on this (sub)manifest
-.TP
-\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
-operate on this manifest and its submanifests
-.PP
-Run `repo help gitc\-delete` to view the detailed manual.
-.SH DETAILS
-.PP
-This subcommand deletes the current GITC client, deleting the GITC manifest and
-all locally downloaded sources.
diff --git a/man/repo-gitc-init.1 b/man/repo-gitc-init.1
deleted file mode 100644
index 02581c6..0000000
--- a/man/repo-gitc-init.1
+++ /dev/null
@@ -1,175 +0,0 @@
-.\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "October 2022" "repo gitc-init" "Repo Manual"
-.SH NAME
-repo \- repo gitc-init - manual page for repo gitc-init
-.SH SYNOPSIS
-.B repo
-\fI\,gitc-init \/\fR[\fI\,options\/\fR] [\fI\,client name\/\fR]
-.SH DESCRIPTION
-Summary
-.PP
-Initialize a GITC Client.
-.SH OPTIONS
-.TP
-\fB\-h\fR, \fB\-\-help\fR
-show this help message and exit
-.SS Logging options:
-.TP
-\fB\-v\fR, \fB\-\-verbose\fR
-show all output
-.TP
-\fB\-q\fR, \fB\-\-quiet\fR
-only show errors
-.SS Manifest options:
-.TP
-\fB\-u\fR URL, \fB\-\-manifest\-url\fR=\fI\,URL\/\fR
-manifest repository location
-.TP
-\fB\-b\fR REVISION, \fB\-\-manifest\-branch\fR=\fI\,REVISION\/\fR
-manifest branch or revision (use HEAD for default)
-.TP
-\fB\-m\fR NAME.xml, \fB\-\-manifest\-name\fR=\fI\,NAME\/\fR.xml
-initial manifest file
-.TP
-\fB\-g\fR GROUP, \fB\-\-groups\fR=\fI\,GROUP\/\fR
-restrict manifest projects to ones with specified
-group(s) [default|all|G1,G2,G3|G4,\-G5,\-G6]
-.TP
-\fB\-p\fR PLATFORM, \fB\-\-platform\fR=\fI\,PLATFORM\/\fR
-restrict manifest projects to ones with a specified
-platform group [auto|all|none|linux|darwin|...]
-.TP
-\fB\-\-submodules\fR
-sync any submodules associated with the manifest repo
-.TP
-\fB\-\-standalone\-manifest\fR
-download the manifest as a static file rather then
-create a git checkout of the manifest repo
-.TP
-\fB\-\-manifest\-depth\fR=\fI\,DEPTH\/\fR
-create a shallow clone of the manifest repo with given
-depth (0 for full clone); see git clone (default: 0)
-.SS Manifest (only) checkout options:
-.TP
-\fB\-\-current\-branch\fR
-fetch only current manifest branch from server
-(default)
-.TP
-\fB\-\-no\-current\-branch\fR
-fetch all manifest branches from server
-.TP
-\fB\-\-tags\fR
-fetch tags in the manifest
-.TP
-\fB\-\-no\-tags\fR
-don't fetch tags in the manifest
-.SS Checkout modes:
-.TP
-\fB\-\-mirror\fR
-create a replica of the remote repositories rather
-than a client working directory
-.TP
-\fB\-\-archive\fR
-checkout an archive instead of a git repository for
-each project. See git archive.
-.TP
-\fB\-\-worktree\fR
-use git\-worktree to manage projects
-.SS Project checkout optimizations:
-.TP
-\fB\-\-reference\fR=\fI\,DIR\/\fR
-location of mirror directory
-.TP
-\fB\-\-dissociate\fR
-dissociate from reference mirrors after clone
-.TP
-\fB\-\-depth\fR=\fI\,DEPTH\/\fR
-create a shallow clone with given depth; see git clone
-.TP
-\fB\-\-partial\-clone\fR
-perform partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
-.TP
-\fB\-\-no\-partial\-clone\fR
-disable use of partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
-.TP
-\fB\-\-partial\-clone\-exclude\fR=\fI\,PARTIAL_CLONE_EXCLUDE\/\fR
-exclude the specified projects (a comma\-delimited
-project names) from partial clone (https://gitscm.com/docs/gitrepositorylayout#_code_partialclone_code)
-.TP
-\fB\-\-clone\-filter\fR=\fI\,CLONE_FILTER\/\fR
-filter for use with \fB\-\-partial\-clone\fR [default:
-blob:none]
-.TP
-\fB\-\-use\-superproject\fR
-use the manifest superproject to sync projects;
-implies \fB\-c\fR
-.TP
-\fB\-\-no\-use\-superproject\fR
-disable use of manifest superprojects
-.TP
-\fB\-\-clone\-bundle\fR
-enable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if
-not \fB\-\-partial\-clone\fR)
-.TP
-\fB\-\-no\-clone\-bundle\fR
-disable use of \fI\,/clone.bundle\/\fP on HTTP/HTTPS (default if
-\fB\-\-partial\-clone\fR)
-.TP
-\fB\-\-git\-lfs\fR
-enable Git LFS support
-.TP
-\fB\-\-no\-git\-lfs\fR
-disable Git LFS support
-.SS repo Version options:
-.TP
-\fB\-\-repo\-url\fR=\fI\,URL\/\fR
-repo repository location ($REPO_URL)
-.TP
-\fB\-\-repo\-rev\fR=\fI\,REV\/\fR
-repo branch or revision ($REPO_REV)
-.TP
-\fB\-\-no\-repo\-verify\fR
-do not verify repo source code
-.SS Other options:
-.TP
-\fB\-\-config\-name\fR
-Always prompt for name/e\-mail
-.SS GITC options:
-.TP
-\fB\-f\fR MANIFEST_FILE, \fB\-\-manifest\-file\fR=\fI\,MANIFEST_FILE\/\fR
-Optional manifest file to use for this GITC client.
-.TP
-\fB\-c\fR GITC_CLIENT, \fB\-\-gitc\-client\fR=\fI\,GITC_CLIENT\/\fR
-Name of the gitc_client instance to create or modify.
-.SS Multi\-manifest:
-.TP
-\fB\-\-outer\-manifest\fR
-operate starting at the outermost manifest
-.TP
-\fB\-\-no\-outer\-manifest\fR
-do not operate on outer manifests
-.TP
-\fB\-\-this\-manifest\-only\fR
-only operate on this (sub)manifest
-.TP
-\fB\-\-no\-this\-manifest\-only\fR, \fB\-\-all\-manifests\fR
-operate on this manifest and its submanifests
-.PP
-Run `repo help gitc\-init` to view the detailed manual.
-.SH DETAILS
-.PP
-The 'repo gitc\-init' command is ran to initialize a new GITC client for use with
-the GITC file system.
-.PP
-This command will setup the client directory, initialize repo, just like repo
-init does, and then downloads the manifest collection and installs it in the
-\&.repo/directory of the GITC client.
-.PP
-Once this is done, a GITC manifest is generated by pulling the HEAD SHA for each
-project and generates the properly formatted XML file and installs it as
-\&.manifest in the GITC client directory.
-.PP
-The \fB\-c\fR argument is required to specify the GITC client name.
-.PP
-The optional \fB\-f\fR argument can be used to specify the manifest file to use for
-this GITC client.
diff --git a/man/repo-manifest.1 b/man/repo-manifest.1
index e49836c..10ec2e7 100644
--- a/man/repo-manifest.1
+++ b/man/repo-manifest.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "October 2022" "repo manifest" "Repo Manual"
+.TH REPO "1" "April 2024" "repo manifest" "Repo Manual"
 .SH NAME
 repo \- repo manifest - manual page for repo manifest
 .SH SYNOPSIS
@@ -194,8 +194,9 @@
 <!ATTLIST extend\-project upstream CDATA #IMPLIED>
 .IP
 <!ELEMENT remove\-project EMPTY>
-<!ATTLIST remove\-project name  CDATA #REQUIRED>
-<!ATTLIST remove\-project optional  CDATA #IMPLIED>
+<!ATTLIST remove\-project name     CDATA #IMPLIED>
+<!ATTLIST remove\-project path     CDATA #IMPLIED>
+<!ATTLIST remove\-project optional CDATA #IMPLIED>
 .IP
 <!ELEMENT repo\-hooks EMPTY>
 <!ATTLIST repo\-hooks in\-project CDATA #REQUIRED>
@@ -210,8 +211,9 @@
 <!ATTLIST contactinfo bugurl  CDATA #REQUIRED>
 .IP
 <!ELEMENT include EMPTY>
-<!ATTLIST include name   CDATA #REQUIRED>
-<!ATTLIST include groups CDATA #IMPLIED>
+<!ATTLIST include name     CDATA #REQUIRED>
+<!ATTLIST include groups   CDATA #IMPLIED>
+<!ATTLIST include revision CDATA #IMPLIED>
 .PP
 ]>
 ```
@@ -533,13 +535,24 @@
 .PP
 Element remove\-project
 .PP
-Deletes the named project from the internal manifest table, possibly allowing a
+Deletes a project from the internal manifest table, possibly allowing a
 subsequent project element in the same manifest file to replace the project with
 a different source.
 .PP
 This element is mostly useful in a local manifest file, where the user can
 remove a project, and possibly replace it with their own definition.
 .PP
+The project `name` or project `path` can be used to specify the remove target
+meaning one of them is required. If only name is specified, all projects with
+that name are removed.
+.PP
+If both name and path are specified, only projects with the same name and path
+are removed, meaning projects with the same name but in other locations are
+kept.
+.PP
+If only path is specified, a matching project is removed regardless of its name.
+Logic otherwise behaves like both are specified.
+.PP
 Attribute `optional`: Set to true to ignore remove\-project elements with no
 matching `project` element.
 .PP
@@ -608,7 +621,10 @@
 included manifests carry all parent include groups. Same syntax as the
 corresponding element of `project`.
 .PP
-Local Manifests 
+Attribute `revision`: Name of a Git branch (e.g. `main` or `refs/heads/main`)
+default to which all projects in the included manifest belong.
+.PP
+Local Manifests
 .PP
 Additional remotes and projects may be added through local manifest files stored
 in `$TOP_DIR/.repo/local_manifests/*.xml`.
diff --git a/man/repo-smartsync.1 b/man/repo-smartsync.1
index c1abbb3..1e97910 100644
--- a/man/repo-smartsync.1
+++ b/man/repo-smartsync.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "November 2022" "repo smartsync" "Repo Manual"
+.TH REPO "1" "April 2024" "repo smartsync" "Repo Manual"
 .SH NAME
 repo \- repo smartsync - manual page for repo smartsync
 .SH SYNOPSIS
@@ -37,6 +37,11 @@
 point to a different object directory. WARNING: this
 may cause loss of data
 .TP
+\fB\-\-force\-checkout\fR
+force checkout even if it results in throwing away
+uncommitted modifications. WARNING: this may cause
+loss of data
+.TP
 \fB\-\-force\-remove\-dirty\fR
 force remove projects with uncommitted modifications
 if projects no longer exist in the manifest. WARNING:
diff --git a/man/repo-sync.1 b/man/repo-sync.1
index f006c03..0327942 100644
--- a/man/repo-sync.1
+++ b/man/repo-sync.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "November 2022" "repo sync" "Repo Manual"
+.TH REPO "1" "April 2024" "repo sync" "Repo Manual"
 .SH NAME
 repo \- repo sync - manual page for repo sync
 .SH SYNOPSIS
@@ -37,6 +37,11 @@
 point to a different object directory. WARNING: this
 may cause loss of data
 .TP
+\fB\-\-force\-checkout\fR
+force checkout even if it results in throwing away
+uncommitted modifications. WARNING: this may cause
+loss of data
+.TP
 \fB\-\-force\-remove\-dirty\fR
 force remove projects with uncommitted modifications
 if projects no longer exist in the manifest. WARNING:
@@ -185,6 +190,11 @@
 they have previously been linked to a different object directory. WARNING: This
 may cause data to be lost since refs may be removed when overwriting.
 .PP
+The \fB\-\-force\-checkout\fR option can be used to force git to switch revs even if the
+index or the working tree differs from HEAD, and if there are untracked files.
+WARNING: This may cause data to be lost since uncommitted changes may be
+removed.
+.PP
 The \fB\-\-force\-remove\-dirty\fR option can be used to remove previously used projects
 with uncommitted changes. WARNING: This may cause data to be lost since
 uncommitted changes may be removed with projects that no longer exist in the
diff --git a/man/repo-upload.1 b/man/repo-upload.1
index b8f6677..c0ae581 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 2022" "repo upload" "Repo Manual"
+.TH REPO "1" "June 2024" "repo upload" "Repo Manual"
 .SH NAME
 repo \- repo upload - manual page for repo upload
 .SH SYNOPSIS
@@ -18,8 +18,11 @@
 number of jobs to run in parallel (default: based on
 number of CPU cores)
 .TP
-\fB\-t\fR
-send local branch name to Gerrit Code Review
+\fB\-t\fR, \fB\-\-topic\-branch\fR
+set the topic to the local branch name
+.TP
+\fB\-\-topic\fR=\fI\,TOPIC\/\fR
+set topic for the change
 .TP
 \fB\-\-hashtag\fR=\fI\,HASHTAGS\/\fR, \fB\-\-ht\fR=\fI\,HASHTAGS\/\fR
 add hashtags (comma delimited) to the review
@@ -30,6 +33,9 @@
 \fB\-l\fR LABELS, \fB\-\-label\fR=\fI\,LABELS\/\fR
 add a label when uploading
 .TP
+\fB\-\-pd\fR=\fI\,PATCHSET_DESCRIPTION\/\fR, \fB\-\-patchset\-description\fR=\fI\,PATCHSET_DESCRIPTION\/\fR
+description for patchset
+.TP
 \fB\-\-re\fR=\fI\,REVIEWERS\/\fR, \fB\-\-reviewers\fR=\fI\,REVIEWERS\/\fR
 request reviews from these people
 .TP
@@ -198,6 +204,12 @@
 Control e\-mail notifications when uploading.
 https://gerrit\-review.googlesource.com/Documentation/user\-upload.html#notify
 .PP
+review.URL.uploadwarningthreshold:
+.PP
+Repo will warn you if you are attempting to upload a large number of commits in
+one or more branches. By default, the threshold is five commits. This option
+allows you to override the warning threshold to a different value.
+.PP
 References
 .PP
 Gerrit Code Review: https://www.gerritcodereview.com/
diff --git a/man/repo.1 b/man/repo.1
index 46425c6..bda68c3 100644
--- a/man/repo.1
+++ b/man/repo.1
@@ -1,5 +1,5 @@
 .\" DO NOT MODIFY THIS FILE!  It was generated by help2man.
-.TH REPO "1" "June 2023" "repo" "Repo Manual"
+.TH REPO "1" "April 2024" "repo" "Repo Manual"
 .SH NAME
 repo \- repository management tool built on top of git
 .SH SYNOPSIS
@@ -79,12 +79,6 @@
 forall
 Run a shell command in each project
 .TP
-gitc\-delete
-Delete a GITC Client.
-.TP
-gitc\-init
-Initialize a GITC Client.
-.TP
 grep
 Print lines matching a pattern
 .TP
diff --git a/manifest_xml.py b/manifest_xml.py
index 4f75212..b26b0ca 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -435,11 +435,6 @@
         self.parent_groups = parent_groups
         self.default_groups = default_groups
 
-        if outer_client and self.isGitcClient:
-            raise ManifestParseError(
-                "Multi-manifest is incompatible with `gitc-init`"
-            )
-
         if submanifest_path and not outer_client:
             # If passing a submanifest_path, there must be an outer_client.
             raise ManifestParseError(f"Bad call to {self.__class__.__name__}")
@@ -2290,7 +2285,6 @@
             submanifest_path: The submanifest root relative to the repo root.
             **kwargs: Additional keyword arguments, passed to XmlManifest.
         """
-        self.isGitcClient = False
         submanifest_path = submanifest_path or ""
         if submanifest_path:
             self._CheckLocalPath(submanifest_path)
diff --git a/platform_utils.py b/platform_utils.py
index 4cf994b..e20198e 100644
--- a/platform_utils.py
+++ b/platform_utils.py
@@ -251,32 +251,3 @@
         return platform_utils_win32.readlink(_makelongpath(path))
     else:
         return os.readlink(path)
-
-
-def realpath(path):
-    """Return the canonical path of the specified filename, eliminating
-    any symbolic links encountered in the path.
-
-    Availability: Windows, Unix.
-    """
-    if isWindows():
-        current_path = os.path.abspath(path)
-        path_tail = []
-        for c in range(0, 100):  # Avoid cycles
-            if islink(current_path):
-                target = readlink(current_path)
-                current_path = os.path.join(
-                    os.path.dirname(current_path), target
-                )
-            else:
-                basename = os.path.basename(current_path)
-                if basename == "":
-                    path_tail.append(current_path)
-                    break
-                path_tail.append(basename)
-                current_path = os.path.dirname(current_path)
-        path_tail.reverse()
-        result = os.path.normpath(os.path.join(*path_tail))
-        return result
-    else:
-        return os.path.realpath(path)
diff --git a/project.py b/project.py
index 9563e7d..4aeee62 100644
--- a/project.py
+++ b/project.py
@@ -148,7 +148,7 @@
     """
     global _project_hook_list
     if _project_hook_list is None:
-        d = platform_utils.realpath(os.path.abspath(os.path.dirname(__file__)))
+        d = os.path.realpath(os.path.abspath(os.path.dirname(__file__)))
         d = os.path.join(d, "hooks")
         _project_hook_list = [
             os.path.join(d, x) for x in platform_utils.listdir(d)
@@ -260,7 +260,7 @@
         self,
         people,
         dryrun=False,
-        auto_topic=False,
+        topic=None,
         hashtags=(),
         labels=(),
         private=False,
@@ -276,7 +276,7 @@
             branch=self.name,
             people=people,
             dryrun=dryrun,
-            auto_topic=auto_topic,
+            topic=topic,
             hashtags=hashtags,
             labels=labels,
             private=private,
@@ -730,12 +730,34 @@
         return None
 
     def IsRebaseInProgress(self):
+        """Returns true if a rebase or "am" is in progress"""
+        # "rebase-apply" is used for "git rebase".
+        # "rebase-merge" is used for "git am".
         return (
             os.path.exists(self.work_git.GetDotgitPath("rebase-apply"))
             or os.path.exists(self.work_git.GetDotgitPath("rebase-merge"))
             or os.path.exists(os.path.join(self.worktree, ".dotest"))
         )
 
+    def IsCherryPickInProgress(self):
+        """Returns True if a cherry-pick is in progress."""
+        return os.path.exists(self.work_git.GetDotgitPath("CHERRY_PICK_HEAD"))
+
+    def _AbortRebase(self):
+        """Abort ongoing rebase, cherry-pick or patch apply (am).
+
+        If no rebase, cherry-pick or patch apply was in progress, this method
+        ignores the status and continues.
+        """
+
+        def _git(*args):
+            # Ignore return code, in case there was no rebase in progress.
+            GitCommand(self, *args, log_as_error=False).Wait()
+
+        _git("cherry-pick", "--abort")
+        _git("rebase", "--abort")
+        _git("am", "--abort")
+
     def IsDirty(self, consider_untracked=True):
         """Is the working directory modified in some way?"""
         self.work_git.update_index(
@@ -1085,7 +1107,7 @@
         branch=None,
         people=([], []),
         dryrun=False,
-        auto_topic=False,
+        topic=None,
         hashtags=(),
         labels=(),
         private=False,
@@ -1148,8 +1170,7 @@
         # This stops git from pushing all reachable annotated tags when
         # push.followTags is configured. Gerrit does not accept any tags
         # pushed to a CL.
-        if git_require((1, 8, 3)):
-            cmd.append("--no-follow-tags")
+        cmd.append("--no-follow-tags")
 
         for push_option in push_options or []:
             cmd.append("-o")
@@ -1162,8 +1183,8 @@
 
         ref_spec = f"{R_HEADS + branch.name}:refs/for/{dest_branch}"
         opts = []
-        if auto_topic:
-            opts += ["topic=" + branch.name]
+        if topic is not None:
+            opts += [f"topic={topic}"]
         opts += ["t=%s" % p for p in hashtags]
         # NB: No need to encode labels as they've been validated above.
         opts += ["l=%s" % p for p in labels]
@@ -1558,8 +1579,6 @@
         self._InitHooks()
 
     def _CopyAndLinkFiles(self):
-        if self.client.isGitcClient:
-            return
         for copyfile in self.copyfiles:
             copyfile._Copy()
         for linkfile in self.linkfiles:
@@ -1682,7 +1701,15 @@
         if branch is None or syncbuf.detach_head:
             # Currently on a detached HEAD.  The user is assumed to
             # not have any local modifications worth worrying about.
-            if self.IsRebaseInProgress():
+            rebase_in_progress = (
+                self.IsRebaseInProgress() or self.IsCherryPickInProgress()
+            )
+            if rebase_in_progress and force_checkout:
+                self._AbortRebase()
+                rebase_in_progress = (
+                    self.IsRebaseInProgress() or self.IsCherryPickInProgress()
+                )
+            if rebase_in_progress:
                 fail(_PriorSyncFailedError(project=self.name))
                 return
 
@@ -1922,7 +1949,7 @@
         # remove because it will recursively delete projects -- we handle that
         # ourselves below.  https://crbug.com/git/48
         if self.use_git_worktrees:
-            needle = platform_utils.realpath(self.gitdir)
+            needle = os.path.realpath(self.gitdir)
             # Find the git worktree commondir under .repo/worktrees/.
             output = self.bare_git.worktree("list", "--porcelain").splitlines()[
                 0
@@ -1936,7 +1963,7 @@
                 with open(gitdir) as fp:
                     relpath = fp.read().strip()
                 # Resolve the checkout path and see if it matches this project.
-                fullpath = platform_utils.realpath(
+                fullpath = os.path.realpath(
                     os.path.join(configs, name, relpath)
                 )
                 if fullpath == needle:
@@ -2663,12 +2690,7 @@
             branch = None
         else:
             branch = self.revisionExpr
-        if (
-            not self.manifest.IsMirror
-            and is_sha1
-            and depth
-            and git_require((1, 8, 3))
-        ):
+        if not self.manifest.IsMirror and is_sha1 and depth:
             # Shallow checkout of a specific commit, fetch from that commit and
             # not the heads only as the commit might be deeper in the history.
             spec.append(branch)
@@ -2880,6 +2902,8 @@
     def _FetchBundle(self, srcUrl, tmpPath, dstPath, quiet, verbose):
         platform_utils.remove(dstPath, missing_ok=True)
 
+        # We do not use curl's --retry option since it generally doesn't
+        # actually retry anything; code 18 for example, it will not retry on.
         cmd = ["curl", "--fail", "--output", tmpPath, "--netrc", "--location"]
         if quiet:
             cmd += ["--silent", "--show-error"]
@@ -2916,11 +2940,18 @@
             (output, _) = proc.communicate()
             curlret = proc.returncode
 
-            if curlret == 22:
+            if curlret in (22, 35, 56, 92):
+                # We use --fail so curl exits with unique status.
                 # From curl man page:
-                # 22: HTTP page not retrieved. The requested url was not found
-                # or returned another error with the HTTP error code being 400
-                # or above. This return code only appears if -f, --fail is used.
+                # 22: HTTP page not retrieved.  The requested url was not found
+                #     or returned another error with the HTTP error code being
+                #     400 or above.
+                # 35: SSL connect error.  The SSL handshaking failed.  This can
+                #     be thrown by Google storage sometimes.
+                # 56: Failure in receiving network data.  This shows up with
+                #     HTTP/404 on Google storage.
+                # 92: Stream error in HTTP/2 framing layer.  Basically the same
+                #     as 22 -- Google storage sometimes throws 500's.
                 if verbose:
                     print(
                         "%s: Unable to retrieve clone.bundle; ignoring."
@@ -3071,14 +3102,12 @@
                             "Retrying clone after deleting %s", self.gitdir
                         )
                         try:
-                            platform_utils.rmtree(
-                                platform_utils.realpath(self.gitdir)
-                            )
+                            platform_utils.rmtree(os.path.realpath(self.gitdir))
                             if self.worktree and os.path.exists(
-                                platform_utils.realpath(self.worktree)
+                                os.path.realpath(self.worktree)
                             ):
                                 platform_utils.rmtree(
-                                    platform_utils.realpath(self.worktree)
+                                    os.path.realpath(self.worktree)
                                 )
                             return self._InitGitDir(
                                 mirror_git=mirror_git,
@@ -3164,7 +3193,7 @@
             self._InitHooks(quiet=quiet)
 
     def _InitHooks(self, quiet=False):
-        hooks = platform_utils.realpath(os.path.join(self.objdir, "hooks"))
+        hooks = os.path.realpath(os.path.join(self.objdir, "hooks"))
         if not os.path.exists(hooks):
             os.makedirs(hooks)
 
@@ -3307,9 +3336,9 @@
             dst_path = os.path.join(destdir, name)
             src_path = os.path.join(srcdir, name)
 
-            dst = platform_utils.realpath(dst_path)
+            dst = os.path.realpath(dst_path)
             if os.path.lexists(dst):
-                src = platform_utils.realpath(src_path)
+                src = os.path.realpath(src_path)
                 # Fail if the links are pointing to the wrong place.
                 if src != dst:
                     logger.error(
@@ -3345,10 +3374,10 @@
         if copy_all:
             to_copy = platform_utils.listdir(gitdir)
 
-        dotgit = platform_utils.realpath(dotgit)
+        dotgit = os.path.realpath(dotgit)
         for name in set(to_copy).union(to_symlink):
             try:
-                src = platform_utils.realpath(os.path.join(gitdir, name))
+                src = os.path.realpath(os.path.join(gitdir, name))
                 dst = os.path.join(dotgit, name)
 
                 if os.path.lexists(dst):
@@ -3445,9 +3474,7 @@
         else:
             if not init_dotgit:
                 # See if the project has changed.
-                if platform_utils.realpath(
-                    self.gitdir
-                ) != platform_utils.realpath(dotgit):
+                if os.path.realpath(self.gitdir) != os.path.realpath(dotgit):
                     platform_utils.remove(dotgit)
 
             if init_dotgit or not os.path.exists(dotgit):
diff --git a/release/update-hooks b/release/update-hooks
new file mode 100755
index 0000000..def4bba
--- /dev/null
+++ b/release/update-hooks
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+# Copyright (C) 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Helper tool for updating hooks from their various upstreams."""
+
+import argparse
+import base64
+import json
+from pathlib import Path
+import sys
+from typing import List, Optional
+import urllib.request
+
+
+assert sys.version_info >= (3, 8), "Python 3.8+ required"
+
+
+TOPDIR = Path(__file__).resolve().parent.parent
+HOOKS_DIR = TOPDIR / "hooks"
+
+
+def update_hook_commit_msg() -> None:
+    """Update commit-msg hook from Gerrit."""
+    hook = HOOKS_DIR / "commit-msg"
+    print(
+        f"{hook.name}: Updating from https://gerrit.googlesource.com/gerrit/"
+        "+/HEAD/resources/com/google/gerrit/server/tools/root/hooks/commit-msg"
+    )
+
+    # Get the current commit.
+    url = "https://gerrit.googlesource.com/gerrit/+/HEAD?format=JSON"
+    with urllib.request.urlopen(url) as fp:
+        data = fp.read()
+    # Discard the xss protection.
+    data = data.split(b"\n", 1)[1]
+    data = json.loads(data)
+    commit = data["commit"]
+
+    # Fetch the data for that commit.
+    url = (
+        f"https://gerrit.googlesource.com/gerrit/+/{commit}/"
+        "resources/com/google/gerrit/server/tools/root/hooks/commit-msg"
+    )
+    with urllib.request.urlopen(f"{url}?format=TEXT") as fp:
+        data = fp.read()
+
+    # gitiles base64 encodes text data.
+    data = base64.b64decode(data)
+
+    # Inject header into the hook.
+    lines = data.split(b"\n")
+    lines = (
+        lines[:1]
+        + [
+            b"# DO NOT EDIT THIS FILE",
+            (
+                b"# All updates should be sent upstream: "
+                b"https://gerrit.googlesource.com/gerrit/"
+            ),
+            f"# This is synced from commit: {commit}".encode("utf-8"),
+            b"# DO NOT EDIT THIS FILE",
+        ]
+        + lines[1:]
+    )
+    data = b"\n".join(lines)
+
+    # Update the hook.
+    hook.write_bytes(data)
+    hook.chmod(0o755)
+
+
+def update_hook_pre_auto_gc() -> None:
+    """Update pre-auto-gc hook from git."""
+    hook = HOOKS_DIR / "pre-auto-gc"
+    print(
+        f"{hook.name}: Updating from https://github.com/git/git/"
+        "HEAD/contrib/hooks/pre-auto-gc-battery"
+    )
+
+    # Get the current commit.
+    headers = {
+        "Accept": "application/vnd.github+json",
+        "X-GitHub-Api-Version": "2022-11-28",
+    }
+    url = "https://api.github.com/repos/git/git/git/refs/heads/master"
+    req = urllib.request.Request(url, headers=headers)
+    with urllib.request.urlopen(req) as fp:
+        data = fp.read()
+    data = json.loads(data)
+
+    # Fetch the data for that commit.
+    commit = data["object"]["sha"]
+    url = (
+        f"https://raw.githubusercontent.com/git/git/{commit}/"
+        "contrib/hooks/pre-auto-gc-battery"
+    )
+    with urllib.request.urlopen(url) as fp:
+        data = fp.read()
+
+    # Inject header into the hook.
+    lines = data.split(b"\n")
+    lines = (
+        lines[:1]
+        + [
+            b"# DO NOT EDIT THIS FILE",
+            (
+                b"# All updates should be sent upstream: "
+                b"https://github.com/git/git/"
+            ),
+            f"# This is synced from commit: {commit}".encode("utf-8"),
+            b"# DO NOT EDIT THIS FILE",
+        ]
+        + lines[1:]
+    )
+    data = b"\n".join(lines)
+
+    # Update the hook.
+    hook.write_bytes(data)
+    hook.chmod(0o755)
+
+
+def main(argv: Optional[List[str]] = None) -> Optional[int]:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.parse_args(argv)
+
+    update_hook_commit_msg()
+    update_hook_pre_auto_gc()
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv[1:]))
diff --git a/repo b/repo
index 98fd5f7..b2114e5 100755
--- a/repo
+++ b/repo
@@ -210,7 +210,6 @@
 # NB: The version of git that the repo launcher requires may be much older than
 # the version of git that the main repo source tree requires.  Keeping this at
 # an older version also makes it easier for users to upgrade/rollback as needed.
-# See requirements.json for versions.
 MIN_GIT_VERSION = (1, 7, 9)  # minimum supported git version
 repodir = ".repo"  # name of repo's private directory
 S_repo = "repo"  # special repo repository
@@ -1237,13 +1236,13 @@
 
         return cls(json_data)
 
-    def _get_soft_ver(self, pkg):
+    def get_soft_ver(self, pkg):
         """Return the soft version for |pkg| if it exists."""
-        return self.requirements.get(pkg, {}).get("soft", ())
+        return tuple(self.requirements.get(pkg, {}).get("soft", ()))
 
-    def _get_hard_ver(self, pkg):
+    def get_hard_ver(self, pkg):
         """Return the hard version for |pkg| if it exists."""
-        return self.requirements.get(pkg, {}).get("hard", ())
+        return tuple(self.requirements.get(pkg, {}).get("hard", ()))
 
     @staticmethod
     def _format_ver(ver):
@@ -1253,8 +1252,8 @@
     def assert_ver(self, pkg, curr_ver):
         """Verify |pkg|'s |curr_ver| is new enough."""
         curr_ver = tuple(curr_ver)
-        soft_ver = tuple(self._get_soft_ver(pkg))
-        hard_ver = tuple(self._get_hard_ver(pkg))
+        soft_ver = tuple(self.get_soft_ver(pkg))
+        hard_ver = tuple(self.get_hard_ver(pkg))
         if curr_ver < hard_ver:
             print(
                 f'repo: error: Your version of "{pkg}" '
diff --git a/repo_logging.py b/repo_logging.py
index 20a5342..639382a 100644
--- a/repo_logging.py
+++ b/repo_logging.py
@@ -39,8 +39,8 @@
 
     def __init__(self, config):
         super().__init__(config, "logs")
-        self.error = self.colorer("error", fg="red")
-        self.warning = self.colorer("warn", fg="yellow")
+        self.error = self.nofmt_colorer("error", fg="red")
+        self.warning = self.nofmt_colorer("warn", fg="yellow")
         self.levelMap = {
             "WARNING": self.warning,
             "ERROR": self.error,
diff --git a/requirements.json b/requirements.json
index dac9a4f..2976eec 100644
--- a/requirements.json
+++ b/requirements.json
@@ -46,8 +46,6 @@
 
   # Supported git versions.
   #
-  # git-1.7.9 is in Ubuntu Precise.
-  # git-1.7.10 is in Debian Wheezy.
   # git-1.9.1 is in Ubuntu Trusty.
   # git-2.1.4 is in Debian Jessie.
   # git-2.7.4 is in Ubuntu Xenial.
@@ -55,7 +53,7 @@
   # git-2.17.0 is in Ubuntu Bionic.
   # git-2.20.1 is in Debian Buster.
   "git": {
-    "hard": [1, 7, 9],
+    "hard": [1, 9, 1],
     "soft": [2, 7, 4]
   }
 }
diff --git a/run_tests b/run_tests
index 7307f82..3e0e501 100755
--- a/run_tests
+++ b/run_tests
@@ -32,6 +32,7 @@
     extra_programs = [
         "repo",
         "run_tests",
+        "release/update-hooks",
         "release/update-manpages",
     ]
     return subprocess.run(
diff --git a/ssh.py b/ssh.py
index 54a7730..ffa0d6c 100644
--- a/ssh.py
+++ b/ssh.py
@@ -24,6 +24,7 @@
 import tempfile
 import time
 
+from git_command import git
 import platform_utils
 from repo_trace import Trace
 
@@ -211,7 +212,33 @@
                 # and print to the log there.
                 pass
 
-        command = command_base[:1] + ["-M", "-N"] + command_base[1:]
+        # Git protocol V2 is a new feature in git 2.18.0, made default in
+        # git 2.26.0
+        # It is faster and more efficient than V1.
+        # To enable it when using SSH, the environment variable GIT_PROTOCOL
+        # must be set in the SSH side channel when establishing the connection
+        # to the git server.
+        # See https://git-scm.com/docs/protocol-v2#_ssh_and_file_transport
+        # Normally git does this by itself. But here, where the SSH connection
+        # is established manually over ControlMaster via the repo-tool, it must
+        # be passed in explicitly instead.
+        # Based on https://git-scm.com/docs/gitprotocol-pack#_extra_parameters,
+        # GIT_PROTOCOL is considered an "Extra Parameter" and must be ignored
+        # by servers that do not understand it. This means that it is safe to
+        # set it even when connecting to older servers.
+        # It should also be safe to set the environment variable for older
+        # local git versions, since it is only part of the ssh side channel.
+        git_protocol_version = _get_git_protocol_version()
+        ssh_git_protocol_args = [
+            "-o",
+            f"SetEnv GIT_PROTOCOL=version={git_protocol_version}",
+        ]
+
+        command = (
+            command_base[:1]
+            + ["-M", "-N", *ssh_git_protocol_args]
+            + command_base[1:]
+        )
         p = None
         try:
             with Trace("Call to ssh: %s", " ".join(command)):
@@ -293,3 +320,32 @@
                 tempfile.mkdtemp("", "ssh-", tmp_dir), "master-" + tokens
             )
         return self._sock_path
+
+
+@functools.lru_cache(maxsize=1)
+def _get_git_protocol_version() -> str:
+    """Return the git protocol version.
+
+    The version is found by first reading the global git config.
+    If no git config for protocol version exists, try to deduce the default
+    protocol version based on the git version.
+
+    See https://git-scm.com/docs/gitprotocol-v2 for details.
+    """
+    try:
+        return subprocess.check_output(
+            ["git", "config", "--get", "--global", "protocol.version"],
+            encoding="utf-8",
+            stderr=subprocess.PIPE,
+        ).strip()
+    except subprocess.CalledProcessError as e:
+        if e.returncode == 1:
+            # Exit code 1 means that the git config key was not found.
+            # Try to imitate the defaults that git would have used.
+            git_version = git.version_tuple()
+            if git_version >= (2, 26, 0):
+                # Since git version 2.26, protocol v2 is the default.
+                return "2"
+            return "1"
+        # Other exit codes indicate error with reading the config.
+        raise
diff --git a/subcmds/init.py b/subcmds/init.py
index e53d033..7617bc1 100644
--- a/subcmds/init.py
+++ b/subcmds/init.py
@@ -21,10 +21,9 @@
 from error import RepoUnhandledExceptionError
 from error import UpdateManifestError
 from git_command import git_require
-from git_command import MIN_GIT_VERSION_HARD
-from git_command import MIN_GIT_VERSION_SOFT
 from repo_logging import RepoLogger
 from wrapper import Wrapper
+from wrapper import WrapperDir
 
 
 logger = RepoLogger(__file__)
@@ -331,13 +330,17 @@
                 self.OptionParser.error("too many arguments to init")
 
     def Execute(self, opt, args):
-        git_require(MIN_GIT_VERSION_HARD, fail=True)
-        if not git_require(MIN_GIT_VERSION_SOFT):
+        wrapper = Wrapper()
+
+        reqs = wrapper.Requirements.from_dir(WrapperDir())
+        git_require(reqs.get_hard_ver("git"), fail=True)
+        min_git_version_soft = reqs.get_soft_ver("git")
+        if not git_require(min_git_version_soft):
             logger.warning(
                 "repo: warning: git-%s+ will soon be required; "
                 "please upgrade your version of git to maintain "
                 "support.",
-                ".".join(str(x) for x in MIN_GIT_VERSION_SOFT),
+                ".".join(str(x) for x in min_git_version_soft),
             )
 
         rp = self.manifest.repoProject
@@ -350,7 +353,6 @@
 
         # Handle new --repo-rev requests.
         if opt.repo_rev:
-            wrapper = Wrapper()
             try:
                 remote_ref, rev = wrapper.check_repo_rev(
                     rp.worktree,
diff --git a/subcmds/upload.py b/subcmds/upload.py
index 081f1af..8039a1c 100644
--- a/subcmds/upload.py
+++ b/subcmds/upload.py
@@ -218,9 +218,14 @@
     def _Options(self, p):
         p.add_option(
             "-t",
+            "--topic-branch",
             dest="auto_topic",
             action="store_true",
-            help="send local branch name to Gerrit Code Review",
+            help="set the topic to the local branch name",
+        )
+        p.add_option(
+            "--topic",
+            help="set topic for the change",
         )
         p.add_option(
             "--hashtag",
@@ -549,42 +554,14 @@
         people = copy.deepcopy(original_people)
         self._AppendAutoList(branch, people)
 
-        # Check if there are local changes that may have been forgotten.
-        changes = branch.project.UncommitedFiles()
-        if opt.ignore_untracked_files:
-            untracked = set(branch.project.UntrackedFiles())
-            changes = [x for x in changes if x not in untracked]
-
-        if changes:
-            key = "review.%s.autoupload" % branch.project.remote.review
-            answer = branch.project.config.GetBoolean(key)
-
-            # If they want to auto upload, let's not ask because it
-            # could be automated.
-            if answer is None:
-                print()
-                print(
-                    "Uncommitted changes in %s (did you forget to "
-                    "amend?):" % branch.project.name
-                )
-                print("\n".join(changes))
-                print("Continue uploading? (y/N) ", end="", flush=True)
-                if opt.yes:
-                    print("<--yes>")
-                    a = "yes"
-                else:
-                    a = sys.stdin.readline().strip().lower()
-                if a not in ("y", "yes", "t", "true", "on"):
-                    print("skipping upload", file=sys.stderr)
-                    branch.uploaded = False
-                    branch.error = "User aborted"
-                    return
-
         # Check if topic branches should be sent to the server during
         # upload.
-        if opt.auto_topic is not True:
-            key = "review.%s.uploadtopic" % branch.project.remote.review
-            opt.auto_topic = branch.project.config.GetBoolean(key)
+        if opt.topic is None:
+            if opt.auto_topic is not True:
+                key = "review.%s.uploadtopic" % branch.project.remote.review
+                opt.auto_topic = branch.project.config.GetBoolean(key)
+            if opt.auto_topic:
+                opt.topic = branch.name
 
         def _ExpandCommaList(value):
             """Split |value| up into comma delimited entries."""
@@ -651,7 +628,7 @@
         branch.UploadForReview(
             people,
             dryrun=opt.dryrun,
-            auto_topic=opt.auto_topic,
+            topic=opt.topic,
             hashtags=hashtags,
             labels=labels,
             private=opt.private,
diff --git a/tests/test_repo_logging.py b/tests/test_repo_logging.py
index 0f6a335..e072039 100644
--- a/tests/test_repo_logging.py
+++ b/tests/test_repo_logging.py
@@ -13,9 +13,14 @@
 # limitations under the License.
 
 """Unit test for repo_logging module."""
+
+import contextlib
+import io
+import logging
 import unittest
 from unittest import mock
 
+from color import SetDefaultColoring
 from error import RepoExitError
 from repo_logging import RepoLogger
 
@@ -62,3 +67,35 @@
                 mock.call("Repo command failed: %s", "RepoExitError"),
             ]
         )
+
+    def test_log_with_format_string(self):
+        """Test different log levels with format strings."""
+
+        # Set color output to "always" for consistent test results.
+        # This ensures the logger's behavior is uniform across different
+        # environments and git configurations.
+        SetDefaultColoring("always")
+
+        # Regex pattern to match optional ANSI color codes.
+        # \033    - Escape character
+        # \[      - Opening square bracket
+        # [0-9;]* - Zero or more digits or semicolons
+        # m       - Ending 'm' character
+        # ?       - Makes the entire group optional
+        opt_color = r"(\033\[[0-9;]*m)?"
+
+        for level in (logging.INFO, logging.WARN, logging.ERROR):
+            name = logging.getLevelName(level)
+
+            with self.subTest(level=level, name=name):
+                output = io.StringIO()
+
+                with contextlib.redirect_stderr(output):
+                    logger = RepoLogger(__name__)
+                    logger.log(level, "%s", "100% pass")
+
+                self.assertRegex(
+                    output.getvalue().strip(),
+                    f"^{opt_color}100% pass{opt_color}$",
+                    f"failed for level {name}",
+                )
diff --git a/wrapper.py b/wrapper.py
index d882336..5508224 100644
--- a/wrapper.py
+++ b/wrapper.py
@@ -18,8 +18,12 @@
 import os
 
 
+def WrapperDir():
+    return os.path.dirname(__file__)
+
+
 def WrapperPath():
-    return os.path.join(os.path.dirname(__file__), "repo")
+    return os.path.join(WrapperDir(), "repo")
 
 
 @functools.lru_cache(maxsize=None)