From 180b1ffab5fdcb52a674fb6461ca42d9788b3a07 Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 1 Sep 2026 09:33:11 +0200 Subject: [PATCH 01/10] fix: keep bare-repository worktrees non-bare (#2223) Opening a linked worktree created from a bare repository read `core.bare` from the common repository and discarded the worktree path. Keep a discovered linked worktree non-bare when its administrative directory has a `commondir` marker. The regression compares Git rev-parse behavior and verifies Repo.bare and working_tree_dir. Git baseline: 0bd5a6920d7c4238e0d90ddc0e7e08866e84a0f1; environment.c:is_bare_repository() and setup.c:check_repository_format_gently(). Git 2.50.1 reports the linked checkout as non-bare. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/repo/base.py | 4 ++++ test/test_repo.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/git/repo/base.py b/git/repo/base.py index 4ae48e17b..7039d0320 100644 --- a/git/repo/base.py +++ b/git/repo/base.py @@ -386,6 +386,10 @@ def __init__( # Let's not assume the option exists, although it should. pass + # A linked worktree is not bare even when its main repository is. + if self._bare and self._working_tree_dir and osp.isfile(osp.join(self.git_dir, "commondir")): + self._bare = False + # Adjust the working directory in case we are actually bare - we didn't know # that in the first place. if self._bare: diff --git a/test/test_repo.py b/test/test_repo.py index 12e572f52..ad1fa68ab 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1459,6 +1459,23 @@ def test_git_work_tree_dotgit(self, rw_dir, use_relative_paths=False): self.assertIsInstance(repo.heads["aaaaaaaa"], Head) + @with_rw_directory + def test_git_work_tree_from_bare_repo(self, rw_dir): + if Git().version_info[:3] < (2, 5, 1): + pytest.skip("worktree feature unsupported, needs git 2.5.1 or later") + + bare_repo = self.rorepo.clone(join_path_native(rw_dir, "bare_repo"), bare=True) + worktree_path = join_path_native(rw_dir, "worktree_repo") + if Git.is_cygwin(): + worktree_path = cygpath(worktree_path) + bare_repo.git.worktree("add", "--detach", worktree_path) + + repo = Repo(worktree_path) + + assert Git(worktree_path).rev_parse("--is-bare-repository") == "false" + assert not repo.bare + assert osp.samefile(repo.working_tree_dir, worktree_path) + def test_git_work_tree_dotgit_relative(self): """Check that we find .git as a worktree file containing a relative path and find the worktree based on it.""" From 0e8c23c56c816b7fae8d2417606e9d6f7b37cc87 Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 1 Sep 2026 10:14:10 +0200 Subject: [PATCH 02/10] ci: restore Cygwin virtual environments on Python 3.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cygwin patches ensurepip to load setuptools and pip wheels from `/usr/share/python-wheels`. The rolling python-pip-wheel package advanced to pip 26.2.1, which requires Python 3.10 or newer, so every Python 3.9 venv creation failed—including the installation test's nested virtual environment. Keep the single Cygwin lane on Python 3.9 because the regular matrix already covers Python 3.12 and newer. Install Cygwin's setuptools wheel, download pip 26.0.1 from its immutable PyPI URL into ensurepip's shared wheel directory, verify its SHA-256 digest, and avoid upgrading pip. This fixes all venv creation without relying on a version that may disappear from rolling Cygwin mirrors. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- .github/workflows/cygwin-test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cygwin-test.yml b/.github/workflows/cygwin-test.yml index b8150e93d..e14856e7e 100644 --- a/.github/workflows/cygwin-test.yml +++ b/.github/workflows/cygwin-test.yml @@ -45,7 +45,7 @@ jobs: - name: Install Cygwin uses: cygwin/cygwin-install-action@v6 with: - packages: git python39 python-pip-wheel python-setuptools-wheel python-wheel-wheel + packages: curl git python39 python-setuptools-wheel add-to-path: false # No need to change $PATH outside the Cygwin environment. - name: Arrange for verbose output @@ -73,12 +73,15 @@ jobs: - name: Set up virtual environment run: | + pip_wheel=/usr/share/python-wheels/pip-26.0.1-py3-none-any.whl + curl -fsSLo "$pip_wheel" https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl + python3.9 -c 'import hashlib, pathlib, sys; assert hashlib.sha256(pathlib.Path(sys.argv[1]).read_bytes()).hexdigest() == sys.argv[2]' "$pip_wheel" bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b python3.9 -m venv .venv echo 'BASH_ENV=.venv/bin/activate' >>"$GITHUB_ENV" - name: Update PyPA packages run: | - python -m pip install -U pip 'setuptools; python_version<"3.12"' wheel + python -m pip install -U 'setuptools; python_version<"3.12"' wheel - name: Install project and test dependencies run: | From 1ed0ebc2f2e74d979cdc367a4864a7731fdcc093 Mon Sep 17 00:00:00 2001 From: Byron Date: Tue, 1 Sep 2026 11:33:19 +0200 Subject: [PATCH 03/10] Reject submodule checkout paths outside the repository GHSA-59cr-6r3x-644w identifies that submodule update paths could reach filesystem operations without the containment check already used by add and move. Add a regression that proves update rejects a parent-directory checkout path before cloning, and override Submodule.abspath to apply the shared _to_relative_path guard for every filesystem consumer. Git baseline: git.git read-cache.c verify_path_internal() rejects invalid index paths, covered for parent traversal by t/t9300-fast-import.sh. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- doc/source/changes.rst | 13 ++++++++++++ git/objects/submodule/base.py | 12 +++++++++++ test/test_submodule.py | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 6b06dd5bf..20bfaeae6 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -2,6 +2,19 @@ Changelog ========= +3.1.62 +====== + +Security fixes for + +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w + +If you can, also try and provide feedback on the upcoming v4 branch +https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. + +See the following for all changes. +https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62 + 3.1.61 ====== diff --git a/git/objects/submodule/base.py b/git/objects/submodule/base.py index 39e912321..563b20a18 100644 --- a/git/objects/submodule/base.py +++ b/git/objects/submodule/base.py @@ -414,6 +414,18 @@ def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike: return path + @property + def abspath(self) -> PathLike: + root = self.repo.working_tree_dir + if root is None: + return super().abspath + path = root + for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"): + path = join_path_native(path, component) + if osp.islink(path): + raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path) + return path + @classmethod def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None: """Write a ``.git`` file containing a (preferably) relative path to the actual diff --git a/test/test_submodule.py b/test/test_submodule.py index d545cc8d5..ca9078aac 100644 --- a/test/test_submodule.py +++ b/test/test_submodule.py @@ -1369,6 +1369,46 @@ class Repo: osp.join(Repo.working_tree_dir + "-other", "module"), ) + @with_rw_directory + def test_update_rejects_checkout_path_outside_parent(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + submodule = Submodule( + parent, + Submodule.NULL_BIN_SHA, + name="module", + path=osp.join("..", "outside"), + url="unused", + ) + + with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): + with pytest.raises(ValueError, match="is not in repository"): + submodule.update(init=True) + + @with_rw_directory + def test_update_rejects_checkout_path_through_symlink(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + os.mkdir(osp.join(parent.working_tree_dir, "target")) + os.symlink("target", osp.join(parent.working_tree_dir, "link")) + submodule = Submodule( + parent, + Submodule.NULL_BIN_SHA, + name="module", + path=osp.join("link", "module"), + url="unused", + ) + + with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("clone attempted")): + with pytest.raises(ValueError, match="contains a symbolic link"): + submodule.update(init=True) + + @with_rw_directory + def test_update_rejects_checkout_path_at_parent_root(self, rwdir): + parent = git.Repo.init(osp.join(rwdir, "parent")) + submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path=".", url="unused") + + with pytest.raises(ValueError, match="must not be the repository root"): + submodule.update(init=True) + @skipUnless(sys.platform == "win32", "Specifically for Windows.") @with_rw_directory def test_to_relative_path_windows_path_kinds(self, rwdir): From 23d0e9269c84573129acd1f90dbedf639e889c8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:39:08 +0000 Subject: [PATCH 04/10] build(deps): bump https://github.com/astral-sh/ruff-pre-commit Bumps the pre-commit group with 1 update: [https://github.com/astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit). Updates `https://github.com/astral-sh/ruff-pre-commit` from v0.16.0 to 0.16.5 - [Release notes](https://github.com/astral-sh/ruff-pre-commit/releases) - [Commits](https://github.com/astral-sh/ruff-pre-commit/compare/v0.16.0...v0.16.5) --- updated-dependencies: - dependency-name: https://github.com/astral-sh/ruff-pre-commit dependency-version: 0.16.5 dependency-type: direct:production dependency-group: pre-commit ... Signed-off-by: dependabot[bot] --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75a29d6db..dfa5fc91e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: exclude: ^test/fixtures/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.0 + rev: v0.16.5 hooks: - id: ruff-check args: ["--fix"] From 075a664f3da8564c44f2b74536914b9ce9fb4cc1 Mon Sep 17 00:00:00 2001 From: NK Date: Fri, 4 Sep 2026 02:36:33 +0530 Subject: [PATCH 05/10] fix: join backslash line continuations when reading config values git config joins an unquoted value that ends in a backslash with the next line: the backslash and the newline are removed and the next line is appended verbatim, so 'k = line1\' followed by ' line2' reads back as 'line1 line2'. GitPython only implemented multi-line values for quoted strings; for unquoted values the continuation line was silently dropped, truncating whatever was stored in the config. Read the continuation inside _read and join it, chaining across lines that themselves end in a backslash. An even number of trailing backslashes is an escaped one, so the value ends there; a single backslash right at end-of-file is dropped, matching git. Verified against git itself for every case covered by the new tests. --- git/config.py | 21 ++++++++++++++++++++- test/test_config.py | 20 ++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/git/config.py b/git/config.py index e7f64f7b5..f30a220ae 100644 --- a/git/config.py +++ b/git/config.py @@ -513,7 +513,26 @@ def string_decode(v: str) -> str: if len(optval) < 2 or optval[0] != '"': # Does not open quoting. - pass + # A value ending in an odd number of backslashes + # continues on the next line, exactly as git does: the + # final backslash and the newline are removed and the + # next line is appended verbatim (leading whitespace + # included). An even number means the last backslash + # is escaped and the value ends there. + while True: + trailing = len(optval) - len(optval.rstrip("\\")) + if trailing % 2 == 0: + break + continuation = fp.readline() + if not continuation: + # Backslash at end of file: git drops it. + optval = optval[:-1] + break + lineno = lineno + 1 + joined = continuation.decode(defenc) + while joined.endswith("\n") or joined.endswith("\r"): + joined = joined[:-1] + optval = optval[:-1] + joined elif optval[-1] != '"': # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True diff --git a/test/test_config.py b/test/test_config.py index 28bb12043..b8d5b9363 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -143,6 +143,26 @@ def test_multi_line_config(self): ) self.assertEqual(len(config.sections()), 23) + def test_backslash_line_continuation(self): + """An unquoted value ending in a backslash continues on the next line, + exactly as git config parses it: the final backslash and the newline + are removed and the next line is appended verbatim.""" + cases = [ + (b"[a]\n\tk = line1\\\n line2\n", "line1 line2"), + (b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"), + (b"[a]\n\tk = val\\\\\n next\n", "val\\\\"), + (b"[a]\n\tk = end\\\n", "end"), + (b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"), + ] + for content, expected in cases: + config_file = io.BytesIO(content) + config_file.name = "backslash_continuation.config" + config = GitConfigParser(config_file) + config.read() + section = "alias" if b"[alias]" in content else "a" + key = "co" if section == "alias" else "k" + self.assertEqual(config.get_value(section, key), expected) + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content) From 60dd9467a73140e00b0d1b0857df5176660b3832 Mon Sep 17 00:00:00 2001 From: Byron Date: Fri, 4 Sep 2026 06:20:07 +0200 Subject: [PATCH 06/10] fix: ignore continuation markers in config comments Git treats # and ; outside quotes as the start of a comment. A trailing backslash in that ignored text therefore cannot continue the value. The continuation loop counted trailing slashes without lexical context, so it consumed the next option and a later writable flush silently dropped that setting. - Track quote and escape state while checking the accumulated value. - Stop continuation scanning at an unquoted comment. - Cover preservation of the following option across a writable flush. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/config.py | 16 ++++++++++++++-- test/test_config.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/git/config.py b/git/config.py index f30a220ae..283ee297b 100644 --- a/git/config.py +++ b/git/config.py @@ -467,6 +467,19 @@ def string_decode(v: str) -> str: # END string_decode + def is_line_continuation(value: str) -> bool: + quoted = escaped = False + for char in value: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + elif char in "#;" and not quoted: + return False + return escaped + while True: # We assume to read binary! line = fp.readline().decode(defenc) @@ -520,8 +533,7 @@ def string_decode(v: str) -> str: # included). An even number means the last backslash # is escaped and the value ends there. while True: - trailing = len(optval) - len(optval.rstrip("\\")) - if trailing % 2 == 0: + if not is_line_continuation(optval): break continuation = fp.readline() if not continuation: diff --git a/test/test_config.py b/test/test_config.py index b8d5b9363..df60da086 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -163,6 +163,19 @@ def test_backslash_line_continuation(self): key = "co" if section == "alias" else "k" self.assertEqual(config.get_value(section, key), expected) + @with_rw_directory + def test_comment_backslash_does_not_continue_value(self, rw_dir): + config_path = osp.join(rw_dir, "config") + with open(config_path, "wb") as config_file: + config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n") + + with GitConfigParser(config_path, read_only=False) as config: + self.assertEqual(config.get_value("a", "x"), "two") + config.set_value("a", "added", "three") + + with GitConfigParser(config_path) as config: + self.assertEqual(config.get_value("a", "x"), "two") + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content) From a15f7910d1d9a169b5a0c7adf14ce9bf4ede35d0 Mon Sep 17 00:00:00 2001 From: Byron Date: Fri, 4 Sep 2026 06:22:28 +0200 Subject: [PATCH 07/10] fix: parse joined config values as a whole Git removes each backslash-newline pair before parsing the resulting logical value. Appending physical lines after processing only the first line left trailing whitespace, comments, quotes, and recognized escapes literal. That divergence returned values unlike git config and could preserve comment text as configuration data. - Accumulate continuation text before parsing it. - Apply whitespace, comment, quote, and escape handling once to the complete value. - Cover each affected syntax form with Git-compatible expectations. Assisted-by: GPT 5.6 Co-authored-by: GPT 5.6 --- git/config.py | 36 +++++++++++++++++++++++++++++++++--- test/test_config.py | 7 ++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/git/config.py b/git/config.py index 283ee297b..aef881d2e 100644 --- a/git/config.py +++ b/git/config.py @@ -480,6 +480,32 @@ def is_line_continuation(value: str) -> bool: return False return escaped + def parse_value(value: str) -> str: + parsed: List[str] = [] + whitespace: List[str] = [] + quoted = escaped = False + escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"} + for char in value: + if escaped: + parsed.append(escapes.get(char, "\\" + char)) + escaped = False + continue + if char.isspace() and not quoted: + if parsed: + whitespace.append(char) + continue + if char in "#;" and not quoted: + break + parsed.extend(whitespace) + whitespace.clear() + if char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + else: + parsed.append(char) + return "".join(parsed) + while True: # We assume to read binary! line = fp.readline().decode(defenc) @@ -529,9 +555,10 @@ def is_line_continuation(value: str) -> bool: # A value ending in an odd number of backslashes # continues on the next line, exactly as git does: the # final backslash and the newline are removed and the - # next line is appended verbatim (leading whitespace - # included). An even number means the last backslash - # is escaped and the value ends there. + # next line is appended before the complete value is + # parsed. An even number means the last backslash is + # escaped and the value ends there. + continued = False while True: if not is_line_continuation(optval): break @@ -545,6 +572,9 @@ def is_line_continuation(value: str) -> bool: while joined.endswith("\n") or joined.endswith("\r"): joined = joined[:-1] optval = optval[:-1] + joined + continued = True + if continued: + optval = parse_value(optval) elif optval[-1] != '"': # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True diff --git a/test/test_config.py b/test/test_config.py index df60da086..3107d8074 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -146,10 +146,14 @@ def test_multi_line_config(self): def test_backslash_line_continuation(self): """An unquoted value ending in a backslash continues on the next line, exactly as git config parses it: the final backslash and the newline - are removed and the next line is appended verbatim.""" + are removed before the complete logical value is parsed.""" cases = [ (b"[a]\n\tk = line1\\\n line2\n", "line1 line2"), (b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"), + (b"[a]\n\tk = one\\\n two \n", "one two"), + (b"[a]\n\tk = one\\\n two ; ignored\n", "one two"), + (b'[a]\n\tk = one\\\n "two"\n', "one two"), + (b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"), (b"[a]\n\tk = val\\\\\n next\n", "val\\\\"), (b"[a]\n\tk = end\\\n", "end"), (b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"), @@ -170,6 +174,7 @@ def test_comment_backslash_does_not_continue_value(self, rw_dir): config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n") with GitConfigParser(config_path, read_only=False) as config: + self.assertEqual(config.get_value("a", "k"), "one two") self.assertEqual(config.get_value("a", "x"), "two") config.set_value("a", "added", "three") From b7548979693b3f7c8d13b8da2b7c3ecef07327da Mon Sep 17 00:00:00 2001 From: Christian Vuerings Date: Sun, 6 Sep 2026 23:28:29 +0000 Subject: [PATCH 08/10] test: cover subdirectory discovery and pathspec commands in bare-repo worktrees (#2223) --- test/test_repo.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/test_repo.py b/test/test_repo.py index ad1fa68ab..b7b5718ff 100644 --- a/test/test_repo.py +++ b/test/test_repo.py @@ -1476,6 +1476,17 @@ def test_git_work_tree_from_bare_repo(self, rw_dir): assert not repo.bare assert osp.samefile(repo.working_tree_dir, worktree_path) + # Discovering from a subdirectory (as tools such as mkdocs plugins do) and then + # running a command with an absolute pathspec must use the worktree, not the + # bare common dir, as the working directory; 3.1.61 failed here with + # "is outside repository at '/.'" (#2223). + subdir = osp.join(worktree_path, "git", "repo") + sub_repo = Repo(subdir, search_parent_directories=True) + assert not sub_repo.bare + assert osp.samefile(sub_repo.working_dir, worktree_path) + expected = repo.git.log("-n", "1", "--format=%H", "--", "git/repo") + assert sub_repo.git.log("-n", "1", "--format=%H", "--", subdir) == expected + def test_git_work_tree_dotgit_relative(self): """Check that we find .git as a worktree file containing a relative path and find the worktree based on it.""" From 10ec385a725fee70def3d9bb7b52208e75174068 Mon Sep 17 00:00:00 2001 From: Byron Date: Mon, 7 Sep 2026 04:44:56 +0200 Subject: [PATCH 09/10] get better commit messages from agents --- AGENTS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ee4ef9d95 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,14 @@ +# Commit messages + +Every commit must have a descriptive title and a substantive body. Title-only +commit messages are not acceptable. + +The body must explain the problem or motivation, what changed, and why the +chosen approach addresses it. Include relevant behavior before and after the +change, design decisions, limitations, and validation results. Scale the detail +to the change; do not add boilerplate or claim checks that were not run. + +Commit messages must stand on their own. Put the information needed to understand +and review the change in the commit body, even when it also appears in a pull +request description. PR and issue links may provide additional context, but must +not substitute for that explanation. From db47516dac750c5968b8f6d388b18712c355df50 Mon Sep 17 00:00:00 2001 From: Byron Date: Mon, 7 Sep 2026 04:54:54 +0200 Subject: [PATCH 10/10] prepare new release --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c29b32b56..4eb2ee669 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.1.61 +3.1.62