# test_porcelain.py -- porcelain tests
# Copyright (C) 2013 Jelmer Vernooij <jelmer@jelmer.uk>
#
# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
# General Public License as published by the Free Software Foundation; version 2.0
# or (at your option) any later version. You can redistribute it and/or
# modify it under the terms of either of these two licenses.
#
# 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.
#
# You should have received a copy of the licenses; if not, see
# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
# License, Version 2.0.
#

"""Tests for dulwich.porcelain."""

import contextlib
import importlib.util
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import threading
import time
from io import BytesIO, StringIO
from unittest import skipIf

from dulwich import porcelain
from dulwich.client import SendPackResult
from dulwich.diff_tree import tree_changes
from dulwich.errors import CommitError
from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
from dulwich.objects import ZERO_SHA, Blob, Commit, Tag, Tree
from dulwich.patch import PatchApplicationFailure
from dulwich.porcelain import (
    CheckoutError,  # Hypothetical or real error class
    CountObjectsResult,
    add,
    commit,
)
from dulwich.repo import NoIndexPresent, Repo
from dulwich.server import DictBackend
from dulwich.tests.utils import build_commit_graph, make_commit, make_object
from dulwich.web import make_server, make_wsgi_chain

from .. import DependencyMissing, TestCase

try:
    import gpg
except ImportError:
    gpg = None


def flat_walk_dir(dir_to_walk):
    for dirpath, _, filenames in os.walk(dir_to_walk):
        rel_dirpath = os.path.relpath(dirpath, dir_to_walk)
        if not dirpath == dir_to_walk:
            yield rel_dirpath
        for filename in filenames:
            if dirpath == dir_to_walk:
                yield filename
            else:
                yield os.path.join(rel_dirpath, filename)


class PorcelainTestCase(TestCase):
    def setUp(self) -> None:
        super().setUp()
        # Disable pagers for tests
        self.overrideEnv("PAGER", "false")
        self.overrideEnv("GIT_PAGER", "false")
        self.overrideEnv("DULWICH_PAGER", "false")

        self.test_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.test_dir)
        self.repo_path = os.path.join(self.test_dir, "repo")
        self.repo = Repo.init(self.repo_path, mkdir=True)
        self.addCleanup(self.repo.close)

    def assertRecentTimestamp(self, ts) -> None:
        # On some slow CIs it does actually take more than 5 seconds to go from
        # creating the tag to here.
        self.assertLess(time.time() - ts, 50)


@skipIf(gpg is None, "gpg is not available")
class PorcelainGpgTestCase(PorcelainTestCase):
    DEFAULT_KEY = """
-----BEGIN PGP PRIVATE KEY BLOCK-----


brMb2upTHa+kjktjdO96/BgSnKYqmNmPB/qB
=ivA/
-----END PGP PRIVATE KEY BLOCK-----
    """

    DEFAULT_KEY_ID = "8EB47C310E1F24AE383D832F7CDD800A52E65E26"

    NON_DEFAULT_KEY = """
-----BEGIN PGP PRIVATE KEY BLOCK-----


ya6JVZCRbMXfdCy8lVPgtNQ6VlHaj8Wvnn2FLbWWO2n2r3s=
=9zU5
-----END PGP PRIVATE KEY BLOCK-----
"""

    NON_DEFAULT_KEY_ID = "6A93393F50C5E6ACD3D6FB45B936212EDB4E14C0"

    def setUp(self) -> None:
        super().setUp()
        self.gpg_dir = os.path.join(self.test_dir, "gpg")
        os.mkdir(self.gpg_dir, mode=0o700)
        # Ignore errors when deleting GNUPGHOME, because of race conditions
        # (e.g. the gpg-agent socket having been deleted). See
        # https://github.com/jelmer/dulwich/issues/1000
        self.addCleanup(shutil.rmtree, self.gpg_dir, ignore_errors=True)
        self.overrideEnv("GNUPGHOME", self.gpg_dir)

    def import_default_key(self) -> None:
        subprocess.run(
            ["gpg", "--import"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            input=PorcelainGpgTestCase.DEFAULT_KEY,
            text=True,
        )

    def import_non_default_key(self) -> None:
        subprocess.run(
            ["gpg", "--import"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            input=PorcelainGpgTestCase.NON_DEFAULT_KEY,
            text=True,
        )


class ArchiveTests(PorcelainTestCase):
    """Tests for the archive command."""

    def test_simple(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/master"] = c3.id
        out = BytesIO()
        err = BytesIO()
        porcelain.archive(
            self.repo.path, b"refs/heads/master", outstream=out, errstream=err
        )
        self.assertEqual(b"", err.getvalue())
        tf = tarfile.TarFile(fileobj=out)
        self.addCleanup(tf.close)
        self.assertEqual([], tf.getnames())


class UpdateServerInfoTests(PorcelainTestCase):
    def test_simple(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/foo"] = c3.id
        porcelain.update_server_info(self.repo.path)
        self.assertTrue(
            os.path.exists(os.path.join(self.repo.controldir(), "info", "refs"))
        )


class CommitTests(PorcelainTestCase):
    def test_custom_author(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/foo"] = c3.id
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

    def test_unicode(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/foo"] = c3.id
        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

    def test_no_verify(self) -> None:
        if os.name != "posix":
            self.skipTest("shell hook tests requires POSIX shell")
        self.assertTrue(os.path.exists("/bin/sh"))

        hooks_dir = os.path.join(self.repo.controldir(), "hooks")
        os.makedirs(hooks_dir, exist_ok=True)
        self.addCleanup(shutil.rmtree, hooks_dir)

        _c1, _c2, _c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )

        hook_fail = "#!/bin/sh\nexit 1"

        # hooks are executed in pre-commit, commit-msg order
        # test commit-msg failure first, then pre-commit failure, then
        # no_verify to skip both hooks
        commit_msg = os.path.join(hooks_dir, "commit-msg")
        with open(commit_msg, "w") as f:
            f.write(hook_fail)
        os.chmod(commit_msg, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)

        with self.assertRaises(CommitError):
            porcelain.commit(
                self.repo.path,
                message="Some message",
                author="Joe <joe@example.com>",
                committer="Bob <bob@example.com>",
            )

        pre_commit = os.path.join(hooks_dir, "pre-commit")
        with open(pre_commit, "w") as f:
            f.write(hook_fail)
        os.chmod(pre_commit, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)

        with self.assertRaises(CommitError):
            porcelain.commit(
                self.repo.path,
                message="Some message",
                author="Joe <joe@example.com>",
                committer="Bob <bob@example.com>",
            )

        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            no_verify=True,
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

    def test_timezone(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/foo"] = c3.id
        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            author_timezone=18000,
            committer="Bob <bob@example.com>",
            commit_timezone=18000,
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        self.assertEqual(commit._author_timezone, 18000)
        self.assertEqual(commit._commit_timezone, 18000)

        self.overrideEnv("GIT_AUTHOR_DATE", "1995-11-20T19:12:08-0501")
        self.overrideEnv("GIT_COMMITTER_DATE", "1995-11-20T19:12:08-0501")

        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        self.assertEqual(commit._author_timezone, -18060)
        self.assertEqual(commit._commit_timezone, -18060)

        self.overrideEnv("GIT_AUTHOR_DATE", None)
        self.overrideEnv("GIT_COMMITTER_DATE", None)

        local_timezone = time.localtime().tm_gmtoff

        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        self.assertEqual(commit._author_timezone, local_timezone)
        self.assertEqual(commit._commit_timezone, local_timezone)

    def test_timestamp(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"refs/heads/foo"] = c3.id
        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            author_timestamp=123456,
            committer="Bob <bob@example.com>",
            commit_timestamp=123456,
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo[sha]
        assert isinstance(commit, Commit)
        self.assertEqual(commit.author_time, 123456)
        self.assertEqual(commit.commit_time, 123456)

    def test_commit_all(self) -> None:
        # Create initial commit
        filename = os.path.join(self.repo.path, "test.txt")
        with open(filename, "wb") as f:
            f.write(b"initial content")
        porcelain.add(self.repo.path, paths=["test.txt"])
        initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")

        # Modify the file without staging
        with open(filename, "wb") as f:
            f.write(b"modified content")

        # Create an untracked file
        untracked_file = os.path.join(self.repo.path, "untracked.txt")
        with open(untracked_file, "wb") as f:
            f.write(b"untracked content")

        # Commit with all=True should stage modified files but not untracked
        sha = porcelain.commit(self.repo.path, message=b"Modified commit", all=True)
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)
        self.assertNotEqual(sha, initial_sha)

        # Verify the commit contains the modification
        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        tree = self.repo.get_object(commit.tree)
        # The modified file should be in the commit
        self.assertIn(b"test.txt", tree)
        # The untracked file should not be in the commit
        self.assertNotIn(b"untracked.txt", tree)

    def test_commit_all_no_changes(self) -> None:
        # Create initial commit
        filename = os.path.join(self.repo.path, "test.txt")
        with open(filename, "wb") as f:
            f.write(b"initial content")
        porcelain.add(self.repo.path, paths=["test.txt"])
        initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")

        # Try to commit with all=True when there are no unstaged changes
        sha = porcelain.commit(self.repo.path, message=b"No changes commit", all=True)
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)
        self.assertNotEqual(sha, initial_sha)

    def test_commit_all_multiple_files(self) -> None:
        # Create initial commit with multiple files
        file1 = os.path.join(self.repo.path, "file1.txt")
        file2 = os.path.join(self.repo.path, "file2.txt")

        with open(file1, "wb") as f:
            f.write(b"content1")
        with open(file2, "wb") as f:
            f.write(b"content2")

        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
        initial_sha = porcelain.commit(self.repo.path, message=b"Initial commit")

        # Modify both files
        with open(file1, "wb") as f:
            f.write(b"modified content1")
        with open(file2, "wb") as f:
            f.write(b"modified content2")

        # Commit with all=True should stage both modified files
        sha = porcelain.commit(self.repo.path, message=b"Modified both files", all=True)
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)
        self.assertNotEqual(sha, initial_sha)

        # Verify both modifications are in the commit
        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        tree = self.repo.get_object(commit.tree)
        self.assertIn(b"file1.txt", tree)
        self.assertIn(b"file2.txt", tree)

    def test_commit_amend_message(self) -> None:
        # Create initial commit
        filename = os.path.join(self.repo.path, "test.txt")
        with open(filename, "wb") as f:
            f.write(b"initial content")
        porcelain.add(self.repo.path, paths=["test.txt"])
        original_sha = porcelain.commit(self.repo.path, message=b"Original commit")

        # Amend with new message
        amended_sha = porcelain.commit(
            self.repo.path, message=b"Amended commit", amend=True
        )

        self.assertIsInstance(amended_sha, bytes)
        self.assertEqual(len(amended_sha), 40)
        self.assertNotEqual(amended_sha, original_sha)

        # Check that the amended commit has the new message
        amended_commit = self.repo.get_object(amended_sha)
        assert isinstance(amended_commit, Commit)
        self.assertEqual(amended_commit.message, b"Amended commit")

        # Check that the amended commit uses the original commit's parents
        original_commit = self.repo.get_object(original_sha)
        assert isinstance(original_commit, Commit)
        # Since this was the first commit, it should have no parents,
        # and the amended commit should also have no parents
        self.assertEqual(amended_commit.parents, original_commit.parents)

    def test_commit_amend_no_message(self) -> None:
        # Create initial commit
        filename = os.path.join(self.repo.path, "test.txt")
        with open(filename, "wb") as f:
            f.write(b"initial content")
        porcelain.add(self.repo.path, paths=["test.txt"])
        original_sha = porcelain.commit(self.repo.path, message=b"Original commit")

        # Modify file and stage it
        with open(filename, "wb") as f:
            f.write(b"modified content")
        porcelain.add(self.repo.path, paths=["test.txt"])

        # Amend without providing message (should reuse original message)
        amended_sha = porcelain.commit(self.repo.path, amend=True)

        self.assertIsInstance(amended_sha, bytes)
        self.assertEqual(len(amended_sha), 40)
        self.assertNotEqual(amended_sha, original_sha)

        # Check that the amended commit has the original message
        amended_commit = self.repo.get_object(amended_sha)
        assert isinstance(amended_commit, Commit)
        self.assertEqual(amended_commit.message, b"Original commit")

    def test_commit_amend_no_existing_commit(self) -> None:
        # Try to amend when there's no existing commit
        with self.assertRaises(ValueError) as cm:
            porcelain.commit(self.repo.path, message=b"Should fail", amend=True)
        self.assertIn("Cannot amend: no existing commit found", str(cm.exception))


@skipIf(
    platform.python_implementation() == "PyPy" or sys.platform == "win32",
    "gpgme not easily available or supported on Windows and PyPy",
)
class CommitSignTests(PorcelainGpgTestCase):
    def test_default_key(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        self.import_default_key()

        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=True,
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # GPG Signatures aren't deterministic, so we can't do a static assertion.
        from dulwich.signature import (
            BadSignature,
            UntrustedSignature,
            get_signature_vendor_for_signature,
        )

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)

        self.import_non_default_key()
        # Verify with wrong keyid - should raise UntrustedSignature
        vendor_wrong_keyid = get_signature_vendor_for_signature(
            commit.gpgsig, keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID]
        )
        self.assertRaises(
            UntrustedSignature,
            vendor_wrong_keyid.verify,
            commit.raw_without_sig(),
            commit.gpgsig,
        )

        assert isinstance(commit, Commit)
        commit.committer = b"Alice <alice@example.com>"
        self.assertRaises(
            BadSignature,
            vendor.verify,
            commit.raw_without_sig(),
            commit.gpgsig,
        )

    def test_non_default_key(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        self.import_non_default_key()

        sha = porcelain.commit(
            self.repo.path,
            message="Some message",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=PorcelainGpgTestCase.NON_DEFAULT_KEY_ID,
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # GPG Signatures aren't deterministic, so we can't do a static assertion.
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)

    def test_sign_uses_config_signingkey(self) -> None:
        """Test that sign=True uses user.signingKey from config."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit with sign=True (should use signingKey from config)
        sha = porcelain.commit(
            self.repo.path,
            message="Signed with configured key",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=True,  # This should read user.signingKey from config
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is signed with the configured key
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)

    def test_commit_gpg_sign_config_enabled(self) -> None:
        """Test that commit.gpgSign=true automatically signs commits."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey and commit.gpgSign in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("commit",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit without explicit signoff parameter (should auto-sign due to config)
        sha = porcelain.commit(
            self.repo.path,
            message="Auto-signed commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            # No signoff parameter - should use commit.gpgSign config
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is signed due to config
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)

    def test_commit_gpg_sign_config_disabled(self) -> None:
        """Test that commit.gpgSign=false does not sign commits."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey and commit.gpgSign=false in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("commit",), "gpgSign", False)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit without explicit signoff parameter (should not sign)
        sha = porcelain.commit(
            self.repo.path,
            message="Unsigned commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            # No signoff parameter - should use commit.gpgSign=false config
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is not signed
        self.assertIsNone(commit._gpgsig)

    def test_commit_gpg_sign_config_no_signing_key(self) -> None:
        """Test that commit.gpgSign=true works without user.signingKey (uses default)."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up commit.gpgSign but no user.signingKey
        cfg = self.repo.get_config()
        cfg.set(("commit",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit without explicit signoff parameter (should auto-sign with default key)
        sha = porcelain.commit(
            self.repo.path,
            message="Default signed commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            # No signoff parameter - should use commit.gpgSign config with default key
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is signed with default key
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)

    def test_explicit_signoff_overrides_config(self) -> None:
        """Test that explicit signoff parameter overrides commit.gpgSign config."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up commit.gpgSign=false but explicitly pass sign=True
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("commit",), "gpgSign", False)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit with explicit sign=True (should override config)
        sha = porcelain.commit(
            self.repo.path,
            message="Explicitly signed commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=True,  # This should override commit.gpgSign=false
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is signed despite config=false
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(commit.gpgsig)
        vendor = get_signature_vendor_for_signature(commit.gpgsig)
        vendor.verify(commit.raw_without_sig(), commit.gpgsig)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            commit.gpgsig, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(commit.raw_without_sig(), commit.gpgsig)

    def test_explicit_false_disables_signing(self) -> None:
        """Test that explicit signoff=False disables signing even with config=true."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up commit.gpgSign=true but explicitly pass sign=False
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("commit",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create commit with explicit sign=False (should disable signing)
        sha = porcelain.commit(
            self.repo.path,
            message="Explicitly unsigned commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=False,  # This should override commit.gpgSign=true
        )

        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)

        commit = self.repo.get_object(sha)
        assert isinstance(commit, Commit)
        # Verify the commit is NOT signed despite config=true
        self.assertIsNone(commit._gpgsig)


@skipIf(
    platform.python_implementation() == "PyPy" or sys.platform == "win32",
    "gpgme not easily available or supported on Windows and PyPy",
)
class VerifyCommitTests(PorcelainGpgTestCase):
    def test_verify_commit_valid_signature(self) -> None:
        """Test verifying a commit with a valid GPG signature."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()
        self.import_default_key()

        # Create a signed commit
        sha = porcelain.commit(
            self.repo.path,
            message="Signed commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=True,
        )

        # Verify should not raise
        porcelain.verify_commit(self.repo.path, sha)
        porcelain.verify_commit(
            self.repo.path, sha, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )

    def test_verify_commit_with_wrong_key(self) -> None:
        """Test that verifying with wrong keyid raises UntrustedSignature."""
        from dulwich.signature import UntrustedSignature

        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()
        self.import_default_key()

        sha = porcelain.commit(
            self.repo.path,
            message="Signed commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=True,
        )

        self.import_non_default_key()
        self.assertRaises(
            UntrustedSignature,
            porcelain.verify_commit,
            self.repo.path,
            sha,
            keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
        )

    def test_verify_commit_unsigned(self) -> None:
        """Test that verifying an unsigned commit succeeds (no signature to verify)."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        sha = porcelain.commit(
            self.repo.path,
            message="Unsigned commit",
            author="Joe <joe@example.com>",
            committer="Bob <bob@example.com>",
            sign=False,
        )

        # Verify should not raise for unsigned commits
        porcelain.verify_commit(self.repo.path, sha)


@skipIf(
    platform.python_implementation() == "PyPy" or sys.platform == "win32",
    "gpgme not easily available or supported on Windows and PyPy",
)
class VerifyTagTests(PorcelainGpgTestCase):
    def test_verify_tag_valid_signature(self) -> None:
        """Test verifying a tag with a valid GPG signature."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()
        self.import_default_key()

        # Create a signed tag
        porcelain.tag_create(
            self.repo.path,
            b"signed-tag",
            b"Tagger <tagger@example.com>",
            b"Signed tag message",
            annotated=True,
            sign=True,
        )

        # Verify should not raise
        porcelain.verify_tag(self.repo.path, b"signed-tag")
        porcelain.verify_tag(
            self.repo.path, b"signed-tag", keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )

    def test_verify_tag_with_wrong_key(self) -> None:
        """Test that verifying with wrong keyid raises UntrustedSignature."""
        from dulwich.signature import UntrustedSignature

        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()
        self.import_default_key()

        porcelain.tag_create(
            self.repo.path,
            b"signed-tag",
            b"Tagger <tagger@example.com>",
            b"Signed tag message",
            annotated=True,
            sign=True,
        )

        self.import_non_default_key()
        self.assertRaises(
            UntrustedSignature,
            porcelain.verify_tag,
            self.repo.path,
            b"signed-tag",
            keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID],
        )

    def test_verify_tag_unsigned(self) -> None:
        """Test that verifying an unsigned tag succeeds (no signature to verify)."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.tag_create(
            self.repo.path,
            b"unsigned-tag",
            b"Tagger <tagger@example.com>",
            b"Unsigned tag message",
            annotated=True,
            sign=False,
        )

        # Verify should not raise for unsigned tags
        porcelain.verify_tag(self.repo.path, b"unsigned-tag")


class TimezoneTests(PorcelainTestCase):
    def put_envs(self, value) -> None:
        self.overrideEnv("GIT_AUTHOR_DATE", value)
        self.overrideEnv("GIT_COMMITTER_DATE", value)

    def fallback(self, value) -> None:
        self.put_envs(value)
        self.assertRaises(porcelain.TimezoneFormatError, porcelain.get_user_timezones)

    def test_internal_format(self) -> None:
        self.put_envs("0 +0500")
        self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())

    def test_rfc_2822(self) -> None:
        self.put_envs("Mon, 20 Nov 1995 19:12:08 -0500")
        self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())

        self.put_envs("Mon, 20 Nov 1995 19:12:08")
        self.assertTupleEqual((0, 0), porcelain.get_user_timezones())

    def test_iso8601(self) -> None:
        self.put_envs("1995-11-20T19:12:08-0501")
        self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())

        self.put_envs("1995-11-20T19:12:08+0501")
        self.assertTupleEqual((18060, 18060), porcelain.get_user_timezones())

        self.put_envs("1995-11-20T19:12:08-05:01")
        self.assertTupleEqual((-18060, -18060), porcelain.get_user_timezones())

        self.put_envs("1995-11-20 19:12:08-05")
        self.assertTupleEqual((-18000, -18000), porcelain.get_user_timezones())

        # https://github.com/git/git/blob/96b2d4fa927c5055adc5b1d08f10a5d7352e2989/t/t6300-for-each-ref.sh#L128
        self.put_envs("2006-07-03 17:18:44 +0200")
        self.assertTupleEqual((7200, 7200), porcelain.get_user_timezones())

    def test_missing_or_malformed(self) -> None:
        # TODO: add more here
        self.fallback("0 + 0500")
        self.fallback("a +0500")

        self.fallback("1995-11-20T19:12:08")
        self.fallback("1995-11-20T19:12:08-05:")

        self.fallback("1995.11.20")
        self.fallback("11/20/1995")
        self.fallback("20.11.1995")

    def test_different_envs(self) -> None:
        self.overrideEnv("GIT_AUTHOR_DATE", "0 +0500")
        self.overrideEnv("GIT_COMMITTER_DATE", "0 +0501")
        self.assertTupleEqual((18000, 18060), porcelain.get_user_timezones())

    def test_no_envs(self) -> None:
        local_timezone = time.localtime().tm_gmtoff

        self.put_envs("0 +0500")
        self.assertTupleEqual((18000, 18000), porcelain.get_user_timezones())

        self.overrideEnv("GIT_COMMITTER_DATE", None)
        self.assertTupleEqual((18000, local_timezone), porcelain.get_user_timezones())

        self.put_envs("0 +0500")
        self.overrideEnv("GIT_AUTHOR_DATE", None)
        self.assertTupleEqual((local_timezone, 18000), porcelain.get_user_timezones())

        self.put_envs("0 +0500")
        self.overrideEnv("GIT_AUTHOR_DATE", None)
        self.overrideEnv("GIT_COMMITTER_DATE", None)
        self.assertTupleEqual(
            (local_timezone, local_timezone), porcelain.get_user_timezones()
        )


class CleanTests(PorcelainTestCase):
    def put_files(self, tracked, ignored, untracked, empty_dirs) -> None:
        """Put the described files in the wd."""
        all_files = tracked | ignored | untracked
        for file_path in all_files:
            abs_path = os.path.join(self.repo.path, file_path)
            # File may need to be written in a dir that doesn't exist yet, so
            # create the parent dir(s) as necessary
            parent_dir = os.path.dirname(abs_path)
            try:
                os.makedirs(parent_dir)
            except FileExistsError:
                pass
            with open(abs_path, "w") as f:
                f.write("")

        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.writelines(ignored)

        for dir_path in empty_dirs:
            os.mkdir(os.path.join(self.repo.path, "empty_dir"))

        files_to_add = [os.path.join(self.repo.path, t) for t in tracked]
        porcelain.add(repo=self.repo.path, paths=files_to_add)
        porcelain.commit(repo=self.repo.path, message="init commit")

    def assert_wd(self, expected_paths) -> None:
        """Assert paths of files and dirs in wd are same as expected_paths."""
        control_dir_rel = os.path.relpath(self.repo._controldir, self.repo.path)

        # normalize paths to simplify comparison across platforms
        found_paths = {
            os.path.normpath(p)
            for p in flat_walk_dir(self.repo.path)
            if not p.split(os.sep)[0] == control_dir_rel
        }
        norm_expected_paths = {os.path.normpath(p) for p in expected_paths}
        self.assertEqual(found_paths, norm_expected_paths)

    def test_from_root(self) -> None:
        self.put_files(
            tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
            ignored={"ignored_file"},
            untracked={
                "untracked_file",
                "tracked_dir/untracked_dir/untracked_file",
                "untracked_dir/untracked_dir/untracked_file",
            },
            empty_dirs={"empty_dir"},
        )

        porcelain.clean(repo=self.repo.path, target_dir=self.repo.path)

        self.assert_wd(
            {
                "tracked_file",
                "tracked_dir/tracked_file",
                ".gitignore",
                "ignored_file",
                "tracked_dir",
            }
        )

    def test_from_subdir(self) -> None:
        self.put_files(
            tracked={"tracked_file", "tracked_dir/tracked_file", ".gitignore"},
            ignored={"ignored_file"},
            untracked={
                "untracked_file",
                "tracked_dir/untracked_dir/untracked_file",
                "untracked_dir/untracked_dir/untracked_file",
            },
            empty_dirs={"empty_dir"},
        )

        porcelain.clean(
            repo=self.repo,
            target_dir=os.path.join(self.repo.path, "untracked_dir"),
        )

        self.assert_wd(
            {
                "tracked_file",
                "tracked_dir/tracked_file",
                ".gitignore",
                "ignored_file",
                "untracked_file",
                "tracked_dir/untracked_dir/untracked_file",
                "empty_dir",
                "untracked_dir",
                "tracked_dir",
                "tracked_dir/untracked_dir",
            }
        )


class CloneTests(PorcelainTestCase):
    def test_simple_local(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1], [2, 1], [3, 1, 2]]
        trees = {
            1: [(b"f1", f1_1), (b"f2", f1_1)],
            2: [(b"f1", f1_1), (b"f2", f1_1)],
            3: [(b"f1", f1_1), (b"f2", f1_1)],
        }

        _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/tags/foo"] = c3.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)
        r = porcelain.clone(
            self.repo.path, target_path, checkout=False, errstream=errstream
        )
        self.addCleanup(r.close)
        self.assertEqual(r.path, target_path)
        target_repo = Repo(target_path)
        self.addCleanup(target_repo.close)
        self.assertEqual(0, len(target_repo.open_index()))
        self.assertEqual(c3.id, target_repo.refs[b"refs/tags/foo"])
        self.assertNotIn(b"f1", os.listdir(target_path))
        self.assertNotIn(b"f2", os.listdir(target_path))
        c = r.get_config()
        encoded_path = self.repo.path
        if not isinstance(encoded_path, bytes):
            encoded_path_bytes = encoded_path.encode("utf-8")
        else:
            encoded_path_bytes = encoded_path
        self.assertEqual(encoded_path_bytes, c.get((b"remote", b"origin"), b"url"))
        self.assertEqual(
            b"+refs/heads/*:refs/remotes/origin/*",
            c.get((b"remote", b"origin"), b"fetch"),
        )

    def test_simple_local_with_checkout(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1], [2, 1], [3, 1, 2]]
        trees = {
            1: [(b"f1", f1_1), (b"f2", f1_1)],
            2: [(b"f1", f1_1), (b"f2", f1_1)],
            3: [(b"f1", f1_1), (b"f2", f1_1)],
        }

        _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c3.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)
        with porcelain.clone(
            self.repo.path, target_path, checkout=True, errstream=errstream
        ) as r:
            self.assertEqual(r.path, target_path)
        with Repo(target_path) as r:
            self.assertEqual(r.head(), c3.id)
        self.assertIn("f1", os.listdir(target_path))
        self.assertIn("f2", os.listdir(target_path))

    def test_bare_local_with_checkout(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1], [2, 1], [3, 1, 2]]
        trees = {
            1: [(b"f1", f1_1), (b"f2", f1_1)],
            2: [(b"f1", f1_1), (b"f2", f1_1)],
            3: [(b"f1", f1_1), (b"f2", f1_1)],
        }

        _c1, _c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c3.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)
        with porcelain.clone(
            self.repo.path, target_path, bare=True, errstream=errstream
        ) as r:
            self.assertEqual(r.path, target_path)
        with Repo(target_path) as r:
            r.head()
            self.assertRaises(NoIndexPresent, r.open_index)
        self.assertNotIn(b"f1", os.listdir(target_path))
        self.assertNotIn(b"f2", os.listdir(target_path))

    def test_no_checkout_with_bare(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1]]
        trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}

        (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c1.id
        self.repo.refs[b"HEAD"] = c1.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)
        self.assertRaises(
            porcelain.Error,
            porcelain.clone,
            self.repo.path,
            target_path,
            checkout=True,
            bare=True,
            errstream=errstream,
        )

    def test_no_head_no_checkout(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1]]
        trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}

        (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c1.id
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        errstream = BytesIO()
        r = porcelain.clone(
            self.repo.path, target_path, checkout=True, errstream=errstream
        )
        r.close()

    def test_no_head_no_checkout_outstream_errstream_autofallback(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1]]
        trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}

        (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c1.id
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        errstream = porcelain.NoneStream()
        r = porcelain.clone(
            self.repo.path, target_path, checkout=True, errstream=errstream
        )
        r.close()

    def test_source_broken(self) -> None:
        with tempfile.TemporaryDirectory() as parent:
            target_path = os.path.join(parent, "target")
            self.assertRaises(
                Exception, porcelain.clone, "/nonexistent/repo", target_path
            )
            self.assertFalse(os.path.exists(target_path))

    def test_fetch_symref(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        trees = {1: [(b"f1", f1_1), (b"f2", f1_1)]}
        [c1] = build_commit_graph(self.repo.object_store, [[1]], trees)
        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/else")
        self.repo.refs[b"refs/heads/else"] = c1.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)
        r = porcelain.clone(
            self.repo.path, target_path, checkout=False, errstream=errstream
        )
        self.addCleanup(r.close)
        self.assertEqual(r.path, target_path)
        target_repo = Repo(target_path)
        self.addCleanup(target_repo.close)
        self.assertEqual(0, len(target_repo.open_index()))
        self.assertEqual(c1.id, target_repo.refs[b"refs/heads/else"])
        self.assertEqual(c1.id, target_repo.refs[b"HEAD"])
        self.assertEqual(
            {
                b"HEAD": b"refs/heads/else",
                b"refs/remotes/origin/HEAD": b"refs/remotes/origin/else",
            },
            target_repo.refs.get_symrefs(),
        )

    def test_detached_head(self) -> None:
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1], [2, 1], [3, 1, 2]]
        trees = {
            1: [(b"f1", f1_1), (b"f2", f1_1)],
            2: [(b"f1", f1_1), (b"f2", f1_1)],
            3: [(b"f1", f1_1), (b"f2", f1_1)],
        }

        _c1, c2, c3 = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs.remove_if_equals(b"HEAD", None)
        self.repo.refs[b"HEAD"] = c3.id
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        errstream = porcelain.NoneStream()
        with porcelain.clone(
            self.repo.path, target_path, checkout=True, errstream=errstream
        ) as r:
            self.assertEqual(c3.id, r.refs[b"HEAD"])

    def test_clone_pathlib(self) -> None:
        from pathlib import Path

        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1]]
        trees = {1: [(b"f1", f1_1)]}

        c1 = build_commit_graph(self.repo.object_store, commit_spec, trees)[0]
        self.repo.refs[b"refs/heads/master"] = c1.id

        target_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_dir)
        target_path = Path(target_dir) / "clone_repo"

        errstream = BytesIO()
        r = porcelain.clone(
            self.repo.path, target_path, checkout=False, errstream=errstream
        )
        self.addCleanup(r.close)
        self.assertEqual(r.path, str(target_path))
        self.assertTrue(os.path.exists(str(target_path)))

    def test_clone_with_recurse_submodules(self) -> None:
        # Create a submodule repository
        sub_repo_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, sub_repo_path)
        sub_repo = Repo.init(sub_repo_path)
        self.addCleanup(sub_repo.close)

        # Add a file to the submodule repo
        sub_file = os.path.join(sub_repo_path, "subfile.txt")
        with open(sub_file, "w") as f:
            f.write("submodule content")

        porcelain.add(sub_repo, paths=[sub_file])
        sub_commit = porcelain.commit(
            sub_repo,
            message=b"Initial submodule commit",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Create main repository with submodule
        main_file = os.path.join(self.repo.path, "main.txt")
        with open(main_file, "w") as f:
            f.write("main content")

        porcelain.add(self.repo, paths=[main_file])
        porcelain.submodule_add(self.repo, sub_repo_path, "sub")

        # Manually add the submodule to the index since submodule_add doesn't do it
        # when the repository is local (to maintain backward compatibility)
        from dulwich.index import IndexEntry
        from dulwich.objects import S_IFGITLINK

        index = self.repo.open_index()
        index[b"sub"] = IndexEntry(
            ctime=0,
            mtime=0,
            dev=0,
            ino=0,
            mode=S_IFGITLINK,
            uid=0,
            gid=0,
            size=0,
            sha=sub_commit,
            flags=0,
        )
        index.write()

        porcelain.add(self.repo, paths=[".gitmodules"])
        porcelain.commit(
            self.repo,
            message=b"Add submodule",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Clone with recurse_submodules
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)

        cloned = porcelain.clone(
            self.repo.path,
            target_path,
            recurse_submodules=True,
        )
        self.addCleanup(cloned.close)

        # Check main file exists
        cloned_main = os.path.join(target_path, "main.txt")
        self.assertTrue(os.path.exists(cloned_main))
        with open(cloned_main) as f:
            self.assertEqual(f.read(), "main content")

        # Check submodule file exists
        cloned_sub_file = os.path.join(target_path, "sub", "subfile.txt")
        self.assertTrue(os.path.exists(cloned_sub_file))
        with open(cloned_sub_file) as f:
            self.assertEqual(f.read(), "submodule content")

    def test_clone_with_refspec_kwarg(self) -> None:
        """Test that clone accepts refspec as a kwarg without TypeError.

        This reproduces a bug where passing refspec as a kwarg to clone()
        would cause a TypeError because it was being passed to
        get_transport_and_path() which doesn't accept that parameter.
        """
        f1_1 = make_object(Blob, data=b"f1")
        commit_spec = [[1]]
        trees = {1: [(b"f1", f1_1)]}

        (c1,) = build_commit_graph(self.repo.object_store, commit_spec, trees)
        self.repo.refs[b"refs/heads/master"] = c1.id
        target_path = tempfile.mkdtemp()
        errstream = BytesIO()
        self.addCleanup(shutil.rmtree, target_path)

        # This should not raise TypeError
        r = porcelain.clone(
            self.repo.path,
            target_path,
            checkout=False,
            errstream=errstream,
            refspec="refs/heads/master",
        )
        self.addCleanup(r.close)
        self.assertEqual(r.path, target_path)


class InitTests(TestCase):
    def test_non_bare(self) -> None:
        repo_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, repo_dir)
        porcelain.init(repo_dir)

    def test_bare(self) -> None:
        repo_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, repo_dir)
        porcelain.init(repo_dir, bare=True)

    def test_init_pathlib(self) -> None:
        from pathlib import Path

        repo_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, repo_dir)
        repo_path = Path(repo_dir)

        # Test non-bare repo with pathlib
        repo = porcelain.init(repo_path)
        self.assertTrue(os.path.exists(os.path.join(repo_dir, ".git")))
        repo.close()

    def test_init_bare_pathlib(self) -> None:
        from pathlib import Path

        repo_dir = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, repo_dir)
        repo_path = Path(repo_dir)

        # Test bare repo with pathlib
        repo = porcelain.init(repo_path, bare=True)
        self.assertTrue(os.path.exists(os.path.join(repo_dir, "refs")))
        repo.close()


class AddTests(PorcelainTestCase):
    def test_add_default_paths(self) -> None:
        # create a file for initial commit
        fullpath = os.path.join(self.repo.path, "blah")
        with open(fullpath, "w") as f:
            f.write("\n")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Add a second test file and a file in a directory
        with open(os.path.join(self.repo.path, "foo"), "w") as f:
            f.write("\n")
        os.mkdir(os.path.join(self.repo.path, "adir"))
        with open(os.path.join(self.repo.path, "adir", "afile"), "w") as f:
            f.write("\n")
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(self.repo.path)
        self.assertEqual({"foo", "blah", "adir", ".git"}, set(os.listdir(".")))
        added, ignored = porcelain.add(self.repo.path)
        # Normalize paths to use forward slashes for comparison
        added_normalized = [path.replace(os.sep, "/") for path in added]
        self.assertEqual(
            (added_normalized, ignored),
            (["foo", "adir/afile"], set()),
        )

        # Check that foo was added and nothing in .git was modified
        index = self.repo.open_index()
        self.assertEqual(sorted(index), [b"adir/afile", b"blah", b"foo"])

    def test_add_default_paths_subdir(self) -> None:
        os.mkdir(os.path.join(self.repo.path, "foo"))
        with open(os.path.join(self.repo.path, "blah"), "w") as f:
            f.write("\n")
        with open(os.path.join(self.repo.path, "foo", "blie"), "w") as f:
            f.write("\n")

        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(os.path.join(self.repo.path, "foo"))
        porcelain.add(repo=self.repo.path)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        index = self.repo.open_index()
        # After fix: add() with no paths should behave like git add -A (add everything)
        self.assertEqual(sorted(index), [b"blah", b"foo/blie"])

    def test_add_file(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        self.assertIn(b"foo", self.repo.open_index())

    def test_add_ignored(self) -> None:
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("foo\nsubdir/")
        with open(os.path.join(self.repo.path, "foo"), "w") as f:
            f.write("BAR")
        with open(os.path.join(self.repo.path, "bar"), "w") as f:
            f.write("BAR")
        os.mkdir(os.path.join(self.repo.path, "subdir"))
        with open(os.path.join(self.repo.path, "subdir", "baz"), "w") as f:
            f.write("BAZ")
        (added, ignored) = porcelain.add(
            self.repo.path,
            paths=[
                os.path.join(self.repo.path, "foo"),
                os.path.join(self.repo.path, "bar"),
                os.path.join(self.repo.path, "subdir"),
            ],
        )
        self.assertIn(b"bar", self.repo.open_index())
        self.assertEqual({"bar"}, set(added))
        self.assertEqual({"foo", "subdir/"}, ignored)

    def test_add_from_ignored_directory(self) -> None:
        # Test for issue #550 - adding files when cwd is in ignored directory
        # Create .gitignore that ignores build/
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("build/\n")

        # Create ignored directory
        build_dir = os.path.join(self.repo.path, "build")
        os.mkdir(build_dir)

        # Create a file in the repo (not in ignored directory)
        src_file = os.path.join(self.repo.path, "source.py")
        with open(src_file, "w") as f:
            f.write("print('hello')\n")

        # Save current directory and change to ignored directory
        original_cwd = os.getcwd()
        try:
            os.chdir(build_dir)
            # Add file using absolute path from within ignored directory
            (added, _ignored) = porcelain.add(self.repo.path, paths=[src_file])
            self.assertIn(b"source.py", self.repo.open_index())
            self.assertEqual({"source.py"}, set(added))
        finally:
            os.chdir(original_cwd)

    def test_add_file_absolute_path(self) -> None:
        # Absolute paths are (not yet) supported
        with open(os.path.join(self.repo.path, "foo"), "w") as f:
            f.write("BAR")
        porcelain.add(self.repo, paths=[os.path.join(self.repo.path, "foo")])
        self.assertIn(b"foo", self.repo.open_index())

    def test_add_not_in_repo(self) -> None:
        with open(os.path.join(self.test_dir, "foo"), "w") as f:
            f.write("BAR")
        self.assertRaises(
            ValueError,
            porcelain.add,
            self.repo,
            paths=[os.path.join(self.test_dir, "foo")],
        )
        self.assertRaises(
            (ValueError, FileNotFoundError),
            porcelain.add,
            self.repo,
            paths=["../foo"],
        )
        self.assertEqual([], list(self.repo.open_index()))

    def test_add_file_clrf_conversion(self) -> None:
        from dulwich.index import IndexEntry

        # Set the right configuration to the repo
        c = self.repo.get_config()
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Add a file with CRLF line-ending
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "wb") as f:
            f.write(b"line1\r\nline2")
        porcelain.add(self.repo.path, paths=[fullpath])

        # The line-endings should have been converted to LF
        index = self.repo.open_index()
        self.assertIn(b"foo", index)

        entry = index[b"foo"]
        assert isinstance(entry, IndexEntry)
        blob = self.repo[entry.sha]
        self.assertEqual(blob.data, b"line1\nline2")

    def test_add_symlink_outside_repo(self) -> None:
        """Test adding a symlink that points outside the repository."""
        # Create a symlink pointing outside the repository
        symlink_path = os.path.join(self.repo.path, "symlink_to_nowhere")
        os.symlink("/nonexistent/path", symlink_path)

        # Adding the symlink should succeed (matching Git's behavior)
        added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])

        # Should successfully add the symlink
        self.assertIn("symlink_to_nowhere", added)
        self.assertEqual(len(ignored), 0)

        # Verify symlink is actually staged
        index = self.repo.open_index()
        self.assertIn(b"symlink_to_nowhere", index)

    def test_add_symlink_to_file_inside_repo(self) -> None:
        """Test adding a symlink that points to a file inside the repository."""
        # Create a regular file
        target_file = os.path.join(self.repo.path, "target.txt")
        with open(target_file, "w") as f:
            f.write("target content")

        # Create a symlink to the file
        symlink_path = os.path.join(self.repo.path, "link_to_target")
        os.symlink("target.txt", symlink_path)

        # Add both the target and the symlink
        added, ignored = porcelain.add(
            self.repo.path, paths=[target_file, symlink_path]
        )

        # Both should be added successfully
        self.assertIn("target.txt", added)
        self.assertIn("link_to_target", added)
        self.assertEqual(len(ignored), 0)

        # Verify both are in the index
        index = self.repo.open_index()
        self.assertIn(b"target.txt", index)
        self.assertIn(b"link_to_target", index)

    def test_add_symlink_to_directory_inside_repo(self) -> None:
        """Test adding a symlink that points to a directory inside the repository."""
        # Create a directory with some files
        target_dir = os.path.join(self.repo.path, "target_dir")
        os.mkdir(target_dir)
        with open(os.path.join(target_dir, "file1.txt"), "w") as f:
            f.write("content1")
        with open(os.path.join(target_dir, "file2.txt"), "w") as f:
            f.write("content2")

        # Create a symlink to the directory
        symlink_path = os.path.join(self.repo.path, "link_to_dir")
        os.symlink("target_dir", symlink_path)

        # Add the symlink
        added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])

        # When adding a symlink to a directory, it follows the symlink and adds contents
        self.assertEqual(len(added), 2)
        self.assertIn("link_to_dir/file1.txt", added)
        self.assertIn("link_to_dir/file2.txt", added)
        self.assertEqual(len(ignored), 0)

        # Verify files are added through the symlink path
        index = self.repo.open_index()
        self.assertIn(b"link_to_dir/file1.txt", index)
        self.assertIn(b"link_to_dir/file2.txt", index)
        # The original target directory files are not added
        self.assertNotIn(b"target_dir/file1.txt", index)
        self.assertNotIn(b"target_dir/file2.txt", index)

    def test_add_symlink_chain(self) -> None:
        """Test adding a chain of symlinks (symlink to symlink)."""
        # Create a regular file
        target_file = os.path.join(self.repo.path, "original.txt")
        with open(target_file, "w") as f:
            f.write("original content")

        # Create first symlink
        first_link = os.path.join(self.repo.path, "link1")
        os.symlink("original.txt", first_link)

        # Create second symlink pointing to first
        second_link = os.path.join(self.repo.path, "link2")
        os.symlink("link1", second_link)

        # Add all files
        added, _ignored = porcelain.add(
            self.repo.path, paths=[target_file, first_link, second_link]
        )

        # All should be added
        self.assertEqual(len(added), 3)
        self.assertIn("original.txt", added)
        self.assertIn("link1", added)
        self.assertIn("link2", added)

        # Verify all are in the index
        index = self.repo.open_index()
        self.assertIn(b"original.txt", index)
        self.assertIn(b"link1", index)
        self.assertIn(b"link2", index)

    def test_add_broken_symlink(self) -> None:
        """Test adding a broken symlink (points to non-existent target)."""
        # Create a symlink to a non-existent file
        broken_link = os.path.join(self.repo.path, "broken_link")
        os.symlink("does_not_exist.txt", broken_link)

        # Add the broken symlink
        added, ignored = porcelain.add(self.repo.path, paths=[broken_link])

        # Should be added successfully (Git tracks the symlink, not its target)
        self.assertIn("broken_link", added)
        self.assertEqual(len(ignored), 0)

        # Verify it's in the index
        index = self.repo.open_index()
        self.assertIn(b"broken_link", index)

    def test_add_symlink_relative_outside_repo(self) -> None:
        """Test adding a symlink that uses '..' to point outside the repository."""
        # Create a file outside the repo
        outside_file = os.path.join(self.test_dir, "outside.txt")
        with open(outside_file, "w") as f:
            f.write("outside content")

        # Create a symlink using relative path to go outside
        symlink_path = os.path.join(self.repo.path, "link_outside")
        os.symlink("../outside.txt", symlink_path)

        # Add the symlink
        added, ignored = porcelain.add(self.repo.path, paths=[symlink_path])

        # Should be added successfully
        self.assertIn("link_outside", added)
        self.assertEqual(len(ignored), 0)

        # Verify it's in the index
        index = self.repo.open_index()
        self.assertIn(b"link_outside", index)

    def test_add_symlink_absolute_to_system(self) -> None:
        """Test adding a symlink with absolute path to system directory."""
        # Create a symlink to a system directory
        symlink_path = os.path.join(self.repo.path, "link_to_tmp")
        if os.name == "nt":
            # On Windows, use a system directory like TEMP
            symlink_target = os.environ["TEMP"]
        else:
            # On Unix-like systems, use /tmp
            symlink_target = os.environ.get("TMPDIR", "/tmp")
        os.symlink(symlink_target, symlink_path)

        # Adding a symlink to a directory outside the repo should raise ValueError
        with self.assertRaises(ValueError) as cm:
            porcelain.add(self.repo.path, paths=[symlink_path])

        # Check that the error indicates the path is outside the repository
        self.assertIn("is not in the subpath of", str(cm.exception))

    def test_add_file_through_symlink(self) -> None:
        """Test adding a file through a symlinked directory."""
        # Create a directory with a file
        real_dir = os.path.join(self.repo.path, "real_dir")
        os.mkdir(real_dir)
        real_file = os.path.join(real_dir, "file.txt")
        with open(real_file, "w") as f:
            f.write("content")

        # Create a symlink to the directory
        link_dir = os.path.join(self.repo.path, "link_dir")
        os.symlink("real_dir", link_dir)

        # Try to add the file through the symlink path
        symlink_file_path = os.path.join(link_dir, "file.txt")

        # This should add the real file, not create a new entry
        added, _ignored = porcelain.add(self.repo.path, paths=[symlink_file_path])

        # The real file should be added
        self.assertIn("real_dir/file.txt", added)
        self.assertEqual(len(added), 1)

        # Verify correct path in index
        index = self.repo.open_index()
        self.assertIn(b"real_dir/file.txt", index)
        # Should not create a separate entry for the symlink path
        self.assertNotIn(b"link_dir/file.txt", index)

    def test_add_repo_path(self) -> None:
        """Test adding the repository path itself should add all untracked files."""
        # Create some untracked files
        with open(os.path.join(self.repo.path, "file1.txt"), "w") as f:
            f.write("content1")
        with open(os.path.join(self.repo.path, "file2.txt"), "w") as f:
            f.write("content2")

        # Add the repository path itself
        added, _ignored = porcelain.add(self.repo.path, paths=[self.repo.path])

        # Should add all untracked files, not stage './'
        self.assertIn("file1.txt", added)
        self.assertIn("file2.txt", added)
        self.assertNotIn("./", added)

        # Verify files are actually staged
        index = self.repo.open_index()
        self.assertIn(b"file1.txt", index)
        self.assertIn(b"file2.txt", index)

    def test_add_directory_contents(self) -> None:
        """Test adding a directory adds all files within it."""
        # Create a subdirectory with multiple files
        subdir = os.path.join(self.repo.path, "subdir")
        os.mkdir(subdir)
        with open(os.path.join(subdir, "file1.txt"), "w") as f:
            f.write("content1")
        with open(os.path.join(subdir, "file2.txt"), "w") as f:
            f.write("content2")
        with open(os.path.join(subdir, "file3.txt"), "w") as f:
            f.write("content3")

        # Add the directory
        added, _ignored = porcelain.add(self.repo.path, paths=["subdir"])

        # Should add all files in the directory
        self.assertEqual(len(added), 3)
        # Normalize paths to use forward slashes for comparison
        added_normalized = [path.replace(os.sep, "/") for path in added]
        self.assertIn("subdir/file1.txt", added_normalized)
        self.assertIn("subdir/file2.txt", added_normalized)
        self.assertIn("subdir/file3.txt", added_normalized)

        # Verify files are actually staged
        index = self.repo.open_index()
        self.assertIn(b"subdir/file1.txt", index)
        self.assertIn(b"subdir/file2.txt", index)
        self.assertIn(b"subdir/file3.txt", index)

    def test_add_nested_directories(self) -> None:
        """Test adding a directory with nested subdirectories."""
        # Create nested directory structure
        dir1 = os.path.join(self.repo.path, "dir1")
        dir2 = os.path.join(dir1, "dir2")
        dir3 = os.path.join(dir2, "dir3")
        os.makedirs(dir3)

        # Add files at each level
        with open(os.path.join(dir1, "file1.txt"), "w") as f:
            f.write("level1")
        with open(os.path.join(dir2, "file2.txt"), "w") as f:
            f.write("level2")
        with open(os.path.join(dir3, "file3.txt"), "w") as f:
            f.write("level3")

        # Add the top-level directory
        added, _ignored = porcelain.add(self.repo.path, paths=["dir1"])

        # Should add all files recursively
        self.assertEqual(len(added), 3)
        # Normalize paths to use forward slashes for comparison
        added_normalized = [path.replace(os.sep, "/") for path in added]
        self.assertIn("dir1/file1.txt", added_normalized)
        self.assertIn("dir1/dir2/file2.txt", added_normalized)
        self.assertIn("dir1/dir2/dir3/file3.txt", added_normalized)

        # Verify files are actually staged
        index = self.repo.open_index()
        self.assertIn(b"dir1/file1.txt", index)
        self.assertIn(b"dir1/dir2/file2.txt", index)
        self.assertIn(b"dir1/dir2/dir3/file3.txt", index)

    def test_add_directory_with_tracked_files(self) -> None:
        """Test adding a directory with some files already tracked."""
        # Create a subdirectory with files
        subdir = os.path.join(self.repo.path, "mixed")
        os.mkdir(subdir)

        # Create and commit one file
        tracked_file = os.path.join(subdir, "tracked.txt")
        with open(tracked_file, "w") as f:
            f.write("already tracked")
        porcelain.add(self.repo.path, paths=[tracked_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add tracked file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Add more untracked files
        with open(os.path.join(subdir, "untracked1.txt"), "w") as f:
            f.write("new file 1")
        with open(os.path.join(subdir, "untracked2.txt"), "w") as f:
            f.write("new file 2")

        # Add the directory
        added, _ignored = porcelain.add(self.repo.path, paths=["mixed"])

        # Should only add the untracked files
        self.assertEqual(len(added), 2)
        # Normalize paths to use forward slashes for comparison
        added_normalized = [path.replace(os.sep, "/") for path in added]
        self.assertIn("mixed/untracked1.txt", added_normalized)
        self.assertIn("mixed/untracked2.txt", added_normalized)
        self.assertNotIn("mixed/tracked.txt", added)

        # Verify the index contains all files
        index = self.repo.open_index()
        self.assertIn(b"mixed/tracked.txt", index)
        self.assertIn(b"mixed/untracked1.txt", index)
        self.assertIn(b"mixed/untracked2.txt", index)

    def test_add_directory_with_gitignore(self) -> None:
        """Test adding a directory respects .gitignore patterns."""
        # Create .gitignore
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("*.log\n*.tmp\nbuild/\n")

        # Create directory with mixed files
        testdir = os.path.join(self.repo.path, "testdir")
        os.mkdir(testdir)

        # Create various files
        with open(os.path.join(testdir, "important.txt"), "w") as f:
            f.write("keep this")
        with open(os.path.join(testdir, "debug.log"), "w") as f:
            f.write("ignore this")
        with open(os.path.join(testdir, "temp.tmp"), "w") as f:
            f.write("ignore this too")
        with open(os.path.join(testdir, "readme.md"), "w") as f:
            f.write("keep this too")

        # Create a build directory that should be ignored
        builddir = os.path.join(testdir, "build")
        os.mkdir(builddir)
        with open(os.path.join(builddir, "output.txt"), "w") as f:
            f.write("ignore entire directory")

        # Add the directory
        added, ignored = porcelain.add(self.repo.path, paths=["testdir"])

        # Should only add non-ignored files
        # Normalize paths to use forward slashes for comparison
        added_normalized = {path.replace(os.sep, "/") for path in added}
        self.assertEqual(
            added_normalized, {"testdir/important.txt", "testdir/readme.md"}
        )

        # Check ignored files
        # Normalize paths to use forward slashes for comparison
        ignored_normalized = {path.replace(os.sep, "/") for path in ignored}
        self.assertIn("testdir/debug.log", ignored_normalized)
        self.assertIn("testdir/temp.tmp", ignored_normalized)
        self.assertIn("testdir/build/", ignored_normalized)

    def test_add_multiple_directories(self) -> None:
        """Test adding multiple directories in one call."""
        # Create multiple directories
        for dirname in ["dir1", "dir2", "dir3"]:
            dirpath = os.path.join(self.repo.path, dirname)
            os.mkdir(dirpath)
            # Add files to each directory
            for i in range(2):
                with open(os.path.join(dirpath, f"file{i}.txt"), "w") as f:
                    f.write(f"content {dirname} {i}")

        # Add all directories at once
        added, _ignored = porcelain.add(self.repo.path, paths=["dir1", "dir2", "dir3"])

        # Should add all files from all directories
        self.assertEqual(len(added), 6)
        # Normalize paths to use forward slashes for comparison
        added_normalized = [path.replace(os.sep, "/") for path in added]
        for dirname in ["dir1", "dir2", "dir3"]:
            for i in range(2):
                self.assertIn(f"{dirname}/file{i}.txt", added_normalized)

        # Verify all files are staged
        index = self.repo.open_index()
        self.assertEqual(len(index), 6)

    def test_add_default_paths_includes_modified_files(self) -> None:
        """Test that add() with no paths includes both untracked and modified files."""
        # Create and commit initial file
        initial_file = os.path.join(self.repo.path, "existing.txt")
        with open(initial_file, "w") as f:
            f.write("initial content\n")
        porcelain.add(repo=self.repo.path, paths=[initial_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"initial commit",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Modify the existing file (this creates an unstaged change)
        with open(initial_file, "w") as f:
            f.write("modified content\n")

        # Create a new untracked file
        new_file = os.path.join(self.repo.path, "new.txt")
        with open(new_file, "w") as f:
            f.write("new file content\n")

        # Call add() with no paths - should stage both modified and untracked files
        added_files, ignored_files = porcelain.add(repo=self.repo.path)

        # Verify both files were added
        self.assertIn("existing.txt", added_files)
        self.assertIn("new.txt", added_files)
        self.assertEqual(len(ignored_files), 0)

        # Verify both files are now staged
        index = self.repo.open_index()
        self.assertIn(b"existing.txt", index)
        self.assertIn(b"new.txt", index)

    def test_add_empty_paths_list(self) -> None:
        """Test that passing paths=[] does not add any files."""
        # Create an untracked file
        with open(os.path.join(self.repo.path, "file.txt"), "w") as f:
            f.write("content")

        # Add with empty paths list
        added, _ignored = porcelain.add(self.repo.path, paths=[])

        # Should not add any files
        self.assertEqual(len(added), 0)

    def test_add_ignorecase_reuses_existing_entry(self) -> None:
        """With core.ignorecase, adding a differently-cased name
        updates the existing index entry instead of creating a duplicate.
        """
        config = self.repo.get_config()
        config.set((b"core",), b"ignorecase", True)
        config.write_to_path()

        lower = os.path.join(self.repo.path, "foo.txt")
        upper = os.path.join(self.repo.path, "Foo.txt")
        with open(lower, "w") as f:
            f.write("lower")
        porcelain.add(self.repo.path, paths=[lower])
        with open(upper, "w") as f:
            f.write("upper")
        porcelain.add(self.repo.path, paths=[upper])

        self.assertEqual([b"foo.txt"], list(self.repo.open_index().paths()))

    def test_add_precomposeunicode_reuses_existing_entry(self) -> None:
        """With core.precomposeunicode, adding an NFD-form path updates
        the NFC-form index entry instead of creating a duplicate.
        """
        import unicodedata

        nfc_name = unicodedata.normalize("NFC", "täst.txt")
        nfd_name = unicodedata.normalize("NFD", "täst.txt")
        self.assertNotEqual(nfc_name, nfd_name)

        config = self.repo.get_config()
        config.set((b"core",), b"precomposeunicode", True)
        config.write_to_path()

        nfc_path = os.path.join(self.repo.path, nfc_name)
        with open(nfc_path, "w") as f:
            f.write("nfc")
        porcelain.add(self.repo.path, paths=[nfc_path])

        # Stage a file whose on-disk name is the NFD form (simulates the
        # path coming from an NFD-returning filesystem listing).
        nfd_path = os.path.join(self.repo.path, nfd_name)
        if os.path.exists(nfd_path):
            # Filesystem collapses NFC/NFD, so nothing meaningful to test.
            return
        with open(nfd_path, "w") as f:
            f.write("nfd")
        porcelain.add(self.repo.path, paths=[nfd_path])

        self.assertEqual(
            [nfc_name.encode("utf-8")],
            list(self.repo.open_index().paths()),
        )


class RemoveTests(PorcelainTestCase):
    def test_remove_file(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )
        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "foo")))
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(self.repo.path)
        porcelain.remove(self.repo.path, paths=["foo"])
        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))

    def test_remove_file_staged(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(self.repo.path)
        porcelain.add(self.repo.path, paths=[fullpath])
        self.assertRaises(Exception, porcelain.rm, self.repo.path, paths=["foo"])

    def test_remove_file_removed_on_disk(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(self.repo.path)
        os.remove(fullpath)
        porcelain.remove(self.repo.path, paths=["foo"])
        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))

    def test_remove_from_different_directory(self) -> None:
        # Create a subdirectory with a file
        subdir = os.path.join(self.repo.path, "mydir")
        os.makedirs(subdir)
        fullpath = os.path.join(subdir, "myfile")
        with open(fullpath, "w") as f:
            f.write("BAR")

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Change to a different directory
        cwd = os.getcwd()
        tempdir = tempfile.mkdtemp()

        def cleanup():
            os.chdir(cwd)
            shutil.rmtree(tempdir)

        self.addCleanup(cleanup)
        os.chdir(tempdir)
        # Remove the file using relative path from repository root
        porcelain.remove(self.repo.path, paths=["mydir/myfile"])

        # Verify file was removed
        self.assertFalse(os.path.exists(fullpath))

    def test_remove_with_absolute_path(self) -> None:
        # Create a file
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Change to a different directory
        cwd = os.getcwd()
        tempdir = tempfile.mkdtemp()

        def cleanup():
            os.chdir(cwd)
            shutil.rmtree(tempdir)

        self.addCleanup(cleanup)
        os.chdir(tempdir)
        # Remove the file using absolute path
        porcelain.remove(self.repo.path, paths=[fullpath])

        # Verify file was removed
        self.assertFalse(os.path.exists(fullpath))

    def test_remove_with_filter_normalization(self) -> None:
        # Enable autocrlf to normalize line endings
        config = self.repo.get_config()
        config.set(("core",), "autocrlf", b"true")
        config.write_to_path()

        # Create a file with LF line endings (will be stored with LF in index)
        fullpath = os.path.join(self.repo.path, "foo.txt")
        with open(fullpath, "wb") as f:
            f.write(b"line1\nline2\nline3")

        # Add and commit the file (stored with LF in index)
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"Add file with LF",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Simulate checkout with CRLF conversion (as would happen on Windows)
        with open(fullpath, "wb") as f:
            f.write(b"line1\r\nline2\r\nline3")

        # Verify file exists
        self.assertTrue(os.path.exists(fullpath))

        # Remove the file - this should not fail even though working tree has CRLF
        # and index has LF (thanks to the normalization in the commit)
        cwd = os.getcwd()
        os.chdir(self.repo.path)
        self.addCleanup(os.chdir, cwd)
        porcelain.remove(self.repo.path, paths=["foo.txt"])

        # Verify file was removed
        self.assertFalse(os.path.exists(fullpath))

    def test_remove_ignorecase_finds_entry(self) -> None:
        """With core.ignorecase, remove matches an entry whose case differs
        from the argument.
        """
        config = self.repo.get_config()
        config.set((b"core",), b"ignorecase", True)
        config.write_to_path()

        lower = os.path.join(self.repo.path, "foo.txt")
        with open(lower, "w") as f:
            f.write("content")
        porcelain.add(self.repo.path, paths=[lower])
        porcelain.commit(
            repo=self.repo,
            message=b"add",
            author=b"test <test@example.com>",
            committer=b"test <test@example.com>",
        )

        # Rename on disk to the upper-cased form so the FS lookup succeeds,
        # then invoke remove with the upper-cased path.
        upper = os.path.join(self.repo.path, "Foo.txt")
        os.rename(lower, upper)
        porcelain.remove(self.repo.path, paths=["Foo.txt"])
        self.assertEqual([], list(self.repo.open_index().paths()))


class MvTests(PorcelainTestCase):
    def test_mv_file(self) -> None:
        # Create a file
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Move the file
        porcelain.mv(self.repo.path, "foo", "bar")

        # Verify old path doesn't exist and new path does
        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "bar")))

        # Verify index was updated
        index = self.repo.open_index()
        self.assertNotIn(b"foo", index)
        self.assertIn(b"bar", index)

    def test_mv_file_to_existing_directory(self) -> None:
        # Create a file and a directory
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")

        dirpath = os.path.join(self.repo.path, "mydir")
        os.makedirs(dirpath)

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Move the file into the directory
        porcelain.mv(self.repo.path, "foo", "mydir")

        # Verify file moved into directory
        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "mydir", "foo")))

        # Verify index was updated
        index = self.repo.open_index()
        self.assertNotIn(b"foo", index)
        self.assertIn(b"mydir/foo", index)

    def test_mv_file_force_overwrite(self) -> None:
        # Create two files
        fullpath1 = os.path.join(self.repo.path, "foo")
        with open(fullpath1, "w") as f:
            f.write("FOO")

        fullpath2 = os.path.join(self.repo.path, "bar")
        with open(fullpath2, "w") as f:
            f.write("BAR")

        # Add and commit both files
        porcelain.add(self.repo.path, paths=[fullpath1, fullpath2])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Try to move without force (should fail)
        self.assertRaises(porcelain.Error, porcelain.mv, self.repo.path, "foo", "bar")

        # Move with force
        porcelain.mv(self.repo.path, "foo", "bar", force=True)

        # Verify foo doesn't exist and bar has foo's content
        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "foo")))
        with open(os.path.join(self.repo.path, "bar")) as f:
            self.assertEqual(f.read(), "FOO")

    def test_mv_file_not_tracked(self) -> None:
        # Create an untracked file
        fullpath = os.path.join(self.repo.path, "untracked")
        with open(fullpath, "w") as f:
            f.write("UNTRACKED")

        # Try to move it (should fail)
        self.assertRaises(
            porcelain.Error, porcelain.mv, self.repo.path, "untracked", "tracked"
        )

    def test_mv_file_not_exists(self) -> None:
        # Try to move a non-existent file
        self.assertRaises(
            porcelain.Error, porcelain.mv, self.repo.path, "nonexistent", "destination"
        )

    def test_mv_absolute_paths(self) -> None:
        # Create a file
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Move using absolute paths
        dest_path = os.path.join(self.repo.path, "bar")
        porcelain.mv(self.repo.path, fullpath, dest_path)

        # Verify file moved
        self.assertFalse(os.path.exists(fullpath))
        self.assertTrue(os.path.exists(dest_path))

    def test_mv_from_different_directory(self) -> None:
        # Create a subdirectory with a file
        subdir = os.path.join(self.repo.path, "mydir")
        os.makedirs(subdir)
        fullpath = os.path.join(subdir, "myfile")
        with open(fullpath, "w") as f:
            f.write("BAR")

        # Add and commit the file
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Change to a different directory and move the file
        cwd = os.getcwd()
        tempdir = tempfile.mkdtemp()

        def cleanup():
            os.chdir(cwd)
            shutil.rmtree(tempdir)

        self.addCleanup(cleanup)
        os.chdir(tempdir)
        # Move the file using relative path from repository root
        porcelain.mv(self.repo.path, "mydir/myfile", "renamed")

        # Verify file was moved
        self.assertFalse(os.path.exists(fullpath))
        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "renamed")))


class LogTests(PorcelainTestCase):
    def test_simple(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.maxDiff = None
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream)
        self.assertEqual(
            outstream.getvalue(),
            """\
--------------------------------------------------
commit: 4a3b887baa9ecb2d054d2469b628aef84e2d74f0
merge: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Commit 3

--------------------------------------------------
commit: 7508036b1cfec5aa9cef0d5a7f04abcecfe09112
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Commit 2

--------------------------------------------------
commit: 11d3cf672a19366435c1983c7340b008ec6b8bf3
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Commit 1

""",
        )

    def test_max_entries(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, max_entries=1)
        self.assertEqual(1, outstream.getvalue().count("-" * 50))

    def test_no_revisions(self) -> None:
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream)
        self.assertEqual("", outstream.getvalue())

    def test_empty_message(self) -> None:
        c1 = make_commit(message="")
        self.repo.object_store.add_object(c1)
        self.repo.refs[b"HEAD"] = c1.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream)
        self.assertEqual(
            outstream.getvalue(),
            """\
--------------------------------------------------
commit: 4a7ad5552fad70647a81fb9a4a923ccefcca4b76
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000
""",
        )

    def test_oneline(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, oneline=True)
        lines = outstream.getvalue().strip().split("\n")
        self.assertEqual(3, len(lines))
        # Each line should be: abbreviated_hash message
        for line in lines:
            parts = line.split(" ", 1)
            self.assertEqual(7, len(parts[0]))  # abbreviated hash

    def test_abbrev_commit(self) -> None:
        c1 = make_commit(message=b"Test commit")
        self.repo.object_store.add_object(c1)
        self.repo.refs[b"HEAD"] = c1.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, abbrev_commit=True)
        output = outstream.getvalue()
        # The commit line should have an abbreviated hash (7 chars)
        for line in output.split("\n"):
            if line.startswith("commit: "):
                commit_hash = line.split(": ", 1)[1]
                self.assertEqual(7, len(commit_hash))

    def test_author_filter(self) -> None:
        c1 = make_commit(author=b"Alice <alice@example.com>", message=b"By Alice")
        c2 = make_commit(
            author=b"Bob <bob@example.com>",
            message=b"By Bob",
            parents=[c1.id],
        )
        self.repo.object_store.add_object(c1)
        self.repo.object_store.add_object(c2)
        self.repo.refs[b"HEAD"] = c2.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, author="Alice")
        output = outstream.getvalue()
        self.assertIn("By Alice", output)
        self.assertNotIn("By Bob", output)

    def test_committer_filter(self) -> None:
        c1 = make_commit(
            committer=b"Alice <alice@example.com>", message=b"Committed by Alice"
        )
        c2 = make_commit(
            committer=b"Bob <bob@example.com>",
            message=b"Committed by Bob",
            parents=[c1.id],
        )
        self.repo.object_store.add_object(c1)
        self.repo.object_store.add_object(c2)
        self.repo.refs[b"HEAD"] = c2.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, committer="Alice")
        output = outstream.getvalue()
        self.assertIn("Committed by Alice", output)
        self.assertNotIn("Committed by Bob", output)

    def test_grep_filter(self) -> None:
        c1 = make_commit(message=b"Fix bug in parser")
        c2 = make_commit(message=b"Add new feature", parents=[c1.id])
        self.repo.object_store.add_object(c1)
        self.repo.object_store.add_object(c2)
        self.repo.refs[b"HEAD"] = c2.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, grep="bug")
        output = outstream.getvalue()
        self.assertIn("Fix bug in parser", output)
        self.assertNotIn("Add new feature", output)

    def test_no_merges(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, no_merges=True)
        output = outstream.getvalue()
        # c3 is a merge commit (parents: c1, c2), should be excluded
        self.assertNotIn("Commit 3", output)
        self.assertIn("Commit 1", output)
        self.assertIn("Commit 2", output)

    def test_merges_only(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, merges=True)
        output = outstream.getvalue()
        # Only c3 is a merge commit
        self.assertIn("Commit 3", output)
        self.assertNotIn("Commit 1", output)
        self.assertNotIn("Commit 2", output)

    def test_max_entries_with_filter(self) -> None:
        c1 = make_commit(author=b"Alice <alice@example.com>", message=b"Alice 1")
        c2 = make_commit(
            author=b"Alice <alice@example.com>",
            message=b"Alice 2",
            parents=[c1.id],
        )
        c3 = make_commit(
            author=b"Alice <alice@example.com>",
            message=b"Alice 3",
            parents=[c2.id],
        )
        self.repo.object_store.add_object(c1)
        self.repo.object_store.add_object(c2)
        self.repo.object_store.add_object(c3)
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.log(
            self.repo.path,
            outstream=outstream,
            author="Alice",
            max_entries=2,
        )
        output = outstream.getvalue()
        self.assertEqual(2, output.count("-" * 50))

    def test_name_only(self) -> None:
        # Create a commit with actual file changes
        c1 = self._commit_file("testfile.txt", b"content")
        self.repo.refs[b"HEAD"] = c1
        outstream = StringIO()
        porcelain.log(self.repo.path, outstream=outstream, name_only=True)
        output = outstream.getvalue()
        self.assertIn("testfile.txt", output)

    def _commit_file(self, filename: str, content: bytes) -> bytes:
        """Helper to create a commit with a file."""
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "wb") as f:
            f.write(content)
        porcelain.add(self.repo, paths=[fullpath])
        return porcelain.commit(
            self.repo,
            message=b"Add " + filename.encode(),
            author=b"Test Author <test@nodomain.com>",
            committer=b"Test Committer <test@nodomain.com>",
        )


class ShowTests(PorcelainTestCase):
    def test_nolist(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.show(self.repo.path, objects=c3.id, outstream=outstream)
        self.assertTrue(outstream.getvalue().startswith("-" * 50))

    def test_simple(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = StringIO()
        porcelain.show(self.repo.path, objects=[c3.id], outstream=outstream)
        self.assertTrue(outstream.getvalue().startswith("-" * 50))

    def test_blob(self) -> None:
        b = Blob.from_string(b"The Foo\n")
        self.repo.object_store.add_object(b)
        outstream = StringIO()
        porcelain.show(self.repo.path, objects=[b.id], outstream=outstream)
        self.assertEqual(outstream.getvalue(), "The Foo\n")

    def test_commit_no_parent(self) -> None:
        a = Blob.from_string(b"The Foo\n")
        ta = Tree()
        ta.add(b"somename", 0o100644, a.id)
        ca = make_commit(tree=ta.id)
        self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
        outstream = StringIO()
        porcelain.show(self.repo.path, objects=[ca.id], outstream=outstream)
        self.assertMultiLineEqual(
            outstream.getvalue(),
            """\
--------------------------------------------------
commit: 344da06c1bb85901270b3e8875c988a027ec087d
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Test message.

diff --git a/somename b/somename
new file mode 100644
index 0000000..ea5c7bf
--- /dev/null
+++ b/somename
@@ -0,0 +1 @@
+The Foo
""",
        )

    def test_tag(self) -> None:
        a = Blob.from_string(b"The Foo\n")
        ta = Tree()
        ta.add(b"somename", 0o100644, a.id)
        ca = make_commit(tree=ta.id)
        self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
            objectish=ca.id,
            tag_time=1552854211,
            tag_timezone=0,
        )
        outstream = StringIO()
        porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
        self.maxDiff = None
        self.assertMultiLineEqual(
            outstream.getvalue(),
            """\
Tagger: foo <foo@bar.com>
Date:   Sun Mar 17 2019 20:23:31 +0000

bar

--------------------------------------------------
commit: 344da06c1bb85901270b3e8875c988a027ec087d
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Test message.

diff --git a/somename b/somename
new file mode 100644
index 0000000..ea5c7bf
--- /dev/null
+++ b/somename
@@ -0,0 +1 @@
+The Foo
""",
        )

    def test_tag_unicode(self) -> None:
        a = Blob.from_string(b"The Foo\n")
        ta = Tree()
        ta.add(b"somename", 0o100644, a.id)
        ca = make_commit(tree=ta.id)
        self.repo.object_store.add_objects([(a, None), (ta, None), (ca, None)])
        porcelain.tag_create(
            self.repo.path,
            "tryme",
            "foo <foo@bar.com>",
            "bar",
            annotated=True,
            objectish=ca.id,
            tag_time=1552854211,
            tag_timezone=0,
        )
        outstream = StringIO()
        porcelain.show(self.repo, objects=[b"refs/tags/tryme"], outstream=outstream)
        self.maxDiff = None
        self.assertMultiLineEqual(
            outstream.getvalue(),
            """\
Tagger: foo <foo@bar.com>
Date:   Sun Mar 17 2019 20:23:31 +0000

bar

--------------------------------------------------
commit: 344da06c1bb85901270b3e8875c988a027ec087d
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Test message.

diff --git a/somename b/somename
new file mode 100644
index 0000000..ea5c7bf
--- /dev/null
+++ b/somename
@@ -0,0 +1 @@
+The Foo
""",
        )

    def test_commit_with_change(self) -> None:
        a = Blob.from_string(b"The Foo\n")
        ta = Tree()
        ta.add(b"somename", 0o100644, a.id)
        ca = make_commit(tree=ta.id)
        b = Blob.from_string(b"The Bar\n")
        tb = Tree()
        tb.add(b"somename", 0o100644, b.id)
        cb = make_commit(tree=tb.id, parents=[ca.id])
        self.repo.object_store.add_objects(
            [
                (a, None),
                (b, None),
                (ta, None),
                (tb, None),
                (ca, None),
                (cb, None),
            ]
        )
        outstream = StringIO()
        porcelain.show(self.repo.path, objects=[cb.id], outstream=outstream)
        self.assertMultiLineEqual(
            outstream.getvalue(),
            """\
--------------------------------------------------
commit: 2c6b6c9cb72c130956657e1fdae58e5b103744fa
Author: Test Author <test@nodomain.com>
Committer: Test Committer <test@nodomain.com>
Date:   Fri Jan 01 2010 00:00:00 +0000

Test message.

diff --git a/somename b/somename
index ea5c7bf..fd38bcb 100644
--- a/somename
+++ b/somename
@@ -1 +1 @@
-The Foo
+The Bar
""",
        )


class FormatPatchTests(PorcelainTestCase):
    def test_format_patch_single_commit(self) -> None:
        # Create initial commit
        tree1 = Tree()
        c1 = make_commit(
            tree=tree1,
            message=b"Initial commit",
        )
        self.repo.object_store.add_objects([(tree1, None), (c1, None)])

        # Create second commit
        b = Blob.from_string(b"modified")
        tree = Tree()
        tree.add(b"test.txt", 0o100644, b.id)
        c2 = make_commit(
            tree=tree,
            parents=[c1.id],
            message=b"Add test.txt",
        )
        self.repo.object_store.add_objects([(b, None), (tree, None), (c2, None)])
        self.repo[b"HEAD"] = c2.id

        # Generate patch for single commit
        with tempfile.TemporaryDirectory() as tmpdir:
            patches = porcelain.format_patch(
                self.repo.path,
                committish=c2.id,
                outdir=tmpdir,
            )

            self.assertEqual(len(patches), 1)
            self.assertTrue(patches[0].endswith("-Add-test.txt.patch"))

            # Verify patch content
            with open(patches[0], "rb") as f:
                content = f.read()
                self.assertIn(b"Subject: [PATCH 1/1] Add test.txt", content)
                self.assertIn(b"+modified", content)

    def test_format_patch_multiple_commits(self) -> None:
        # Create commit chain
        commits = []
        for i in range(3):
            blob = Blob.from_string(f"content {i}".encode())
            tree = Tree()
            tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)

            parents = [commits[-1].id] if commits else []
            commit = make_commit(
                tree=tree,
                parents=parents,
                message=f"Commit {i}".encode(),
            )
            self.repo.object_store.add_objects(
                [(blob, None), (tree, None), (commit, None)]
            )
            commits.append(commit)

        self.repo[b"HEAD"] = commits[-1].id

        # Test generating last 2 commits
        with tempfile.TemporaryDirectory() as tmpdir:
            patches = porcelain.format_patch(
                self.repo.path,
                n=2,
                outdir=tmpdir,
            )

            self.assertEqual(len(patches), 2)
            self.assertTrue(patches[0].endswith("-Commit-1.patch"))
            self.assertTrue(patches[1].endswith("-Commit-2.patch"))

            # Check patch numbering
            with open(patches[0], "rb") as f:
                self.assertIn(b"Subject: [PATCH 1/2] Commit 1", f.read())
            with open(patches[1], "rb") as f:
                self.assertIn(b"Subject: [PATCH 2/2] Commit 2", f.read())

    def test_format_patch_range(self) -> None:
        # Create commit chain
        c1, _c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
        self.repo[b"HEAD"] = c3.id

        # Test commit range
        with tempfile.TemporaryDirectory() as tmpdir:
            patches = porcelain.format_patch(
                self.repo.path,
                committish=(c1.id, c3.id),
                outdir=tmpdir,
            )

            # Should include c2 and c3
            self.assertEqual(len(patches), 2)

    def test_format_patch_stdout(self) -> None:
        # Create a commit
        blob = Blob.from_string(b"test content")
        tree = Tree()
        tree.add(b"test.txt", 0o100644, blob.id)
        commit = make_commit(
            tree=tree,
            message=b"Test commit",
        )
        self.repo.object_store.add_objects([(blob, None), (tree, None), (commit, None)])
        self.repo[b"HEAD"] = commit.id

        # Test stdout output
        outstream = BytesIO()
        patches = porcelain.format_patch(
            self.repo.path,
            committish=commit.id,
            stdout=True,
            outstream=outstream,
        )

        # Should return empty list when writing to stdout
        self.assertEqual(patches, [])

        # Check stdout content
        outstream.seek(0)
        content = outstream.read()
        self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
        self.assertIn(b"diff --git", content)

    def test_format_patch_no_commits(self) -> None:
        # Test with a new repository with no commits
        # Just remove HEAD to simulate empty repo
        patches = porcelain.format_patch(
            self.repo.path,
            n=5,
        )
        self.assertEqual(patches, [])

    def test_format_patch_subject_cannot_escape_outdir(self) -> None:
        # A malicious commit subject must not be able to direct the
        # generated patch file outside the requested output directory.
        tree1 = Tree()
        c1 = make_commit(tree=tree1, message=b"Initial commit")
        self.repo.object_store.add_objects([(tree1, None), (c1, None)])

        blob = Blob.from_string(b"data")
        tree2 = Tree()
        tree2.add(b"f.txt", 0o100644, blob.id)
        # Subjects that try to traverse out of outdir via path separators
        # or parent-directory components.
        for evil in (b"x/../../x", b"x\\..\\..\\x", b"a:b"):
            c2 = make_commit(
                tree=tree2,
                parents=[c1.id],
                message=evil,
            )
            self.repo.object_store.add_objects(
                [(blob, None), (tree2, None), (c2, None)]
            )
            self.repo[b"HEAD"] = c2.id

            with tempfile.TemporaryDirectory() as tmpdir:
                patches = porcelain.format_patch(
                    self.repo.path,
                    committish=c2.id,
                    outdir=tmpdir,
                )
                self.assertEqual(len(patches), 1)
                real_outdir = os.path.realpath(tmpdir)
                real_patch = os.path.realpath(patches[0])
                self.assertEqual(
                    os.path.dirname(real_patch),
                    real_outdir,
                    f"patch for subject {evil!r} escaped outdir: {patches[0]}",
                )
                # Filename must not contain path separators or parent refs.
                base = os.path.basename(patches[0])
                self.assertNotIn("/", base)
                self.assertNotIn("\\", base)
                self.assertNotIn("..", base)

    def test_format_patch_long_subject_truncated(self) -> None:
        tree1 = Tree()
        c1 = make_commit(tree=tree1, message=b"Initial commit")
        self.repo.object_store.add_objects([(tree1, None), (c1, None)])

        blob = Blob.from_string(b"data")
        tree2 = Tree()
        tree2.add(b"f.txt", 0o100644, blob.id)
        c2 = make_commit(
            tree=tree2,
            parents=[c1.id],
            message=b"a" * 500,
        )
        self.repo.object_store.add_objects([(blob, None), (tree2, None), (c2, None)])
        self.repo[b"HEAD"] = c2.id

        with tempfile.TemporaryDirectory() as tmpdir:
            patches = porcelain.format_patch(
                self.repo.path,
                committish=c2.id,
                outdir=tmpdir,
            )
            self.assertEqual(len(patches), 1)
            # Whatever the cap is, an extremely long subject must not
            # produce a pathologically long filename.
            self.assertLess(len(os.path.basename(patches[0])), 100)


class SymbolicRefTests(PorcelainTestCase):
    def test_set_wrong_symbolic_ref(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        self.assertRaises(
            porcelain.Error, porcelain.symbolic_ref, self.repo.path, b"foobar"
        )

    def test_set_force_wrong_symbolic_ref(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.symbolic_ref(self.repo.path, b"force_foobar", force=True)

        # test if we actually changed the file
        with self.repo.get_named_file("HEAD") as f:
            new_ref = f.read()
        self.assertEqual(new_ref, b"ref: refs/heads/force_foobar\n")

    def test_set_symbolic_ref(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.symbolic_ref(self.repo.path, b"master")

    def test_set_symbolic_ref_other_than_master(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 1, 2]],
            attrs=dict(refs="develop"),
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/develop"] = c3.id

        porcelain.symbolic_ref(self.repo.path, b"develop")

        # test if we actually changed the file
        with self.repo.get_named_file("HEAD") as f:
            new_ref = f.read()
        self.assertEqual(new_ref, b"ref: refs/heads/develop\n")


class DiffTreeTests(PorcelainTestCase):
    def test_empty(self) -> None:
        _c1, c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        outstream = BytesIO()
        porcelain.diff_tree(self.repo.path, c2.tree, c3.tree, outstream=outstream)
        self.assertEqual(outstream.getvalue(), b"")


class DiffTests(PorcelainTestCase):
    def test_diff_uncommitted_stage(self) -> None:
        # Test diff in repository with no commits yet
        fullpath = os.path.join(self.repo.path, "test.txt")
        with open(fullpath, "w") as f:
            f.write("Hello, world!\n")
        porcelain.add(self.repo.path, paths=["test.txt"])

        outstream = BytesIO()
        porcelain.diff(self.repo.path, staged=True, outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
        self.assertIn(b"new file mode", diff_output)
        self.assertIn(b"+Hello, world!", diff_output)

    def test_diff_uncommitted_working_tree(self) -> None:
        # Test unstaged changes in repository with no commits
        fullpath = os.path.join(self.repo.path, "test.txt")
        with open(fullpath, "w") as f:
            f.write("Hello, world!\n")
        porcelain.add(self.repo.path, paths=["test.txt"])

        # Modify file in working tree
        with open(fullpath, "w") as f:
            f.write("Hello, world!\nNew line\n")

        outstream = BytesIO()
        porcelain.diff(self.repo.path, staged=False, outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
        self.assertIn(b"+New line", diff_output)

    def test_diff_with_commits_staged(self) -> None:
        # Test staged changes with existing commits
        fullpath = os.path.join(self.repo.path, "test.txt")
        with open(fullpath, "w") as f:
            f.write("Initial content\n")
        porcelain.add(self.repo.path, paths=["test.txt"])
        porcelain.commit(self.repo.path, message=b"Initial commit")

        # Modify and stage
        with open(fullpath, "w") as f:
            f.write("Initial content\nModified\n")
        porcelain.add(self.repo.path, paths=["test.txt"])

        outstream = BytesIO()
        porcelain.diff(self.repo.path, staged=True, outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
        self.assertIn(b"+Modified", diff_output)

    def test_diff_with_commits_unstaged(self) -> None:
        # Test unstaged changes with existing commits
        fullpath = os.path.join(self.repo.path, "test.txt")
        with open(fullpath, "w") as f:
            f.write("Initial content\n")
        porcelain.add(self.repo.path, paths=["test.txt"])
        porcelain.commit(self.repo.path, message=b"Initial commit")

        # Modify without staging
        with open(fullpath, "w") as f:
            f.write("Initial content\nModified\n")

        outstream = BytesIO()
        porcelain.diff(self.repo.path, staged=False, outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
        self.assertIn(b"+Modified", diff_output)

    def test_diff_file_deletion(self) -> None:
        # Test showing file deletion
        fullpath = os.path.join(self.repo.path, "test.txt")
        with open(fullpath, "w") as f:
            f.write("Content to delete\n")
        porcelain.add(self.repo.path, paths=["test.txt"])
        porcelain.commit(self.repo.path, message=b"Add file")

        # Delete file
        os.unlink(fullpath)

        outstream = BytesIO()
        porcelain.diff(self.repo.path, staged=False, outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/test.txt b/test.txt", diff_output)
        self.assertIn(b"deleted file mode", diff_output)
        self.assertIn(b"-Content to delete", diff_output)

    def test_diff_with_paths(self) -> None:
        # Test diff with specific paths
        # Create two files
        fullpath1 = os.path.join(self.repo.path, "file1.txt")
        fullpath2 = os.path.join(self.repo.path, "file2.txt")
        with open(fullpath1, "w") as f:
            f.write("File 1 content\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 content\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
        porcelain.commit(self.repo.path, message=b"Add two files")

        # Modify both files
        with open(fullpath1, "w") as f:
            f.write("File 1 modified\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 modified\n")

        # Test diff with specific path
        outstream = BytesIO()
        porcelain.diff(self.repo.path, paths=["file1.txt"], outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
        self.assertIn(b"-File 1 content", diff_output)
        self.assertIn(b"+File 1 modified", diff_output)
        # file2.txt should not appear in diff
        self.assertNotIn(b"file2.txt", diff_output)

    def test_diff_with_paths_multiple(self) -> None:
        # Test diff with multiple paths
        # Create three files
        fullpath1 = os.path.join(self.repo.path, "file1.txt")
        fullpath2 = os.path.join(self.repo.path, "file2.txt")
        fullpath3 = os.path.join(self.repo.path, "file3.txt")
        with open(fullpath1, "w") as f:
            f.write("File 1 content\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 content\n")
        with open(fullpath3, "w") as f:
            f.write("File 3 content\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt", "file3.txt"])
        porcelain.commit(self.repo.path, message=b"Add three files")

        # Modify all files
        with open(fullpath1, "w") as f:
            f.write("File 1 modified\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 modified\n")
        with open(fullpath3, "w") as f:
            f.write("File 3 modified\n")

        # Test diff with two specific paths
        outstream = BytesIO()
        porcelain.diff(
            self.repo.path, paths=["file1.txt", "file3.txt"], outstream=outstream
        )

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
        self.assertIn(b"diff --git a/file3.txt b/file3.txt", diff_output)
        # file2.txt should not appear in diff
        self.assertNotIn(b"file2.txt", diff_output)

    def test_diff_with_paths_directory(self) -> None:
        # Test diff with directory paths
        # Create files in subdirectory
        os.mkdir(os.path.join(self.repo.path, "subdir"))
        fullpath1 = os.path.join(self.repo.path, "subdir", "file1.txt")
        fullpath2 = os.path.join(self.repo.path, "subdir", "file2.txt")
        fullpath3 = os.path.join(self.repo.path, "root.txt")
        with open(fullpath1, "w") as f:
            f.write("Subdir file 1\n")
        with open(fullpath2, "w") as f:
            f.write("Subdir file 2\n")
        with open(fullpath3, "w") as f:
            f.write("Root file\n")
        porcelain.add(
            self.repo.path, paths=["subdir/file1.txt", "subdir/file2.txt", "root.txt"]
        )
        porcelain.commit(self.repo.path, message=b"Add files in subdir")

        # Modify all files
        with open(fullpath1, "w") as f:
            f.write("Subdir file 1 modified\n")
        with open(fullpath2, "w") as f:
            f.write("Subdir file 2 modified\n")
        with open(fullpath3, "w") as f:
            f.write("Root file modified\n")

        # Test diff with directory path
        outstream = BytesIO()
        porcelain.diff(self.repo.path, paths=["subdir"], outstream=outstream)

        diff_output = outstream.getvalue()
        self.assertIn(b"subdir/file1.txt", diff_output)
        self.assertIn(b"subdir/file2.txt", diff_output)
        # root.txt should not appear in diff
        self.assertNotIn(b"root.txt", diff_output)

    def test_diff_staged_with_paths(self) -> None:
        # Test staged diff with specific paths
        # Create two files
        fullpath1 = os.path.join(self.repo.path, "file1.txt")
        fullpath2 = os.path.join(self.repo.path, "file2.txt")
        with open(fullpath1, "w") as f:
            f.write("File 1 content\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 content\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
        porcelain.commit(self.repo.path, message=b"Add two files")

        # Modify and stage both files
        with open(fullpath1, "w") as f:
            f.write("File 1 staged\n")
        with open(fullpath2, "w") as f:
            f.write("File 2 staged\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])

        # Test staged diff with specific path
        outstream = BytesIO()
        porcelain.diff(
            self.repo.path, staged=True, paths=["file1.txt"], outstream=outstream
        )

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
        self.assertIn(b"-File 1 content", diff_output)
        self.assertIn(b"+File 1 staged", diff_output)
        # file2.txt should not appear in diff
        self.assertNotIn(b"file2.txt", diff_output)

    def test_diff_with_commit_and_paths(self) -> None:
        # Test diff against specific commit with paths
        # Create initial file
        fullpath1 = os.path.join(self.repo.path, "file1.txt")
        fullpath2 = os.path.join(self.repo.path, "file2.txt")
        with open(fullpath1, "w") as f:
            f.write("Initial content 1\n")
        with open(fullpath2, "w") as f:
            f.write("Initial content 2\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
        first_commit = porcelain.commit(self.repo.path, message=b"Initial commit")

        # Make second commit
        with open(fullpath1, "w") as f:
            f.write("Second content 1\n")
        with open(fullpath2, "w") as f:
            f.write("Second content 2\n")
        porcelain.add(self.repo.path, paths=["file1.txt", "file2.txt"])
        porcelain.commit(self.repo.path, message=b"Second commit")

        # Modify working tree
        with open(fullpath1, "w") as f:
            f.write("Working content 1\n")
        with open(fullpath2, "w") as f:
            f.write("Working content 2\n")

        # Test diff against first commit with specific path
        outstream = BytesIO()
        porcelain.diff(
            self.repo.path,
            commit=first_commit,
            paths=["file1.txt"],
            outstream=outstream,
        )

        diff_output = outstream.getvalue()
        self.assertIn(b"diff --git a/file1.txt b/file1.txt", diff_output)
        self.assertIn(b"-Initial content 1", diff_output)
        self.assertIn(b"+Working content 1", diff_output)
        # file2.txt should not appear in diff
        self.assertNotIn(b"file2.txt", diff_output)


class CommitTreeTests(PorcelainTestCase):
    def test_simple(self) -> None:
        _c1, _c2, _c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        b = Blob()
        b.data = b"foo the bar"
        t = Tree()
        t.add(b"somename", 0o100644, b.id)
        self.repo.object_store.add_object(t)
        self.repo.object_store.add_object(b)
        sha = porcelain.commit_tree(
            self.repo.path,
            t.id,
            message=b"Withcommit.",
            author=b"Joe <joe@example.com>",
            committer=b"Jane <jane@example.com>",
        )
        self.assertIsInstance(sha, bytes)
        self.assertEqual(len(sha), 40)


class RevListTests(PorcelainTestCase):
    def test_simple(self) -> None:
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        outstream = BytesIO()
        porcelain.rev_list(self.repo.path, [c3.id], outstream=outstream)
        self.assertEqual(
            c3.id + b"\n" + c2.id + b"\n" + c1.id + b"\n", outstream.getvalue()
        )


@skipIf(
    platform.python_implementation() == "PyPy" or sys.platform == "win32",
    "gpgme not easily available or supported on Windows and PyPy",
)
class TagCreateSignTests(PorcelainGpgTestCase):
    def test_default_key(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        self.import_default_key()

        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
            sign=True,
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"tryme"])
        tag = self.repo[b"refs/tags/tryme"]
        self.assertIsInstance(tag, Tag)
        self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
        self.assertEqual(b"bar\n", tag.message)
        self.assertRecentTimestamp(tag.tag_time)
        tag = self.repo[b"refs/tags/tryme"]
        assert isinstance(tag, Tag)
        # GPG Signatures aren't deterministic, so we can't do a static assertion.
        from dulwich.signature import (
            BadSignature,
            UntrustedSignature,
            get_signature_vendor_for_signature,
        )

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)

        self.import_non_default_key()
        # Verify with wrong keyid - should raise UntrustedSignature
        vendor_wrong_keyid = get_signature_vendor_for_signature(
            tag.signature, keyids=[PorcelainGpgTestCase.NON_DEFAULT_KEY_ID]
        )
        self.assertRaises(
            UntrustedSignature,
            vendor_wrong_keyid.verify,
            tag.raw_without_sig(),
            tag.signature,
        )

        assert tag.signature is not None
        tag._chunked_text = [b"bad data", tag.signature]
        self.assertRaises(
            BadSignature,
            vendor.verify,
            tag.raw_without_sig(),
            tag.signature,
        )

    def test_non_default_key(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        self.import_non_default_key()

        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
            sign=True,
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"tryme"])
        tag = self.repo[b"refs/tags/tryme"]
        self.assertIsInstance(tag, Tag)
        self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
        self.assertEqual(b"bar\n", tag.message)
        self.assertRecentTimestamp(tag.tag_time)
        tag = self.repo[b"refs/tags/tryme"]
        assert isinstance(tag, Tag)
        # GPG Signatures aren't deterministic, so we can't do a static assertion.
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)

    def test_sign_uses_config_signingkey(self) -> None:
        """Test that sign=True uses user.signingKey from config."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag with sign=True (should use signingKey from config)
        porcelain.tag_create(
            self.repo.path,
            b"signed-tag",
            b"foo <foo@bar.com>",
            b"Tag with configured key",
            annotated=True,
            sign=True,  # This should read user.signingKey from config
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"signed-tag"])
        tag = self.repo[b"refs/tags/signed-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is signed with the configured key
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)

    def test_tag_gpg_sign_config_enabled(self) -> None:
        """Test that tag.gpgSign=true automatically signs tags."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey and tag.gpgSign in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("tag",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag without explicit sign parameter (should auto-sign due to config)
        porcelain.tag_create(
            self.repo.path,
            b"auto-signed-tag",
            b"foo <foo@bar.com>",
            b"Auto-signed tag",
            annotated=True,
            # No sign parameter - should use tag.gpgSign config
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"auto-signed-tag"])
        tag = self.repo[b"refs/tags/auto-signed-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is signed due to config
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)

    def test_tag_gpg_sign_config_disabled(self) -> None:
        """Test that tag.gpgSign=false does not sign tags."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up user.signingKey and tag.gpgSign=false in config
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("tag",), "gpgSign", False)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag without explicit sign parameter (should not sign)
        porcelain.tag_create(
            self.repo.path,
            b"unsigned-tag",
            b"foo <foo@bar.com>",
            b"Unsigned tag",
            annotated=True,
            # No sign parameter - should use tag.gpgSign=false config
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"unsigned-tag"])
        tag = self.repo[b"refs/tags/unsigned-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is not signed
        self.assertIsNone(tag._signature)

    def test_tag_gpg_sign_config_no_signing_key(self) -> None:
        """Test that tag.gpgSign=true works without user.signingKey (uses default)."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up tag.gpgSign but no user.signingKey
        cfg = self.repo.get_config()
        cfg.set(("tag",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag without explicit sign parameter (should auto-sign with default key)
        porcelain.tag_create(
            self.repo.path,
            b"default-signed-tag",
            b"foo <foo@bar.com>",
            b"Default signed tag",
            annotated=True,
            # No sign parameter - should use tag.gpgSign config with default key
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"default-signed-tag"])
        tag = self.repo[b"refs/tags/default-signed-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is signed with default key
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)

    def test_explicit_sign_overrides_config(self) -> None:
        """Test that explicit sign parameter overrides tag.gpgSign config."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up tag.gpgSign=false but explicitly pass sign=True
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("tag",), "gpgSign", False)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag with explicit sign=True (should override config)
        porcelain.tag_create(
            self.repo.path,
            b"explicit-signed-tag",
            b"foo <foo@bar.com>",
            b"Explicitly signed tag",
            annotated=True,
            sign=True,  # This should override tag.gpgSign=false
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"explicit-signed-tag"])
        tag = self.repo[b"refs/tags/explicit-signed-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is signed despite config=false
        from dulwich.signature import get_signature_vendor_for_signature

        self.assertIsNotNone(tag.signature)
        vendor = get_signature_vendor_for_signature(tag.signature)
        vendor.verify(tag.raw_without_sig(), tag.signature)
        # Verify with specific keyid
        vendor_with_keyid = get_signature_vendor_for_signature(
            tag.signature, keyids=[PorcelainGpgTestCase.DEFAULT_KEY_ID]
        )
        vendor_with_keyid.verify(tag.raw_without_sig(), tag.signature)

    def test_explicit_false_disables_tag_signing(self) -> None:
        """Test that explicit sign=False disables signing even with config=true."""
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        # Set up tag.gpgSign=true but explicitly pass sign=False
        cfg = self.repo.get_config()
        cfg.set(("user",), "signingKey", PorcelainGpgTestCase.DEFAULT_KEY_ID)
        cfg.set(("tag",), "gpgSign", True)
        cfg.write_to_path()

        self.import_default_key()

        # Create tag with explicit sign=False (should disable signing)
        porcelain.tag_create(
            self.repo.path,
            b"explicit-unsigned-tag",
            b"foo <foo@bar.com>",
            b"Explicitly unsigned tag",
            annotated=True,
            sign=False,  # This should override tag.gpgSign=true
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"explicit-unsigned-tag"])
        tag = self.repo[b"refs/tags/explicit-unsigned-tag"]
        self.assertIsInstance(tag, Tag)

        # Verify the tag is NOT signed despite config=true
        self.assertIsNone(tag._signature)


class TagCreateTests(PorcelainTestCase):
    def test_annotated(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
        )

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"tryme"])
        tag = self.repo[b"refs/tags/tryme"]
        self.assertIsInstance(tag, Tag)
        self.assertEqual(b"foo <foo@bar.com>", tag.tagger)
        self.assertEqual(b"bar\n", tag.message)
        self.assertRecentTimestamp(tag.tag_time)

    def test_unannotated(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.tag_create(self.repo.path, b"tryme", annotated=False)

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"tryme"])
        self.repo[b"refs/tags/tryme"]
        self.assertEqual(list(tags.values()), [self.repo.head()])

    def test_unannotated_unicode(self) -> None:
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id

        porcelain.tag_create(self.repo.path, "tryme", annotated=False)

        tags = self.repo.refs.as_dict(b"refs/tags")
        self.assertEqual(list(tags.keys()), [b"tryme"])
        self.repo[b"refs/tags/tryme"]
        self.assertEqual(list(tags.values()), [self.repo.head()])


class TagListTests(PorcelainTestCase):
    def test_empty(self) -> None:
        tags = porcelain.tag_list(self.repo.path)
        self.assertEqual([], tags)

    def test_simple(self) -> None:
        self.repo.refs[b"refs/tags/foo"] = b"aa" * 20
        self.repo.refs[b"refs/tags/bar/bla"] = b"bb" * 20
        tags = porcelain.tag_list(self.repo.path)

        self.assertEqual([b"bar/bla", b"foo"], tags)


class TagDeleteTests(PorcelainTestCase):
    def test_simple(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.tag_create(self.repo, b"foo")
        self.assertIn(b"foo", porcelain.tag_list(self.repo))
        porcelain.tag_delete(self.repo, b"foo")
        self.assertNotIn(b"foo", porcelain.tag_list(self.repo))


class ResetTests(PorcelainTestCase):
    def test_hard_head(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        with open(os.path.join(self.repo.path, "foo"), "wb") as f:
            f.write(b"OOH")

        porcelain.reset(self.repo, "hard", b"HEAD")

        index = self.repo.open_index()
        changes = list(
            tree_changes(
                self.repo.object_store,
                index.commit(self.repo.object_store),
                self.repo[b"HEAD"].tree,
            )
        )

        self.assertEqual([], changes)

    def test_hard_commit(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        with open(fullpath, "wb") as f:
            f.write(b"BAZ")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some other message",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        porcelain.reset(self.repo, "hard", sha)

        index = self.repo.open_index()
        changes = list(
            tree_changes(
                self.repo.object_store,
                index.commit(self.repo.object_store),
                self.repo[sha].tree,
            )
        )

        self.assertEqual([], changes)

    def test_hard_commit_short_hash(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        with open(fullpath, "wb") as f:
            f.write(b"BAZ")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some other message",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Test with short hash (7 characters)
        short_sha = sha[:7].decode("ascii")
        porcelain.reset(self.repo, "hard", short_sha)

        index = self.repo.open_index()
        changes = list(
            tree_changes(
                self.repo.object_store,
                index.commit(self.repo.object_store),
                self.repo[sha].tree,
            )
        )

        self.assertEqual([], changes)

    def test_hard_deletes_untracked_files(self) -> None:
        """Test that reset --hard deletes files that don't exist in target tree."""
        # Create and commit a file
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        sha1 = porcelain.commit(
            self.repo.path,
            message=b"First commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Create another file and commit
        fullpath2 = os.path.join(self.repo.path, "bar")
        with open(fullpath2, "w") as f:
            f.write("BAZ")
        porcelain.add(self.repo.path, paths=[fullpath2])
        porcelain.commit(
            self.repo.path,
            message=b"Second commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Reset hard to first commit - this should delete 'bar'
        porcelain.reset(self.repo, "hard", sha1)

        # Check that 'foo' still exists and 'bar' is deleted
        self.assertTrue(os.path.exists(fullpath))
        self.assertFalse(os.path.exists(fullpath2))

        # Check index matches first commit
        index = self.repo.open_index()
        self.assertIn(b"foo", index)
        self.assertNotIn(b"bar", index)

    def test_hard_deletes_files_in_subdirs(self) -> None:
        """Test that reset --hard deletes files in subdirectories."""
        # Create and commit files in subdirectory
        subdir = os.path.join(self.repo.path, "subdir")
        os.makedirs(subdir)
        file1 = os.path.join(subdir, "file1")
        file2 = os.path.join(subdir, "file2")

        with open(file1, "w") as f:
            f.write("content1")
        with open(file2, "w") as f:
            f.write("content2")

        porcelain.add(self.repo.path, paths=[file1, file2])
        porcelain.commit(
            self.repo.path,
            message=b"First commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Remove one file from subdirectory and commit
        porcelain.rm(self.repo.path, paths=[file2])
        sha2 = porcelain.commit(
            self.repo.path,
            message=b"Remove file2",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Create file2 again (untracked)
        with open(file2, "w") as f:
            f.write("new content")

        # Reset to commit that has file2 removed - untracked file2 should remain
        porcelain.reset(self.repo, "hard", sha2)

        self.assertTrue(os.path.exists(file1))
        # Untracked files are not removed by reset --hard
        self.assertTrue(os.path.exists(file2))

    def test_hard_reset_to_remote_branch(self) -> None:
        """Test reset --hard to remote branch deletes local files not in remote."""
        # Create a file and commit
        file1 = os.path.join(self.repo.path, "file1")
        with open(file1, "w") as f:
            f.write("content1")
        porcelain.add(self.repo.path, paths=[file1])
        sha1 = porcelain.commit(
            self.repo.path,
            message=b"Initial commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Create a "remote" ref that doesn't have additional files
        self.repo.refs[b"refs/remotes/origin/master"] = sha1

        # Add another file locally and commit
        file2 = os.path.join(self.repo.path, "file2")
        with open(file2, "w") as f:
            f.write("content2")
        porcelain.add(self.repo.path, paths=[file2])
        porcelain.commit(
            self.repo.path,
            message=b"Add file2",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Both files should exist
        self.assertTrue(os.path.exists(file1))
        self.assertTrue(os.path.exists(file2))

        # Reset to remote branch - should delete file2
        porcelain.reset(self.repo, "hard", b"refs/remotes/origin/master")

        # file1 should exist, file2 should be deleted
        self.assertTrue(os.path.exists(file1))
        self.assertFalse(os.path.exists(file2))

    def test_mixed_reset(self) -> None:
        # Create initial commit
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        first_sha = porcelain.commit(
            self.repo.path,
            message=b"First commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Make second commit with modified content
        with open(fullpath, "w") as f:
            f.write("BAZ")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Second commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Modify working tree without staging
        with open(fullpath, "w") as f:
            f.write("MODIFIED")

        # Mixed reset to first commit
        porcelain.reset(self.repo, "mixed", first_sha)

        # Check that HEAD points to first commit
        self.assertEqual(self.repo.head(), first_sha)

        # Check that index matches first commit
        index = self.repo.open_index()
        changes = list(
            tree_changes(
                self.repo.object_store,
                index.commit(self.repo.object_store),
                self.repo[first_sha].tree,
            )
        )
        self.assertEqual([], changes)

        # Check that working tree is unchanged (still has "MODIFIED")
        with open(fullpath) as f:
            self.assertEqual(f.read(), "MODIFIED")

    def test_soft_reset(self) -> None:
        # Create initial commit
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(self.repo.path, paths=[fullpath])
        first_sha = porcelain.commit(
            self.repo.path,
            message=b"First commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Make second commit with modified content
        with open(fullpath, "w") as f:
            f.write("BAZ")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Second commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Stage a new change
        with open(fullpath, "w") as f:
            f.write("STAGED")
        porcelain.add(self.repo.path, paths=[fullpath])

        # Soft reset to first commit
        porcelain.reset(self.repo, "soft", first_sha)

        # Check that HEAD points to first commit
        self.assertEqual(self.repo.head(), first_sha)

        # Check that index still has the staged change (not reset)
        index = self.repo.open_index()
        # The index should still contain the staged content, not the first commit's content
        self.assertIn(b"foo", index)

        # Check that working tree is unchanged
        with open(fullpath) as f:
            self.assertEqual(f.read(), "STAGED")


class ResetFileTests(PorcelainTestCase):
    def test_reset_modify_file_to_commit(self) -> None:
        file = "foo"
        full_path = os.path.join(self.repo.path, file)

        with open(full_path, "w") as f:
            f.write("hello")
        porcelain.add(self.repo, paths=[full_path])
        sha = porcelain.commit(
            self.repo,
            message=b"unitest",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        with open(full_path, "a") as f:
            f.write("something new")
        porcelain.reset_file(self.repo, file, target=sha)

        with open(full_path) as f:
            self.assertEqual("hello", f.read())

    def test_reset_remove_file_to_commit(self) -> None:
        file = "foo"
        full_path = os.path.join(self.repo.path, file)

        with open(full_path, "w") as f:
            f.write("hello")
        porcelain.add(self.repo, paths=[full_path])
        sha = porcelain.commit(
            self.repo,
            message=b"unitest",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        os.remove(full_path)
        porcelain.reset_file(self.repo, file, target=sha)

        with open(full_path) as f:
            self.assertEqual("hello", f.read())

    def test_resetfile_with_dir(self) -> None:
        os.mkdir(os.path.join(self.repo.path, "new_dir"))
        full_path = os.path.join(self.repo.path, "new_dir", "foo")

        with open(full_path, "w") as f:
            f.write("hello")
        porcelain.add(self.repo, paths=[full_path])
        sha = porcelain.commit(
            self.repo,
            message=b"unitest",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        with open(full_path, "a") as f:
            f.write("something new")
        porcelain.commit(
            self.repo,
            message=b"unitest 2",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        porcelain.reset_file(self.repo, os.path.join("new_dir", "foo"), target=sha)

        with open(full_path) as f:
            self.assertEqual("hello", f.read())


def _commit_file_with_content(repo, filename, content):
    file_path = os.path.join(repo.path, filename)

    with open(file_path, "w") as f:
        f.write(content)
    porcelain.add(repo, paths=[file_path])
    sha = porcelain.commit(
        repo,
        message=b"add " + filename.encode(),
        committer=b"Jane <jane@example.com>",
        author=b"John <john@example.com>",
    )

    return sha, file_path


class RevertTests(PorcelainTestCase):
    def test_revert_simple(self) -> None:
        # Create initial commit
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("initial content\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Initial commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Make a change
        with open(fullpath, "w") as f:
            f.write("modified content\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        change_sha = porcelain.commit(
            self.repo.path,
            message=b"Change content",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Revert the change
        revert_sha = porcelain.revert(self.repo.path, commits=[change_sha])

        # Check the file content is back to initial
        with open(fullpath) as f:
            self.assertEqual("initial content\n", f.read())

        # Check the revert commit message
        revert_commit = self.repo[revert_sha]
        self.assertIn(b'Revert "Change content"', revert_commit.message)
        self.assertIn(change_sha[:7], revert_commit.message)

    def test_revert_multiple(self) -> None:
        # Create initial commit
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("line1\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Initial commit",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Add line2
        with open(fullpath, "a") as f:
            f.write("line2\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        commit1 = porcelain.commit(
            self.repo.path,
            message=b"Add line2",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Add line3
        with open(fullpath, "a") as f:
            f.write("line3\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        commit2 = porcelain.commit(
            self.repo.path,
            message=b"Add line3",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Revert both commits (in reverse order)
        porcelain.revert(self.repo.path, commits=[commit2, commit1])

        # Check file is back to initial state
        with open(fullpath) as f:
            self.assertEqual("line1\n", f.read())

    def test_revert_no_commit(self) -> None:
        # Create initial commit
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("initial\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Initial",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Make a change
        with open(fullpath, "w") as f:
            f.write("changed\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        change_sha = porcelain.commit(
            self.repo.path,
            message=b"Change",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Revert with no_commit
        result = porcelain.revert(self.repo.path, commits=[change_sha], no_commit=True)

        # Should return None
        self.assertIsNone(result)

        # File should be reverted
        with open(fullpath) as f:
            self.assertEqual("initial\n", f.read())

        # HEAD should still point to the change commit
        self.assertEqual(self.repo.refs[b"HEAD"], change_sha)

    def test_revert_custom_message(self) -> None:
        # Create commits
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("initial\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Initial",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        with open(fullpath, "w") as f:
            f.write("changed\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        change_sha = porcelain.commit(
            self.repo.path,
            message=b"Change",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Revert with custom message
        custom_msg = "Custom revert message"
        revert_sha = porcelain.revert(
            self.repo.path, commits=[change_sha], message=custom_msg
        )

        # Check the message
        revert_commit = self.repo[revert_sha]
        self.assertEqual(custom_msg.encode("utf-8"), revert_commit.message)

    def test_revert_no_parent(self) -> None:
        # Try to revert the initial commit (no parent)
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("content\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        initial_sha = porcelain.commit(
            self.repo.path,
            message=b"Initial",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Should raise an error
        with self.assertRaises(porcelain.Error) as cm:
            porcelain.revert(self.repo.path, commits=[initial_sha])
        self.assertIn("no parents", str(cm.exception))

    def _commit_dotgit_hook_tree(self) -> bytes:
        # Build a commit whose tree carries a ".git/hooks/post-checkout"
        # entry. The path resolves through lookup_path, so without the
        # defense-in-depth check it would write a hook into the control dir.
        blob = Blob.from_string(b"#!/bin/sh\ntouch owned\n")
        hooks = Tree()
        hooks.add(b"post-checkout", 0o100755, blob.id)
        dotgit = Tree()
        dotgit.add(b"hooks", stat.S_IFDIR, hooks.id)
        tree = Tree()
        tree.add(b".git", stat.S_IFDIR, dotgit.id)
        for obj in (blob, hooks, dotgit, tree):
            self.repo.object_store.add_object(obj)
        return self.repo.get_worktree().commit(
            message=b"malicious",
            tree=tree.id,
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

    def test_reset_file_rejects_dotgit_path(self) -> None:
        sha = self._commit_dotgit_hook_tree()
        self.assertRaises(
            porcelain.Error,
            porcelain.reset_file,
            self.repo,
            ".git/hooks/post-checkout",
            sha,
        )
        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
        self.assertFalse(os.path.exists(hook))


class CheckoutTests(PorcelainTestCase):
    def setUp(self) -> None:
        super().setUp()
        self._sha, self._foo_path = _commit_file_with_content(
            self.repo, "foo", "hello\n"
        )
        porcelain.branch_create(self.repo, "uni")

    def test_checkout_to_existing_branch(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))
        porcelain.checkout(self.repo, b"uni")
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

    def test_checkout_to_non_existing_branch(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        with self.assertRaises(KeyError):
            porcelain.checkout(self.repo, b"bob")

        self.assertEqual(b"master", porcelain.active_branch(self.repo))

    def test_checkout_to_branch_with_modified_files(self) -> None:
        with open(self._foo_path, "a") as f:
            f.write("new message\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
        )

        # The new checkout behavior prevents switching with staged changes
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.checkout(self.repo, b"uni")

        # Should still be on master
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # Force checkout should work
        porcelain.checkout(self.repo, b"uni", force=True)
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

    def test_checkout_with_deleted_files(self) -> None:
        porcelain.remove(self.repo.path, [os.path.join(self.repo.path, "foo")])
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [b"foo"], "modify": []}, [], []], status
        )

        # The new checkout behavior prevents switching with staged deletions
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.checkout(self.repo, b"uni")

        # Should still be on master
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # Force checkout should work
        porcelain.checkout(self.repo, b"uni", force=True)
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

    def test_checkout_to_branch_with_added_files(self) -> None:
        file_path = os.path.join(self.repo.path, "bar")

        with open(file_path, "w") as f:
            f.write("bar content\n")
        porcelain.add(self.repo, paths=[file_path])
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
        )

        # Both branches have file 'foo' checkout should be fine.
        porcelain.checkout(self.repo, b"uni")
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [b"bar"], "delete": [], "modify": []}, [], []], status
        )

    def test_checkout_to_branch_with_modified_file_not_present(self) -> None:
        # Commit a new file that the other branch doesn't have.
        _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")

        # Modify the file the other branch doesn't have.
        with open(nee_path, "a") as f:
            f.write("bar content\n")
        porcelain.add(self.repo, paths=[nee_path])
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
        )

        # Checkout should fail when there are staged changes that would be lost
        # This matches Git's behavior to prevent data loss
        from dulwich.errors import WorkingTreeModifiedError

        with self.assertRaises(WorkingTreeModifiedError) as cm:
            porcelain.checkout(self.repo, b"uni")

        self.assertIn("nee", str(cm.exception))

        # Should still be on master branch
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # The staged changes should still be present
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
        )
        self.assertTrue(os.path.exists(nee_path))

        # Force checkout should work and lose the changes
        porcelain.checkout(self.repo, b"uni", force=True)
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

        # Now the file should be gone
        self.assertFalse(os.path.exists(nee_path))

    def test_checkout_to_branch_with_modified_file_not_present_forced(self) -> None:
        # Commit a new file that the other branch doesn't have.
        _, nee_path = _commit_file_with_content(self.repo, "nee", "Good content\n")

        # Modify the file the other branch doesn't have.
        with open(nee_path, "a") as f:
            f.write("bar content\n")
        porcelain.add(self.repo, paths=[nee_path])
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
        )

        # 'uni' branch doesn't have 'nee' and it has been modified, but we force to reset the entire index.
        porcelain.checkout(self.repo, b"uni", force=True)

        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

    def test_checkout_to_branch_with_unstaged_files(self) -> None:
        # Edit `foo`.
        with open(self._foo_path, "a") as f:
            f.write("new message")

        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
        )

        # The new checkout behavior prevents switching with unstaged changes
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.checkout(self.repo, b"uni")

        # Should still be on master
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # Force checkout should work
        porcelain.checkout(self.repo, b"uni", force=True)
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))

    def test_checkout_to_branch_with_untracked_files(self) -> None:
        with open(os.path.join(self.repo.path, "neu"), "a") as f:
            f.write("new message\n")

        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": []}, [], [os.fsencode("neu")]], status
        )

        porcelain.checkout(self.repo, b"uni")

        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": []}, [], [os.fsencode("neu")]], status
        )

    def test_checkout_to_branch_with_new_files(self) -> None:
        porcelain.checkout(self.repo, b"uni")
        sub_directory = os.path.join(self.repo.path, "sub1")
        os.mkdir(sub_directory)
        for index in range(5):
            _commit_file_with_content(
                self.repo, "new_file_" + str(index + 1), "Some content\n"
            )
            _commit_file_with_content(
                self.repo,
                os.path.join("sub1", "new_file_" + str(index + 10)),
                "Good content\n",
            )

        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        porcelain.checkout(self.repo, b"master")
        self.assertEqual(b"master", porcelain.active_branch(self.repo))
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        porcelain.checkout(self.repo, b"uni")
        self.assertEqual(b"uni", porcelain.active_branch(self.repo))
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

    def test_checkout_to_branch_with_file_in_sub_directory(self) -> None:
        sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
        os.makedirs(sub_directory)

        sub_directory_file = os.path.join(sub_directory, "neu")
        with open(sub_directory_file, "w") as f:
            f.write("new message\n")

        porcelain.add(self.repo, paths=[sub_directory_file])
        porcelain.commit(
            self.repo,
            message=b"add " + sub_directory_file.encode(),
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        self.assertTrue(os.path.isdir(sub_directory))
        self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))

        porcelain.checkout(self.repo, b"uni")

        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        self.assertFalse(os.path.isdir(sub_directory))
        self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))

        porcelain.checkout(self.repo, b"master")

        self.assertTrue(os.path.isdir(sub_directory))
        self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))

    def test_checkout_to_branch_with_multiple_files_in_sub_directory(self) -> None:
        sub_directory = os.path.join(self.repo.path, "sub1", "sub2")
        os.makedirs(sub_directory)

        sub_directory_file_1 = os.path.join(sub_directory, "neu")
        with open(sub_directory_file_1, "w") as f:
            f.write("new message\n")

        sub_directory_file_2 = os.path.join(sub_directory, "gus")
        with open(sub_directory_file_2, "w") as f:
            f.write("alternative message\n")

        porcelain.add(self.repo, paths=[sub_directory_file_1, sub_directory_file_2])
        porcelain.commit(
            self.repo,
            message=b"add files neu and gus.",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        self.assertTrue(os.path.isdir(sub_directory))
        self.assertTrue(os.path.isdir(os.path.dirname(sub_directory)))

        porcelain.checkout(self.repo, b"uni")

        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        self.assertFalse(os.path.isdir(sub_directory))
        self.assertFalse(os.path.isdir(os.path.dirname(sub_directory)))

    def _commit_something_wrong(self):
        with open(self._foo_path, "a") as f:
            f.write("something wrong")

        porcelain.add(self.repo, paths=[self._foo_path])
        return porcelain.commit(
            self.repo,
            message=b"I may added something wrong",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

    def test_checkout_to_commit_sha(self) -> None:
        self._commit_something_wrong()

        porcelain.checkout(self.repo, self._sha)
        self.assertEqual(self._sha, self.repo.head())

        # Working tree and index should be clean after checkout
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

    def test_checkout_files_starting_with_dotgit(self) -> None:
        # Regression test: paths starting with ".git" (like .github/, .gitignore,
        # .gitattributes) were incorrectly skipped during checkout, leaving the
        # index out of sync with HEAD.
        github_dir = os.path.join(self.repo.path, ".github", "workflows")
        os.makedirs(github_dir)

        github_file = os.path.join(github_dir, "ci.yml")
        gitignore_file = os.path.join(self.repo.path, ".gitignore")

        with open(github_file, "w") as f:
            f.write("# version 1\n")
        with open(gitignore_file, "w") as f:
            f.write("*.pyc\n")

        porcelain.add(self.repo, paths=[github_file, gitignore_file])
        sha1 = porcelain.commit(
            self.repo,
            message=b"add .github and .gitignore",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Update those files in a second commit
        with open(github_file, "w") as f:
            f.write("# version 2\n")
        with open(gitignore_file, "w") as f:
            f.write("*.pyc\n*.pyo\n")

        porcelain.add(self.repo, paths=[github_file, gitignore_file])
        porcelain.commit(
            self.repo,
            message=b"update .github and .gitignore",
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )

        # Checkout the first commit (going back to v1)
        porcelain.checkout(self.repo, sha1)

        # Working tree and index should be clean (no staged changes)
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

        # Working tree files should have v1 content
        with open(github_file) as f:
            self.assertEqual("# version 1\n", f.read())
        with open(gitignore_file) as f:
            self.assertEqual("*.pyc\n", f.read())

    def test_checkout_rejects_dotgit_path(self) -> None:
        # A path-specific checkout of ".git/hooks/post-checkout" resolves in
        # the tree, so without the defense-in-depth check it would drop a hook
        # into the control directory.
        blob = Blob.from_string(b"#!/bin/sh\ntouch owned\n")
        hooks = Tree()
        hooks.add(b"post-checkout", 0o100755, blob.id)
        dotgit = Tree()
        dotgit.add(b"hooks", stat.S_IFDIR, hooks.id)
        tree = Tree()
        tree.add(b".git", stat.S_IFDIR, dotgit.id)
        for obj in (blob, hooks, dotgit, tree):
            self.repo.object_store.add_object(obj)
        sha = self.repo.get_worktree().commit(
            message=b"malicious",
            tree=tree.id,
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        self.assertRaises(
            porcelain.Error,
            porcelain.checkout,
            self.repo,
            sha,
            paths=[b".git/hooks/post-checkout"],
        )
        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
        self.assertFalse(os.path.exists(hook))

    def test_checkout_to_head(self) -> None:
        new_sha = self._commit_something_wrong()

        porcelain.checkout(self.repo, b"HEAD")
        self.assertEqual(new_sha, self.repo.head())

    def _checkout_remote_branch(self):
        errstream = BytesIO()
        outstream = BytesIO()

        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        self.addCleanup(target_repo.close)
        self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])

        # create a second file to be pushed back to origin
        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        porcelain.commit(
            repo=clone_path,
            message=b"push",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup a non-checked out branch in the remote
        refs_path = b"refs/heads/foo"
        new_id = self.repo[b"HEAD"].id
        self.assertNotEqual(new_id, ZERO_SHA)
        self.repo.refs[refs_path] = new_id

        # Push to the remote
        porcelain.push(
            clone_path,
            "origin",
            b"HEAD:" + refs_path,
            outstream=outstream,
            errstream=errstream,
        )

        self.assertEqual(
            target_repo.refs[b"refs/remotes/origin/foo"],
            target_repo.refs[b"HEAD"],
        )

        # The new checkout behavior treats origin/foo as a ref and creates detached HEAD
        porcelain.checkout(target_repo, b"origin/foo")
        original_id = target_repo[b"HEAD"].id
        uni_id = target_repo[b"refs/remotes/origin/uni"].id

        # Should be in detached HEAD state
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(target_repo)

        expected_refs = {
            b"HEAD": original_id,
            b"refs/heads/master": original_id,
            # No local foo branch is created anymore
            b"refs/remotes/origin/foo": original_id,
            b"refs/remotes/origin/uni": uni_id,
            b"refs/remotes/origin/HEAD": new_id,
            b"refs/remotes/origin/master": new_id,
        }
        self.assertEqual(expected_refs, target_repo.get_refs())

        return target_repo

    def test_checkout_remote_branch(self) -> None:
        repo = self._checkout_remote_branch()
        repo.close()

    def test_checkout_remote_branch_then_master_then_remote_branch_again(self) -> None:
        target_repo = self._checkout_remote_branch()
        # Should be in detached HEAD state
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(target_repo)

        # Save the commit SHA before adding bar
        detached_commit_sha, _ = _commit_file_with_content(
            target_repo, "bar", "something\n"
        )
        self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))

        porcelain.checkout(target_repo, b"master")

        self.assertEqual(b"master", porcelain.active_branch(target_repo))
        self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))

        # Going back to origin/foo won't have bar because the commit was made in detached state
        porcelain.checkout(target_repo, b"origin/foo")

        # Should be in detached HEAD state again
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(target_repo)
        # bar is NOT there because we're back at the original origin/foo commit
        self.assertFalse(os.path.isfile(os.path.join(target_repo.path, "bar")))

        # But we can checkout the specific commit to get bar back
        porcelain.checkout(target_repo, detached_commit_sha.decode())
        self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))

        target_repo.close()

    def test_checkout_new_branch_from_remote_sets_tracking(self) -> None:
        # Create a "remote" repository
        remote_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, remote_path)
        remote_repo = porcelain.init(remote_path)

        # Add a commit to the remote
        remote_sha, _ = _commit_file_with_content(
            remote_repo, "bar", "remote content\n"
        )

        # Clone the remote repository
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        target_repo = porcelain.clone(remote_path, target_path)
        self.addCleanup(target_repo.close)

        # Create a remote tracking branch reference
        remote_branch_ref = b"refs/remotes/origin/feature"
        target_repo.refs[remote_branch_ref] = remote_sha

        # Checkout a new branch from the remote branch
        porcelain.checkout(target_repo, remote_branch_ref, new_branch=b"local-feature")

        # Verify the branch was created and is active
        self.assertEqual(b"local-feature", porcelain.active_branch(target_repo))

        # Verify tracking configuration was set
        config = target_repo.get_config()
        self.assertEqual(
            b"origin", config.get((b"branch", b"local-feature"), b"remote")
        )
        self.assertEqual(
            b"refs/heads/feature", config.get((b"branch", b"local-feature"), b"merge")
        )

        target_repo.close()
        remote_repo.close()


class RestoreTests(PorcelainTestCase):
    """Tests for the restore command."""

    def setUp(self) -> None:
        super().setUp()
        self._sha, self._foo_path = _commit_file_with_content(
            self.repo, "foo", "original\n"
        )

    def test_restore_worktree_from_index(self) -> None:
        # Modify the working tree file
        with open(self._foo_path, "w") as f:
            f.write("modified\n")

        # Restore from index (should restore to original)
        porcelain.restore(self.repo, paths=["foo"])

        with open(self._foo_path) as f:
            content = f.read()
        self.assertEqual("original\n", content)

    def test_restore_worktree_from_head(self) -> None:
        # Modify and stage the file
        with open(self._foo_path, "w") as f:
            f.write("staged\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        # Now modify it again in worktree
        with open(self._foo_path, "w") as f:
            f.write("worktree\n")

        # Restore from HEAD (should restore to original, not staged)
        porcelain.restore(self.repo, paths=["foo"], source="HEAD")

        with open(self._foo_path) as f:
            content = f.read()
        self.assertEqual("original\n", content)

    def test_restore_staged_from_head(self) -> None:
        # Modify and stage the file
        with open(self._foo_path, "w") as f:
            f.write("staged\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        # Verify it's staged
        status = list(porcelain.status(self.repo))
        self.assertEqual(
            [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
        )

        # Restore staged from HEAD
        porcelain.restore(self.repo, paths=["foo"], staged=True, worktree=False)

        # Verify it's no longer staged
        status = list(porcelain.status(self.repo))
        # Now it should show as unstaged modification
        self.assertEqual(
            [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
        )

    def test_restore_both_staged_and_worktree(self) -> None:
        # Modify and stage the file
        with open(self._foo_path, "w") as f:
            f.write("staged\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        # Now modify it again in worktree
        with open(self._foo_path, "w") as f:
            f.write("worktree\n")

        # Restore both from HEAD
        porcelain.restore(self.repo, paths=["foo"], staged=True, worktree=True)

        # Verify content is restored
        with open(self._foo_path) as f:
            content = f.read()
        self.assertEqual("original\n", content)

        # Verify nothing is staged
        status = list(porcelain.status(self.repo))
        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)

    def test_restore_nonexistent_path(self) -> None:
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.restore(self.repo, paths=["nonexistent"])

    def test_restore_rejects_dotgit_path(self) -> None:
        # Restoring ".git/hooks/post-checkout" from a source tree resolves,
        # so the defense-in-depth check must refuse it before writing.
        blob = Blob.from_string(b"#!/bin/sh\ntouch owned\n")
        hooks = Tree()
        hooks.add(b"post-checkout", 0o100755, blob.id)
        dotgit = Tree()
        dotgit.add(b"hooks", stat.S_IFDIR, hooks.id)
        tree = Tree()
        tree.add(b".git", stat.S_IFDIR, dotgit.id)
        for obj in (blob, hooks, dotgit, tree):
            self.repo.object_store.add_object(obj)
        sha = self.repo.get_worktree().commit(
            message=b"malicious",
            tree=tree.id,
            committer=b"Jane <jane@example.com>",
            author=b"John <john@example.com>",
        )
        self.assertRaises(
            porcelain.Error,
            porcelain.restore,
            self.repo,
            paths=[".git/hooks/post-checkout"],
            source=sha,
        )
        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
        self.assertFalse(os.path.exists(hook))


class CheckWorktreePathTests(PorcelainTestCase):
    """Tests for the _checked_worktree_path defense-in-depth helper."""

    def test_rejects_unsafe_paths(self) -> None:
        from dulwich.porcelain import _checked_worktree_path

        for bad in (
            b".git/hooks",
            b".git",
            b"..",
            b"a/../b",
            b"/abs",
            b"\\abs",
        ):
            self.assertRaises(porcelain.Error, _checked_worktree_path, self.repo, bad)

    def test_allows_ordinary_paths(self) -> None:
        from dulwich.porcelain import _checked_worktree_path

        root = os.fsencode(self.repo.path)
        self.assertEqual(
            os.path.join(root, b"foo"),
            _checked_worktree_path(self.repo, b"foo"),
        )
        self.assertEqual(
            os.path.join(root, b"libs/foo"),
            _checked_worktree_path(self.repo, b"libs/foo"),
        )
        self.assertEqual(
            os.path.join(root, b".github/workflows/ci.yml"),
            _checked_worktree_path(self.repo, b".github/workflows/ci.yml"),
        )


class SwitchTests(PorcelainTestCase):
    """Tests for the switch command."""

    def setUp(self) -> None:
        super().setUp()
        self._sha, self._foo_path = _commit_file_with_content(
            self.repo, "foo", "hello\n"
        )
        porcelain.branch_create(self.repo, "dev")

    def test_switch_to_existing_branch(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))
        porcelain.switch(self.repo, "dev")
        self.assertEqual(b"dev", porcelain.active_branch(self.repo))

    def test_switch_to_non_existing_branch(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        with self.assertRaises(KeyError):
            porcelain.switch(self.repo, "nonexistent")

        self.assertEqual(b"master", porcelain.active_branch(self.repo))

    def test_switch_with_create(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))
        porcelain.switch(self.repo, "master", create="feature")
        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

    def test_switch_with_detach(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))
        porcelain.switch(self.repo, self._sha.decode(), detach=True)
        # In detached HEAD state, active_branch raises IndexError
        with self.assertRaises(IndexError):
            porcelain.active_branch(self.repo)

    def test_switch_with_uncommitted_changes(self) -> None:
        # Modify the file
        with open(self._foo_path, "a") as f:
            f.write("new content\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        # Switch should fail due to uncommitted changes
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.switch(self.repo, "dev")

        # Should still be on master
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

    def test_switch_with_force(self) -> None:
        # Modify the file
        with open(self._foo_path, "a") as f:
            f.write("new content\n")
        porcelain.add(self.repo, paths=[self._foo_path])

        # Force switch should work
        porcelain.switch(self.repo, "dev", force=True)
        self.assertEqual(b"dev", porcelain.active_branch(self.repo))

    def test_switch_to_commit_without_detach(self) -> None:
        # Switching to a commit SHA without --detach should fail
        with self.assertRaises(porcelain.CheckoutError):
            porcelain.switch(self.repo, self._sha.decode())


class GeneralCheckoutTests(PorcelainTestCase):
    """Tests for the general checkout function that handles branches, tags, and commits."""

    def setUp(self) -> None:
        super().setUp()
        # Create initial commit
        self._sha1, self._foo_path = _commit_file_with_content(
            self.repo, "foo", "initial content\n"
        )
        # Create a branch
        porcelain.branch_create(self.repo, "feature")
        # Create another commit on master
        self._sha2, self._bar_path = _commit_file_with_content(
            self.repo, "bar", "bar content\n"
        )
        # Create a tag
        porcelain.tag_create(self.repo, "v1.0", objectish=self._sha1)

    def test_checkout_branch(self) -> None:
        """Test checking out a branch."""
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # Checkout feature branch
        porcelain.checkout(self.repo, "feature")
        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

        # File 'bar' should not exist in feature branch
        self.assertFalse(os.path.exists(self._bar_path))

        # Go back to master
        porcelain.checkout(self.repo, "master")
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

        # File 'bar' should exist again
        self.assertTrue(os.path.exists(self._bar_path))

    def test_checkout_commit(self) -> None:
        """Test checking out a specific commit (detached HEAD)."""
        # Checkout first commit by SHA
        porcelain.checkout(self.repo, self._sha1.decode("ascii"))

        # Should be in detached HEAD state - active_branch raises IndexError
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(self.repo)

        # File 'bar' should not exist
        self.assertFalse(os.path.exists(self._bar_path))

        # HEAD should point to the commit
        self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])

    def test_checkout_tag(self) -> None:
        """Test checking out a tag (detached HEAD)."""
        # Checkout tag
        porcelain.checkout(self.repo, "v1.0")

        # Should be in detached HEAD state - active_branch raises IndexError
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(self.repo)

        # File 'bar' should not exist (tag points to first commit)
        self.assertFalse(os.path.exists(self._bar_path))

        # HEAD should point to the tagged commit
        self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])

    def test_checkout_new_branch(self) -> None:
        """Test creating a new branch during checkout (like git checkout -b)."""
        # Create and checkout new branch from current HEAD
        porcelain.checkout(self.repo, "master", new_branch="new-feature")

        self.assertEqual(b"new-feature", porcelain.active_branch(self.repo))
        self.assertTrue(os.path.exists(self._bar_path))

        # Create and checkout new branch from specific commit
        porcelain.checkout(self.repo, self._sha1.decode("ascii"), new_branch="from-old")

        self.assertEqual(b"from-old", porcelain.active_branch(self.repo))
        self.assertFalse(os.path.exists(self._bar_path))

    def test_checkout_with_uncommitted_changes(self) -> None:
        """Test checkout behavior with uncommitted changes."""
        # Modify a file
        with open(self._foo_path, "w") as f:
            f.write("modified content\n")

        # Should raise error when trying to checkout
        with self.assertRaises(porcelain.CheckoutError) as cm:
            porcelain.checkout(self.repo, "feature")

        self.assertIn("local changes", str(cm.exception))
        self.assertIn("foo", str(cm.exception))

        # Should still be on master
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

    def test_checkout_force(self) -> None:
        """Test forced checkout discards local changes for files that differ between branches."""
        # Modify a file
        with open(self._foo_path, "w") as f:
            f.write("modified content\n")

        # Force checkout should succeed
        porcelain.checkout(self.repo, "feature", force=True)

        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

        # Since foo has the same content in master and feature branches,
        # checkout should NOT restore it - the modified content should remain
        with open(self._foo_path) as f:
            content = f.read()
        self.assertEqual("modified content\n", content)

    def test_checkout_nonexistent_ref(self) -> None:
        """Test checkout of non-existent branch/commit."""
        with self.assertRaises(KeyError):
            porcelain.checkout(self.repo, "nonexistent")

    def test_checkout_partial_sha(self) -> None:
        """Test checkout with partial SHA."""
        # Git typically allows checkout with partial SHA
        partial_sha = self._sha1.decode("ascii")[:7]
        porcelain.checkout(self.repo, partial_sha)

        # Should be in detached HEAD state at the right commit
        self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])

    def test_checkout_preserves_untracked_files(self) -> None:
        """Test that checkout preserves untracked files."""
        # Create an untracked file
        untracked_path = os.path.join(self.repo.path, "untracked.txt")
        with open(untracked_path, "w") as f:
            f.write("untracked content\n")

        # Checkout another branch
        porcelain.checkout(self.repo, "feature")

        # Untracked file should still exist
        self.assertTrue(os.path.exists(untracked_path))
        with open(untracked_path) as f:
            content = f.read()
        self.assertEqual("untracked content\n", content)

    def test_checkout_full_ref_paths(self) -> None:
        """Test checkout with full ref paths."""
        # Test checkout with full branch ref path
        porcelain.checkout(self.repo, "refs/heads/feature")
        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

        # Test checkout with full tag ref path
        porcelain.checkout(self.repo, "refs/tags/v1.0")
        # Should be in detached HEAD state
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(self.repo)
        self.assertEqual(self._sha1, self.repo.refs[b"HEAD"])

    def test_checkout_bytes_vs_string_target(self) -> None:
        """Test that checkout works with both bytes and string targets."""
        # Test with string target
        porcelain.checkout(self.repo, "feature")
        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

        # Test with bytes target
        porcelain.checkout(self.repo, b"master")
        self.assertEqual(b"master", porcelain.active_branch(self.repo))

    def test_checkout_new_branch_from_commit(self) -> None:
        """Test creating a new branch from a specific commit."""
        # Create new branch from first commit
        porcelain.checkout(self.repo, self._sha1.decode(), new_branch="from-commit")

        self.assertEqual(b"from-commit", porcelain.active_branch(self.repo))
        # Should be at the first commit (no bar file)
        self.assertFalse(os.path.exists(self._bar_path))

    def test_checkout_with_staged_addition(self) -> None:
        """Test checkout behavior with staged file additions."""
        # Create and stage a new file that doesn't exist in target branch
        new_file_path = os.path.join(self.repo.path, "new.txt")
        with open(new_file_path, "w") as f:
            f.write("new file content\n")
        porcelain.add(self.repo, [new_file_path])

        # This should succeed because the file doesn't exist in target branch
        porcelain.checkout(self.repo, "feature")

        # Should be on feature branch
        self.assertEqual(b"feature", porcelain.active_branch(self.repo))

        # The new file should still exist and be staged
        self.assertTrue(os.path.exists(new_file_path))
        status = porcelain.status(self.repo)
        self.assertIn(b"new.txt", status.staged["add"])

    def test_checkout_with_staged_modification_conflict(self) -> None:
        """Test checkout behavior with staged modifications that would conflict."""
        # Stage changes to a file that exists in both branches
        with open(self._foo_path, "w") as f:
            f.write("modified content\n")
        porcelain.add(self.repo, [self._foo_path])

        # Should prevent checkout due to staged changes to existing file
        with self.assertRaises(porcelain.CheckoutError) as cm:
            porcelain.checkout(self.repo, "feature")

        self.assertIn("local changes", str(cm.exception))
        self.assertIn("foo", str(cm.exception))

    def test_checkout_head_reference(self) -> None:
        """Test checkout of HEAD reference."""
        # Move to feature branch first
        porcelain.checkout(self.repo, "feature")

        # Checkout HEAD creates detached HEAD state
        porcelain.checkout(self.repo, "HEAD")

        # Should be in detached HEAD state
        with self.assertRaises((ValueError, IndexError)):
            porcelain.active_branch(self.repo)

    def test_checkout_error_messages(self) -> None:
        """Test that checkout error messages are helpful."""
        # Create uncommitted changes
        with open(self._foo_path, "w") as f:
            f.write("uncommitted changes\n")

        # Try to checkout
        with self.assertRaises(porcelain.CheckoutError) as cm:
            porcelain.checkout(self.repo, "feature")

        error_msg = str(cm.exception)
        self.assertIn("local changes", error_msg)
        self.assertIn("foo", error_msg)
        self.assertIn("overwritten", error_msg)
        self.assertIn("commit or stash", error_msg)


class SubmoduleTests(PorcelainTestCase):
    def test_empty(self) -> None:
        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        self.assertEqual([], list(porcelain.submodule_list(self.repo)))

    def test_add(self) -> None:
        porcelain.submodule_add(self.repo, "../bar.git", "bar")
        with open(f"{self.repo.path}/.gitmodules") as f:
            self.assertEqual(
                """\
[submodule "bar"]
\turl = ../bar.git
\tpath = bar
""",
                f.read(),
            )

    def test_init(self) -> None:
        porcelain.submodule_add(self.repo, "../bar.git", "bar")
        porcelain.submodule_init(self.repo)

    def test_update(self) -> None:
        # Create a submodule repository
        sub_repo_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, sub_repo_path)
        sub_repo = Repo.init(sub_repo_path)
        self.addCleanup(sub_repo.close)

        # Add a file to the submodule repo
        sub_file = os.path.join(sub_repo_path, "test.txt")
        with open(sub_file, "w") as f:
            f.write("submodule content")

        porcelain.add(sub_repo, paths=[sub_file])
        sub_commit = porcelain.commit(
            sub_repo,
            message=b"Initial submodule commit",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Add the submodule to the main repository
        porcelain.submodule_add(self.repo, sub_repo_path, "test_submodule")

        # Manually add the submodule to the index
        from dulwich.index import IndexEntry
        from dulwich.objects import S_IFGITLINK

        index = self.repo.open_index()
        index[b"test_submodule"] = IndexEntry(
            ctime=0,
            mtime=0,
            dev=0,
            ino=0,
            mode=S_IFGITLINK,
            uid=0,
            gid=0,
            size=0,
            sha=sub_commit,
            flags=0,
        )
        index.write()

        porcelain.add(self.repo, paths=[".gitmodules"])
        porcelain.commit(
            self.repo,
            message=b"Add submodule",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Initialize and update the submodule
        porcelain.submodule_init(self.repo)
        porcelain.submodule_update(self.repo)

        # Check that the submodule directory exists
        submodule_path = os.path.join(self.repo.path, "test_submodule")
        self.assertTrue(os.path.exists(submodule_path))

        # Check that the submodule file exists
        submodule_file = os.path.join(submodule_path, "test.txt")
        self.assertTrue(os.path.exists(submodule_file))
        with open(submodule_file) as f:
            self.assertEqual(f.read(), "submodule content")

    def test_update_recursive(self) -> None:
        # Create a nested (innermost) submodule repository
        nested_repo_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, nested_repo_path)
        nested_repo = Repo.init(nested_repo_path)
        self.addCleanup(nested_repo.close)

        # Add a file to the nested repo
        nested_file = os.path.join(nested_repo_path, "nested.txt")
        with open(nested_file, "w") as f:
            f.write("nested submodule content")

        porcelain.add(nested_repo, paths=[nested_file])
        nested_commit = porcelain.commit(
            nested_repo,
            message=b"Initial nested commit",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Create a middle submodule repository
        middle_repo_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, middle_repo_path)
        middle_repo = Repo.init(middle_repo_path)
        self.addCleanup(middle_repo.close)

        # Add a file to the middle repo
        middle_file = os.path.join(middle_repo_path, "middle.txt")
        with open(middle_file, "w") as f:
            f.write("middle submodule content")

        porcelain.add(middle_repo, paths=[middle_file])

        # Add the nested submodule to the middle repository
        porcelain.submodule_add(middle_repo, nested_repo_path, "nested")

        # Manually add the nested submodule to the index
        from dulwich.index import IndexEntry
        from dulwich.objects import S_IFGITLINK

        middle_index = middle_repo.open_index()
        middle_index[b"nested"] = IndexEntry(
            ctime=0,
            mtime=0,
            dev=0,
            ino=0,
            mode=S_IFGITLINK,
            uid=0,
            gid=0,
            size=0,
            sha=nested_commit,
            flags=0,
        )
        middle_index.write()

        porcelain.add(middle_repo, paths=[".gitmodules"])
        middle_commit = porcelain.commit(
            middle_repo,
            message=b"Add nested submodule",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Add the middle submodule to the main repository
        porcelain.submodule_add(self.repo, middle_repo_path, "middle")

        # Manually add the middle submodule to the index
        main_index = self.repo.open_index()
        main_index[b"middle"] = IndexEntry(
            ctime=0,
            mtime=0,
            dev=0,
            ino=0,
            mode=S_IFGITLINK,
            uid=0,
            gid=0,
            size=0,
            sha=middle_commit,
            flags=0,
        )
        main_index.write()

        porcelain.add(self.repo, paths=[".gitmodules"])
        porcelain.commit(
            self.repo,
            message=b"Add middle submodule",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Initialize and recursively update the submodules
        porcelain.submodule_init(self.repo)
        porcelain.submodule_update(self.repo, recursive=True)

        # Check that the middle submodule directory and file exist
        middle_submodule_path = os.path.join(self.repo.path, "middle")
        self.assertTrue(os.path.exists(middle_submodule_path))

        middle_submodule_file = os.path.join(middle_submodule_path, "middle.txt")
        self.assertTrue(os.path.exists(middle_submodule_file))
        with open(middle_submodule_file) as f:
            self.assertEqual(f.read(), "middle submodule content")

        # Check that the nested submodule directory and file exist
        nested_submodule_path = os.path.join(self.repo.path, "middle", "nested")
        self.assertTrue(os.path.exists(nested_submodule_path))

        nested_submodule_file = os.path.join(nested_submodule_path, "nested.txt")
        self.assertTrue(os.path.exists(nested_submodule_file))
        with open(nested_submodule_file) as f:
            self.assertEqual(f.read(), "nested submodule content")

    def _build_malicious_submodule_repo(self, submodule_path):
        """Build a parent repo whose gitlink path is attacker-controlled.

        Returns the path to a bare attacker submodule repository and commits a
        matching ``.gitmodules`` plus tree gitlink entry into ``self.repo``,
        both pointing at ``submodule_path``.
        """
        attacker_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, attacker_path)
        attacker = Repo.init_bare(attacker_path, mkdir=False)
        self.addCleanup(attacker.close)

        payload = Blob.from_string(b"#!/bin/sh\necho PWNED\n")
        attacker.object_store.add_object(payload)
        tree = Tree()
        tree.add(b"post-checkout", 0o100755, payload.id)
        attacker.object_store.add_object(tree)
        commit = Commit()
        commit.tree = tree.id
        commit.author = commit.committer = b"a <a@a>"
        commit.author_time = commit.commit_time = 0
        commit.author_timezone = commit.commit_timezone = 0
        commit.message = b"payload"
        attacker.object_store.add_object(commit)
        attacker.refs[b"refs/heads/master"] = commit.id
        attacker.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")

        gitmodules = (
            b'[submodule "evil"]\n'
            b"\tpath = " + submodule_path + b"\n"
            b"\turl = " + attacker_path.encode() + b"\n"
        )
        # A real clone checks .gitmodules out into the work tree; write it
        # directly so submodule_update can read it without a full checkout.
        with open(os.path.join(self.repo.path, ".gitmodules"), "wb") as f:
            f.write(gitmodules)
        gmb = Blob.from_string(gitmodules)
        self.repo.object_store.add_object(gmb)
        vt = Tree()
        vt.add(b".gitmodules", 0o100644, gmb.id)
        vt.add(submodule_path, 0o160000, commit.id)
        self.repo.object_store.add_object(vt)
        vc = Commit()
        vc.tree = vt.id
        vc.author = vc.committer = b"a <a@a>"
        vc.author_time = vc.commit_time = 0
        vc.author_timezone = vc.commit_timezone = 0
        vc.message = b"parent"
        self.repo.object_store.add_object(vc)
        self.repo.refs[b"refs/heads/master"] = vc.id
        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
        return attacker_path

    def test_update_rejects_dotgit_path(self) -> None:
        # A submodule path of .git/hooks would drop the attacker's tree
        # into the parent's .git/hooks directory (CVE-style RCE via hooks).
        self._build_malicious_submodule_repo(b".git/hooks")
        self.assertRaises(
            porcelain.Error,
            porcelain.submodule_update,
            self.repo,
            init=True,
        )
        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
        self.assertFalse(os.path.exists(hook))

    def test_update_rejects_parent_traversal_path(self) -> None:
        self._build_malicious_submodule_repo(b"../escape")
        self.assertRaises(
            porcelain.Error,
            porcelain.submodule_update,
            self.repo,
            init=True,
        )

    def test_check_submodule_path(self) -> None:
        from dulwich.index import (
            validate_path_element_default,
            validate_path_element_ntfs,
        )
        from dulwich.porcelain.submodule import _check_submodule_path

        # .git and .. components are rejected on every platform.
        for bad in (b".git/hooks", b"..", b"a/../b", b"/abs", b".git"):
            self.assertRaises(
                porcelain.Error, _check_submodule_path, bad, validate_path_element_ntfs
            )

        # A path that is only unsafe on NTFS (a reserved device name) is
        # refused under the default protectNTFS validator but, like git with
        # core.protectNTFS=false, accepted by the default validator so an
        # existing POSIX repository can still be updated.
        self.assertRaises(
            porcelain.Error, _check_submodule_path, b"aux", validate_path_element_ntfs
        )
        _check_submodule_path(b"aux", validate_path_element_default)

        # Ordinary nested paths pass under either validator.
        _check_submodule_path(b"libs/foo", validate_path_element_ntfs)


class PushTests(PorcelainTestCase):
    def test_simple(self) -> None:
        """Basic test of porcelain push where self.repo is the remote.  First
        clone the remote, commit a file to the clone, then push the changes
        back to the remote.
        """
        outstream = BytesIO()
        errstream = BytesIO()

        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        self.addCleanup(target_repo.close)
        self.assertEqual(target_repo[b"HEAD"], self.repo[b"HEAD"])

        # create a second file to be pushed back to origin
        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        porcelain.commit(
            repo=clone_path,
            message=b"push",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup a non-checked out branch in the remote
        refs_path = b"refs/heads/foo"
        new_id = self.repo[b"HEAD"].id
        self.assertNotEqual(new_id, ZERO_SHA)
        self.repo.refs[refs_path] = new_id

        # Push to the remote
        porcelain.push(
            clone_path,
            "origin",
            b"HEAD:" + refs_path,
            outstream=outstream,
            errstream=errstream,
        )

        self.assertEqual(
            target_repo.refs[b"refs/remotes/origin/foo"],
            target_repo.refs[b"HEAD"],
        )

        # Check that the target and source
        with Repo(clone_path) as r_clone:
            self.assertEqual(
                {
                    b"HEAD": new_id,
                    b"refs/heads/foo": r_clone[b"HEAD"].id,
                    b"refs/heads/master": new_id,
                },
                self.repo.get_refs(),
            )
            self.assertEqual(r_clone[b"HEAD"].id, self.repo[refs_path].id)

            # Get the change in the target repo corresponding to the add
            # this will be in the foo branch.
            change = next(
                iter(
                    tree_changes(
                        self.repo.object_store,
                        self.repo[b"HEAD"].tree,
                        self.repo[b"refs/heads/foo"].tree,
                    )
                )
            )
            self.assertEqual(
                os.path.basename(fullpath), change.new.path.decode("ascii")
            )

    def test_local_missing(self) -> None:
        """Pushing a new branch."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.init(clone_path)
        target_repo.close()

        self.assertRaises(
            porcelain.Error,
            porcelain.push,
            self.repo,
            clone_path,
            b"HEAD:refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

    def test_new(self) -> None:
        """Pushing a new branch."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.init(clone_path)
        target_repo.close()

        # create a second file to be pushed back to origin
        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        new_id = porcelain.commit(
            repo=self.repo,
            message=b"push",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Push to the remote
        porcelain.push(
            self.repo,
            clone_path,
            b"HEAD:refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        with Repo(clone_path) as r_clone:
            self.assertEqual(
                {
                    b"HEAD": new_id,
                    b"refs/heads/master": new_id,
                },
                r_clone.get_refs(),
            )

    def test_delete(self) -> None:
        """Basic test of porcelain push, removing a branch."""
        outstream = BytesIO()
        errstream = BytesIO()

        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()

        # Setup a non-checked out branch in the remote
        refs_path = b"refs/heads/foo"
        new_id = self.repo[b"HEAD"].id
        self.assertNotEqual(new_id, ZERO_SHA)
        self.repo.refs[refs_path] = new_id

        # Push to the remote
        porcelain.push(
            clone_path,
            self.repo.path,
            b":" + refs_path,
            outstream=outstream,
            errstream=errstream,
        )

        self.assertEqual(
            {
                b"HEAD": new_id,
                b"refs/heads/master": new_id,
            },
            self.repo.get_refs(),
        )

    def test_diverged(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()

        remote_id = porcelain.commit(
            repo=self.repo.path,
            message=b"remote change",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        local_id = porcelain.commit(
            repo=clone_path,
            message=b"local change",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        outstream = BytesIO()
        errstream = BytesIO()

        # Push to the remote
        self.assertRaises(
            porcelain.DivergedBranches,
            porcelain.push,
            clone_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        self.assertEqual(
            {
                b"HEAD": remote_id,
                b"refs/heads/master": remote_id,
            },
            self.repo.get_refs(),
        )

        self.assertEqual(b"", outstream.getvalue())
        self.assertEqual(b"", errstream.getvalue())

        outstream = BytesIO()
        errstream = BytesIO()

        # Push to the remote with --force
        porcelain.push(
            clone_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
            force=True,
        )

        self.assertEqual(
            {
                b"HEAD": local_id,
                b"refs/heads/master": local_id,
            },
            self.repo.get_refs(),
        )

        self.assertEqual(b"", outstream.getvalue())
        self.assertTrue(re.search(b"Push to .* successful.\n", errstream.getvalue()))

    def test_push_returns_sendpackresult(self) -> None:
        """Test that push returns a SendPackResult with per-ref information."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create initial commit
        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()

        # Create a commit in the clone
        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        porcelain.commit(
            repo=clone_path,
            message=b"push",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Push and check the return value
        result = porcelain.push(
            clone_path,
            "origin",
            b"HEAD:refs/heads/new-branch",
            outstream=outstream,
            errstream=errstream,
        )

        # Verify that we get a SendPackResult
        self.assertIsInstance(result, SendPackResult)

        # Verify that it contains refs
        self.assertIsNotNone(result.refs)
        self.assertIn(b"refs/heads/new-branch", result.refs)

        # Verify ref_status - should be None for successful updates
        if result.ref_status:
            self.assertIsNone(result.ref_status.get(b"refs/heads/new-branch"))

    def test_mirror_mode(self) -> None:
        """Test push with remote.<name>.mirror configuration."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create initial commit
        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()

        # Create multiple refs in the clone
        with Repo(clone_path) as r_clone:
            # Create a new branch
            r_clone.refs[b"refs/heads/feature"] = r_clone[b"HEAD"].id
            # Create a tag
            r_clone.refs[b"refs/tags/v1.0"] = r_clone[b"HEAD"].id
            # Create a remote tracking branch
            r_clone.refs[b"refs/remotes/upstream/main"] = r_clone[b"HEAD"].id

        # Create a branch in the remote that doesn't exist in clone
        self.repo.refs[b"refs/heads/to-be-deleted"] = self.repo[b"HEAD"].id

        # Configure mirror mode
        with Repo(clone_path) as r_clone:
            config = r_clone.get_config()
            config.set((b"remote", b"origin"), b"mirror", True)
            config.write_to_path()

        # Push with mirror mode
        porcelain.push(
            clone_path,
            "origin",
            outstream=outstream,
            errstream=errstream,
        )

        # Verify refs were properly mirrored
        with Repo(clone_path) as r_clone:
            # All local branches should be pushed
            self.assertEqual(
                r_clone.refs[b"refs/heads/feature"],
                self.repo.refs[b"refs/heads/feature"],
            )
            # All tags should be pushed
            self.assertEqual(
                r_clone.refs[b"refs/tags/v1.0"], self.repo.refs[b"refs/tags/v1.0"]
            )
            # Remote tracking branches should be pushed
            self.assertEqual(
                r_clone.refs[b"refs/remotes/upstream/main"],
                self.repo.refs[b"refs/remotes/upstream/main"],
            )

        # Verify the extra branch was deleted
        self.assertNotIn(b"refs/heads/to-be-deleted", self.repo.refs)

    def test_mirror_mode_disabled(self) -> None:
        """Test that mirror mode is properly disabled when set to false."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create initial commit
        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Setup target repo cloned from temp test repo
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()

        # Create a branch in the remote that doesn't exist in clone
        self.repo.refs[b"refs/heads/should-not-be-deleted"] = self.repo[b"HEAD"].id

        # Explicitly set mirror mode to false
        with Repo(clone_path) as r_clone:
            config = r_clone.get_config()
            config.set((b"remote", b"origin"), b"mirror", False)
            config.write_to_path()

        # Push normally (not mirror mode)
        porcelain.push(
            clone_path,
            "origin",
            outstream=outstream,
            errstream=errstream,
        )

        # Verify the extra branch was NOT deleted
        self.assertIn(b"refs/heads/should-not-be-deleted", self.repo.refs)


class PushOptionsTests(PorcelainTestCase):
    """Tests for the new push options (--all, --tags, --delete, etc.)."""

    def _setup_clone(self):
        """Helper to create a commit and clone."""
        errstream = BytesIO()
        porcelain.commit(
            repo=self.repo.path,
            message=b"init",
            author=b"author <email>",
            committer=b"committer <email>",
        )
        clone_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, clone_path)
        target_repo = porcelain.clone(
            self.repo.path, target=clone_path, errstream=errstream
        )
        target_repo.close()
        return clone_path

    def test_push_all(self) -> None:
        """Test --all pushes all branches."""
        clone_path = self._setup_clone()

        # Create multiple branches in the clone
        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/heads/feature1"] = head_id
            r_clone.refs[b"refs/heads/feature2"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            all=True,
        )

        # All branches should be pushed to remote
        self.assertIn(b"refs/heads/feature1", self.repo.refs)
        self.assertIn(b"refs/heads/feature2", self.repo.refs)

    def test_push_tags(self) -> None:
        """Test --tags pushes all tags."""
        clone_path = self._setup_clone()

        # Create tags in the clone
        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/tags/v1.0"] = head_id
            r_clone.refs[b"refs/tags/v2.0"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            tags=True,
        )

        # All tags should be pushed to remote
        self.assertIn(b"refs/tags/v1.0", self.repo.refs)
        self.assertIn(b"refs/tags/v2.0", self.repo.refs)

    def test_push_delete_flag(self) -> None:
        """Test --delete removes remote refs."""
        clone_path = self._setup_clone()

        # Create a branch on the remote
        head_id = self.repo[b"HEAD"].id
        self.repo.refs[b"refs/heads/to-delete"] = head_id
        self.assertIn(b"refs/heads/to-delete", self.repo.refs)

        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/to-delete"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            delete=True,
        )

        self.assertNotIn(b"refs/heads/to-delete", self.repo.refs)

    def test_push_delete_requires_refspecs(self) -> None:
        """Test --delete without refspecs raises an error."""
        clone_path = self._setup_clone()

        self.assertRaises(
            porcelain.Error,
            porcelain.push,
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            delete=True,
        )

    def test_push_prune(self) -> None:
        """Test --prune removes remote branches not in local."""
        clone_path = self._setup_clone()

        # Create a branch on the remote that doesn't exist in clone
        head_id = self.repo[b"HEAD"].id
        self.repo.refs[b"refs/heads/orphan"] = head_id
        self.assertIn(b"refs/heads/orphan", self.repo.refs)

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            prune=True,
        )

        self.assertNotIn(b"refs/heads/orphan", self.repo.refs)

    def test_push_dry_run(self) -> None:
        """Test --dry-run doesn't actually push."""
        clone_path = self._setup_clone()

        # Create a new branch in clone
        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/heads/dry-run-branch"] = head_id

        errstream = BytesIO()
        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/dry-run-branch"],
            outstream=BytesIO(),
            errstream=errstream,
            dry_run=True,
        )

        # Branch should NOT be pushed (dry run)
        self.assertNotIn(b"refs/heads/dry-run-branch", self.repo.refs)
        # Should mention dry run in output
        self.assertIn(b"dry run", errstream.getvalue())

    def test_push_set_upstream(self) -> None:
        """Test --set-upstream sets tracking info."""
        clone_path = self._setup_clone()

        porcelain.push(
            clone_path,
            "origin",
            refspecs=[b"refs/heads/master"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            set_upstream=True,
        )

        # Check that tracking info was set
        with Repo(clone_path) as r_clone:
            config = r_clone.get_config()
            remote = config.get((b"branch", b"master"), b"remote")
            merge = config.get((b"branch", b"master"), b"merge")
            self.assertEqual(remote, b"origin")
            self.assertEqual(merge, b"refs/heads/master")

    def test_push_mirror_flag(self) -> None:
        """Test --mirror flag pushes all refs and deletes missing ones."""
        clone_path = self._setup_clone()

        # Create extra refs in clone
        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/heads/feature"] = head_id
            r_clone.refs[b"refs/tags/v1.0"] = head_id

        # Create a branch on remote that doesn't exist in clone
        self.repo.refs[b"refs/heads/old-branch"] = self.repo[b"HEAD"].id

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            mirror=True,
        )

        # All local refs should be on remote
        self.assertIn(b"refs/heads/feature", self.repo.refs)
        self.assertIn(b"refs/tags/v1.0", self.repo.refs)
        # Remote-only branch should be deleted
        self.assertNotIn(b"refs/heads/old-branch", self.repo.refs)

    def test_push_all_does_not_push_tags(self) -> None:
        """Test --all only pushes branches, not tags."""
        clone_path = self._setup_clone()

        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/heads/feature"] = head_id
            r_clone.refs[b"refs/tags/v1.0"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            all=True,
        )

        self.assertIn(b"refs/heads/feature", self.repo.refs)
        self.assertNotIn(b"refs/tags/v1.0", self.repo.refs)

    def test_push_tags_with_refspecs(self) -> None:
        """Test --tags combined with refspecs pushes both."""
        clone_path = self._setup_clone()

        with Repo(clone_path) as r_clone:
            head_id = r_clone[b"HEAD"].id
            r_clone.refs[b"refs/heads/feature"] = head_id
            r_clone.refs[b"refs/tags/v1.0"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/feature"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            tags=True,
        )

        # Both the explicit branch and all tags should be pushed
        self.assertIn(b"refs/heads/feature", self.repo.refs)
        self.assertIn(b"refs/tags/v1.0", self.repo.refs)

    def test_push_prune_keeps_tags(self) -> None:
        """Test --prune only removes branches, not tags."""
        clone_path = self._setup_clone()

        head_id = self.repo[b"HEAD"].id
        self.repo.refs[b"refs/heads/orphan-branch"] = head_id
        self.repo.refs[b"refs/tags/orphan-tag"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            outstream=BytesIO(),
            errstream=BytesIO(),
            prune=True,
        )

        # Branch not in local should be pruned
        self.assertNotIn(b"refs/heads/orphan-branch", self.repo.refs)
        # Tag should NOT be pruned (prune only affects branches)
        self.assertIn(b"refs/tags/orphan-tag", self.repo.refs)

    def test_push_delete_short_name(self) -> None:
        """Test --delete with a short branch name resolves correctly."""
        clone_path = self._setup_clone()

        head_id = self.repo[b"HEAD"].id
        self.repo.refs[b"refs/heads/to-delete"] = head_id

        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"to-delete"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            delete=True,
        )

        self.assertNotIn(b"refs/heads/to-delete", self.repo.refs)

    def test_push_follow_tags(self) -> None:
        """Test --follow-tags pushes reachable annotated tags."""
        clone_path = self._setup_clone()

        # Create a new commit in clone
        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        new_id = porcelain.commit(
            repo=clone_path,
            message=b"new commit",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Create an annotated tag on the commit
        porcelain.tag_create(
            clone_path,
            tag=b"v1.0",
            message=b"release v1.0",
            author=b"author <email>",
            annotated=True,
        )

        # Push only the branch with --follow-tags
        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/master"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            follow_tags=True,
        )

        # The annotated tag should also be pushed
        self.assertIn(b"refs/tags/v1.0", self.repo.refs)
        # And the commit should be there
        self.assertEqual(self.repo.refs[b"refs/heads/master"], new_id)

    def test_push_follow_tags_skips_lightweight(self) -> None:
        """Test --follow-tags does NOT push lightweight tags."""
        clone_path = self._setup_clone()

        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        porcelain.commit(
            repo=clone_path,
            message=b"new commit",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Create a lightweight (non-annotated) tag
        porcelain.tag_create(
            clone_path,
            tag=b"lightweight",
            annotated=False,
        )

        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/master"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            follow_tags=True,
        )

        # Lightweight tag should NOT be pushed
        self.assertNotIn(b"refs/tags/lightweight", self.repo.refs)

    def test_push_follow_tags_skips_already_on_remote(self) -> None:
        """Test --follow-tags skips tags already present on remote."""
        clone_path = self._setup_clone()

        handle, fullpath = tempfile.mkstemp(dir=clone_path)
        os.close(handle)
        porcelain.add(repo=clone_path, paths=[fullpath])
        porcelain.commit(
            repo=clone_path,
            message=b"new commit",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Create an annotated tag
        porcelain.tag_create(
            clone_path,
            tag=b"v1.0",
            message=b"release v1.0",
            author=b"author <email>",
            annotated=True,
        )

        # Pre-set the tag on the remote to a different value
        self.repo.refs[b"refs/tags/v1.0"] = self.repo[b"HEAD"].id
        original_tag_sha = self.repo.refs[b"refs/tags/v1.0"]

        porcelain.push(
            clone_path,
            self.repo.path,
            refspecs=[b"refs/heads/master"],
            outstream=BytesIO(),
            errstream=BytesIO(),
            follow_tags=True,
        )

        # Tag should NOT be overwritten (already on remote)
        self.assertEqual(self.repo.refs[b"refs/tags/v1.0"], original_tag_sha)


class PullTests(PorcelainTestCase):
    def setUp(self) -> None:
        super().setUp()
        # create a file for initial commit
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Setup target repo
        self.target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, self.target_path)
        target_repo = porcelain.clone(
            self.repo.path, target=self.target_path, errstream=BytesIO()
        )
        target_repo.close()

        # create a second file to be pushed
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test2",
            author=b"test2 <email>",
            committer=b"test2 <email>",
        )

        self.assertIn(b"refs/heads/master", self.repo.refs)
        self.assertIn(b"refs/heads/master", target_repo.refs)

    def test_simple(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        # Pull changes into the cloned repo
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Check the target repo for pushed changes
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)

    def test_diverged(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        c3a = porcelain.commit(
            repo=self.target_path,
            message=b"test3a",
            author=b"test2 <email>",
            committer=b"test2 <email>",
        )

        porcelain.commit(
            repo=self.repo.path,
            message=b"test3b",
            author=b"test2 <email>",
            committer=b"test2 <email>",
        )

        # Pull changes into the cloned repo
        self.assertRaises(
            porcelain.DivergedBranches,
            porcelain.pull,
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Check the target repo for pushed changes
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"refs/heads/master"].id, c3a)

        # Pull with merge should now work
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
            fast_forward=False,
        )

        # Check the target repo for merged changes
        with Repo(self.target_path) as r:
            # HEAD should now be a merge commit
            head = r[b"HEAD"]
            # It should have two parents
            self.assertEqual(len(head.parents), 2)
            # One parent should be the previous HEAD (c3a)
            self.assertIn(c3a, head.parents)
            # The other parent should be from the source repo
            self.assertIn(self.repo[b"HEAD"].id, head.parents)

    def test_no_refspec(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        # Pull changes into the cloned repo
        porcelain.pull(
            self.target_path,
            self.repo.path,
            outstream=outstream,
            errstream=errstream,
        )

        # Check the target repo for pushed changes
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)

    def test_no_remote_location(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        # Pull changes into the cloned repo
        porcelain.pull(
            self.target_path,
            refspecs=b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Check the target repo for pushed changes
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)

    def test_pull_updates_working_tree(self) -> None:
        """Test that pull updates the working tree with new files."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create a new file with content in the source repo
        new_file = os.path.join(self.repo.path, "newfile.txt")
        with open(new_file, "w") as f:
            f.write("This is new content")

        porcelain.add(repo=self.repo.path, paths=[new_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add new file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Before pull, the file should not exist in target
        target_file = os.path.join(self.target_path, "newfile.txt")
        self.assertFalse(os.path.exists(target_file))

        # Pull changes into the cloned repo
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # After pull, the file should exist with correct content
        self.assertTrue(os.path.exists(target_file))
        with open(target_file) as f:
            self.assertEqual(f.read(), "This is new content")

        # Check the HEAD is updated too
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)

    def test_pull_protects_modified_files(self) -> None:
        """Test that pull refuses to overwrite uncommitted changes by default."""
        from dulwich.errors import WorkingTreeModifiedError

        outstream = BytesIO()
        errstream = BytesIO()

        # Create a file with content in the source repo
        test_file = os.path.join(self.repo.path, "testfile.txt")
        with open(test_file, "w") as f:
            f.write("original content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Pull this change to target first
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Now modify the file in source repo
        with open(test_file, "w") as f:
            f.write("updated content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Update test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Modify the same file in target working directory (uncommitted)
        target_file = os.path.join(self.target_path, "testfile.txt")
        with open(target_file, "w") as f:
            f.write("local modifications")

        # Pull should fail because of uncommitted changes
        with self.assertRaises(WorkingTreeModifiedError) as cm:
            porcelain.pull(
                self.target_path,
                self.repo.path,
                b"refs/heads/master",
                outstream=outstream,
                errstream=errstream,
            )

        self.assertIn("Your local changes", str(cm.exception))
        self.assertIn("testfile.txt", str(cm.exception))

        # Verify the file still has local modifications
        with open(target_file) as f:
            self.assertEqual(f.read(), "local modifications")

    def test_pull_force_overwrites_modified_files(self) -> None:
        """Test that pull with force=True overwrites uncommitted changes."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create a file with content in the source repo
        test_file = os.path.join(self.repo.path, "testfile.txt")
        with open(test_file, "w") as f:
            f.write("original content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Pull this change to target first
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Now modify the file in source repo
        with open(test_file, "w") as f:
            f.write("updated content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Update test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Modify the same file in target working directory (uncommitted)
        target_file = os.path.join(self.target_path, "testfile.txt")
        with open(target_file, "w") as f:
            f.write("local modifications")

        # Pull with force=True should succeed and overwrite local changes
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
            force=True,
        )

        # Verify the file now has the remote content
        with open(target_file) as f:
            self.assertEqual(f.read(), "updated content")

        # Check the HEAD is updated too
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)

    def test_pull_allows_unmodified_files(self) -> None:
        """Test that pull allows updating files that haven't been modified locally."""
        outstream = BytesIO()
        errstream = BytesIO()

        # Create a file with content in the source repo
        test_file = os.path.join(self.repo.path, "testfile.txt")
        with open(test_file, "w") as f:
            f.write("original content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Pull this change to target first
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Now modify the file in source repo
        with open(test_file, "w") as f:
            f.write("updated content")

        porcelain.add(repo=self.repo.path, paths=[test_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Update test file",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Don't modify the file in target - it should be safe to update
        target_file = os.path.join(self.target_path, "testfile.txt")

        # Pull should succeed since the file wasn't modified locally
        porcelain.pull(
            self.target_path,
            self.repo.path,
            b"refs/heads/master",
            outstream=outstream,
            errstream=errstream,
        )

        # Verify the file now has the remote content
        with open(target_file) as f:
            self.assertEqual(f.read(), "updated content")

        # Check the HEAD is updated too
        with Repo(self.target_path) as r:
            self.assertEqual(r[b"HEAD"].id, self.repo[b"HEAD"].id)


class StatusTests(PorcelainTestCase):
    def test_empty(self) -> None:
        results = porcelain.status(self.repo)
        self.assertEqual({"add": [], "delete": [], "modify": []}, results.staged)
        self.assertEqual([], results.unstaged)

    def test_status_base(self) -> None:
        """Integration test for `status` functionality."""
        # Commit a dummy file then modify it
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("origstuff")

        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # modify access and modify time of path
        os.utime(fullpath, (0, 0))

        with open(fullpath, "wb") as f:
            f.write(b"stuff")

        # Make a dummy file and stage it
        filename_add = "bar"
        fullpath = os.path.join(self.repo.path, filename_add)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)

        results = porcelain.status(self.repo)

        self.assertEqual(results.staged["add"][0], os.fsencode(filename_add))
        self.assertEqual(results.unstaged, [os.fsencode("foo")])

    def test_status_with_core_preloadindex(self) -> None:
        """Test status with core.preloadIndex enabled."""
        # Set core.preloadIndex to true
        config = self.repo.get_config()
        config.set(b"core", b"preloadIndex", b"true")
        config.write_to_path()

        # Create multiple files
        files = []
        for i in range(10):
            filename = f"file{i}"
            fullpath = os.path.join(self.repo.path, filename)
            with open(fullpath, "w") as f:
                f.write(f"content{i}")
            files.append(fullpath)

        porcelain.add(repo=self.repo.path, paths=files)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test preload status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Modify some files
        modified_files = ["file1", "file3", "file5", "file7"]
        for filename in modified_files:
            fullpath = os.path.join(self.repo.path, filename)
            with open(fullpath, "w") as f:
                f.write("modified content")
            os.utime(fullpath, (0, 0))

        # Status should work correctly with preloadIndex enabled
        results = porcelain.status(self.repo)

        # Check that we detected the correct unstaged changes
        unstaged_sorted = sorted(results.unstaged)
        expected_sorted = sorted([os.fsencode(f) for f in modified_files])
        self.assertEqual(unstaged_sorted, expected_sorted)

    def test_status_all(self) -> None:
        del_path = os.path.join(self.repo.path, "foo")
        mod_path = os.path.join(self.repo.path, "bar")
        add_path = os.path.join(self.repo.path, "baz")
        us_path = os.path.join(self.repo.path, "blye")
        ut_path = os.path.join(self.repo.path, "blyat")
        with open(del_path, "w") as f:
            f.write("origstuff")
        with open(mod_path, "w") as f:
            f.write("origstuff")
        with open(us_path, "w") as f:
            f.write("origstuff")
        porcelain.add(repo=self.repo.path, paths=[del_path, mod_path, us_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )
        porcelain.remove(self.repo.path, [del_path])
        with open(add_path, "w") as f:
            f.write("origstuff")
        with open(mod_path, "w") as f:
            f.write("more_origstuff")
        with open(us_path, "w") as f:
            f.write("more_origstuff")
        porcelain.add(repo=self.repo.path, paths=[add_path, mod_path])
        with open(us_path, "w") as f:
            f.write("\norigstuff")
        with open(ut_path, "w") as f:
            f.write("origstuff")
        results = porcelain.status(self.repo.path)
        self.assertDictEqual(
            {
                "add": [os.fsencode("baz")],
                "delete": [os.fsencode("foo")],
                "modify": [os.fsencode("bar")],
            },
            results.staged,
        )
        self.assertListEqual(results.unstaged, [os.fsencode("blye")])
        results_no_untracked = porcelain.status(self.repo.path, untracked_files="no")
        self.assertListEqual(results_no_untracked.untracked, [])

    def test_status_wrong_untracked_files_value(self) -> None:
        with self.assertRaises(ValueError):
            porcelain.status(self.repo.path, untracked_files="antani")

    def test_status_untracked_path(self) -> None:
        untracked_dir = os.path.join(self.repo_path, "untracked_dir")
        os.mkdir(untracked_dir)
        untracked_file = os.path.join(untracked_dir, "untracked_file")
        with open(untracked_file, "w") as fh:
            fh.write("untracked")

        _, _, untracked = porcelain.status(self.repo.path, untracked_files="all")
        self.assertEqual(
            untracked, [os.fsencode(os.path.join("untracked_dir", "untracked_file"))]
        )

    def test_status_untracked_path_normal(self) -> None:
        # Create an untracked directory with multiple files
        untracked_dir = os.path.join(self.repo_path, "untracked_dir")
        os.mkdir(untracked_dir)
        untracked_file1 = os.path.join(untracked_dir, "file1")
        untracked_file2 = os.path.join(untracked_dir, "file2")
        with open(untracked_file1, "w") as fh:
            fh.write("untracked1")
        with open(untracked_file2, "w") as fh:
            fh.write("untracked2")

        # Create a nested untracked directory
        nested_dir = os.path.join(untracked_dir, "nested")
        os.mkdir(nested_dir)
        nested_file = os.path.join(nested_dir, "file3")
        with open(nested_file, "w") as fh:
            fh.write("untracked3")

        # Test "normal" mode - should only show the directory, not individual files
        _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
        self.assertEqual(untracked, [os.fsencode("untracked_dir" + os.sep)])

        # Test "all" mode - should show all files
        _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
        self.assertEqual(
            sorted(untracked_all),
            [
                os.fsencode(os.path.join("untracked_dir", "file1")),
                os.fsencode(os.path.join("untracked_dir", "file2")),
                os.fsencode(os.path.join("untracked_dir", "nested", "file3")),
            ],
        )

    def test_status_mixed_tracked_untracked(self) -> None:
        # Create a directory with both tracked and untracked files
        mixed_dir = os.path.join(self.repo_path, "mixed_dir")
        os.mkdir(mixed_dir)

        # Add a tracked file
        tracked_file = os.path.join(mixed_dir, "tracked.txt")
        with open(tracked_file, "w") as fh:
            fh.write("tracked content")
        porcelain.add(self.repo.path, paths=[tracked_file])
        porcelain.commit(
            repo=self.repo.path,
            message=b"add tracked file",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Add untracked files to the same directory
        untracked_file = os.path.join(mixed_dir, "untracked.txt")
        with open(untracked_file, "w") as fh:
            fh.write("untracked content")

        # In "normal" mode, should show individual untracked files in mixed dirs
        _, _, untracked = porcelain.status(self.repo.path, untracked_files="normal")
        self.assertEqual(
            untracked, [os.fsencode(os.path.join("mixed_dir", "untracked.txt"))]
        )

        # In "all" mode, should be the same for mixed directories
        _, _, untracked_all = porcelain.status(self.repo.path, untracked_files="all")
        self.assertEqual(
            untracked_all, [os.fsencode(os.path.join("mixed_dir", "untracked.txt"))]
        )

    def test_status_crlf_mismatch(self) -> None:
        # First make a commit as if the file has been added on a Linux system
        # or with core.autocrlf=True
        file_path = os.path.join(self.repo.path, "crlf")
        with open(file_path, "wb") as f:
            f.write(b"line1\nline2")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Then update the file as if it was created by CGit on a Windows
        # system with core.autocrlf=true
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2")

        results = porcelain.status(self.repo)
        self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
        self.assertListEqual(results.unstaged, [os.fsencode("crlf")])
        self.assertListEqual(results.untracked, [])

    def test_status_autocrlf_true(self) -> None:
        # First make a commit as if the file has been added on a Linux system
        # or with core.autocrlf=True
        file_path = os.path.join(self.repo.path, "crlf")
        with open(file_path, "wb") as f:
            f.write(b"line1\nline2")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Then update the file as if it was created by CGit on a Windows
        # system with core.autocrlf=true
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2")

        # TODO: It should be set automatically by looking at the configuration
        c = self.repo.get_config()
        c.set("core", "autocrlf", True)
        c.write_to_path()

        results = porcelain.status(self.repo)
        self.assertDictEqual({"add": [], "delete": [], "modify": []}, results.staged)
        self.assertListEqual(results.unstaged, [])
        self.assertListEqual(results.untracked, [])

    def test_status_autocrlf_input(self) -> None:
        # Commit existing file with CRLF
        file_path = os.path.join(self.repo.path, "crlf-exists")
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Get the index entry mtime and set the file mtime to match it exactly
        # This ensures stat matching works correctly with nanosecond precision
        index = self.repo.open_index()
        entry = index[b"crlf-exists"]
        if isinstance(entry.mtime, tuple):
            mtime_nsec = entry.mtime[0] * 1_000_000_000 + entry.mtime[1]
        else:
            mtime_nsec = int(entry.mtime * 1_000_000_000)
        # Use ns parameter to preserve nanosecond precision
        os.utime(file_path, ns=(mtime_nsec, mtime_nsec))

        c = self.repo.get_config()
        # Set trustctime=false because os.utime() updates ctime, which would
        # break stat matching. This test verifies that stat matching optimization
        # works with mtime+size matching (ignoring ctime changes from utime).
        c.set("core", "trustctime", "false")
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Add new (untracked) file
        file_path = os.path.join(self.repo.path, "crlf-new")
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2")
        porcelain.add(repo=self.repo.path, paths=[file_path])

        results = porcelain.status(self.repo)
        self.assertDictEqual(
            {"add": [os.fsencode("crlf-new")], "delete": [], "modify": []},
            results.staged,
        )
        # File committed with CRLF before autocrlf=input was enabled
        # will NOT appear as unstaged because stat matching optimization
        # skips filter processing when file hasn't been modified.
        # This matches Git's behavior, which uses stat matching to avoid
        # expensive filter operations. Git shows a warning instead.
        self.assertListEqual(results.unstaged, [])
        self.assertListEqual(results.untracked, [])

    def test_status_autocrlf_input_modified(self) -> None:
        """Test that modified files with CRLF are correctly detected with autocrlf=input."""
        # Commit existing file with CRLF
        file_path = os.path.join(self.repo.path, "crlf-file.txt")
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2\r\nline3\r\n")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"initial commit",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        c = self.repo.get_config()
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Modify the file content but keep CRLF
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2 modified\r\nline3\r\n")

        results = porcelain.status(self.repo)
        # Modified file should be detected as unstaged
        self.assertListEqual(results.unstaged, [os.fsencode("crlf-file.txt")])

    def test_status_autocrlf_input_binary(self) -> None:
        """Test that binary files are not affected by autocrlf=input."""
        # Set autocrlf=input first
        c = self.repo.get_config()
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Commit binary file with CRLF-like sequences
        file_path = os.path.join(self.repo.path, "binary.dat")
        with open(file_path, "wb") as f:
            f.write(b"binary\r\ndata\x00\xff\r\nmore")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"add binary",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # Status should be clean - binary files not normalized
        results = porcelain.status(self.repo)
        self.assertListEqual(results.unstaged, [])

    def test_status_autocrlf_input_mixed_endings(self) -> None:
        """Test files with mixed line endings with autocrlf=input."""
        # Set autocrlf=input
        c = self.repo.get_config()
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Create file with mixed line endings
        file_path = os.path.join(self.repo.path, "mixed.txt")
        with open(file_path, "wb") as f:
            f.write(b"line1\r\nline2\nline3\r\n")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"add mixed",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # The file was normalized on commit, so working tree should match
        results = porcelain.status(self.repo)
        self.assertListEqual(results.unstaged, [])

    def test_status_autocrlf_input_issue_1770(self) -> None:
        """Test the specific scenario from issue #1770.

        Files with CRLF committed with autocrlf=input should not show as unstaged
        when their content hasn't changed.
        """
        # Set autocrlf=input BEFORE committing
        c = self.repo.get_config()
        c.set("core", "autocrlf", "input")
        c.write_to_path()

        # Create and commit file with CRLF endings
        file_path = os.path.join(self.repo.path, "crlf-test.dsp")
        with open(file_path, "wb") as f:
            f.write(b"# Microsoft DSP file\r\n\r\nContent here\r\n")
        porcelain.add(repo=self.repo.path, paths=[file_path])
        porcelain.commit(
            repo=self.repo.path,
            message=b"add dsp file",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        # File was normalized to LF in index, but working tree still has CRLF
        # Status should be clean because comparison normalizes working tree too
        results = porcelain.status(self.repo)
        self.assertListEqual(results.unstaged, [])

    def test_get_tree_changes_add(self) -> None:
        """Unit test for get_tree_changes add."""
        # Make a dummy file, stage
        filename = "bar"
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        filename = "foo"
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        changes = porcelain.get_tree_changes(self.repo.path)

        self.assertEqual(changes["add"][0], filename.encode("ascii"))
        self.assertEqual(len(changes["add"]), 1)
        self.assertEqual(len(changes["modify"]), 0)
        self.assertEqual(len(changes["delete"]), 0)

    def test_get_tree_changes_modify(self) -> None:
        """Unit test for get_tree_changes modify."""
        # Make a dummy file, stage, commit, modify
        filename = "foo"
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )
        with open(fullpath, "w") as f:
            f.write("otherstuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        changes = porcelain.get_tree_changes(self.repo.path)

        self.assertEqual(changes["modify"][0], filename.encode("ascii"))
        self.assertEqual(len(changes["add"]), 0)
        self.assertEqual(len(changes["modify"]), 1)
        self.assertEqual(len(changes["delete"]), 0)

    def test_get_tree_changes_delete(self) -> None:
        """Unit test for get_tree_changes delete."""
        # Make a dummy file, stage, commit, remove
        filename = "foo"
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.chdir(self.repo.path)
        porcelain.remove(repo=self.repo.path, paths=[filename])
        changes = porcelain.get_tree_changes(self.repo.path)

        self.assertEqual(changes["delete"][0], filename.encode("ascii"))
        self.assertEqual(len(changes["add"]), 0)
        self.assertEqual(len(changes["modify"]), 0)
        self.assertEqual(len(changes["delete"]), 1)

    def test_get_untracked_paths(self) -> None:
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("ignored\n")
        with open(os.path.join(self.repo.path, "ignored"), "w") as f:
            f.write("blah\n")
        with open(os.path.join(self.repo.path, "notignored"), "w") as f:
            f.write("blah\n")
        os.symlink(
            os.path.join(self.repo.path, os.pardir, "external_target"),
            os.path.join(self.repo.path, "link"),
        )
        self.assertEqual(
            {"ignored", "notignored", ".gitignore", "link"},
            set(
                porcelain.get_untracked_paths(
                    self.repo.path, self.repo.path, self.repo.open_index()
                )
            ),
        )
        self.assertEqual(
            {os.fsencode(".gitignore"), os.fsencode("notignored"), os.fsencode("link")},
            set(porcelain.status(self.repo).untracked),
        )
        self.assertEqual(
            {
                os.fsencode(".gitignore"),
                os.fsencode("notignored"),
                os.fsencode("ignored"),
                os.fsencode("link"),
            },
            set(porcelain.status(self.repo, ignored=True).untracked),
        )

    def test_get_untracked_paths_subrepo(self) -> None:
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("nested/\n")
        with open(os.path.join(self.repo.path, "notignored"), "w") as f:
            f.write("blah\n")

        subrepo = Repo.init(os.path.join(self.repo.path, "nested"), mkdir=True)
        with open(os.path.join(subrepo.path, "ignored"), "w") as f:
            f.write("bleep\n")
        with open(os.path.join(subrepo.path, "with"), "w") as f:
            f.write("bloop\n")
        with open(os.path.join(subrepo.path, "manager"), "w") as f:
            f.write("blop\n")

        self.assertEqual(
            {".gitignore", "notignored", os.path.join("nested", "")},
            set(
                porcelain.get_untracked_paths(
                    self.repo.path, self.repo.path, self.repo.open_index()
                )
            ),
        )
        self.assertEqual(
            {".gitignore", "notignored"},
            set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    self.repo.open_index(),
                    exclude_ignored=True,
                )
            ),
        )
        self.assertEqual(
            {"ignored", "with", "manager"},
            set(
                porcelain.get_untracked_paths(
                    subrepo.path, subrepo.path, subrepo.open_index()
                )
            ),
        )
        self.assertEqual(
            set(),
            set(
                porcelain.get_untracked_paths(
                    subrepo.path,
                    self.repo.path,
                    self.repo.open_index(),
                )
            ),
        )
        self.assertEqual(
            {
                os.path.join("nested", "ignored"),
                os.path.join("nested", "with"),
                os.path.join("nested", "manager"),
            },
            set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    subrepo.path,
                    self.repo.open_index(),
                )
            ),
        )

    def test_get_untracked_paths_nested_gitignore(self) -> None:
        """Test directories with nested .gitignore files that ignore all contents."""
        # Create cache directories with .gitignore files that contain "*"
        cache_dirs = [".ruff_cache", ".pytest_cache", "__pycache__"]
        for cache_dir in cache_dirs:
            cache_path = os.path.join(self.repo.path, cache_dir)
            os.mkdir(cache_path)
            # Create .gitignore with * pattern (ignores everything)
            with open(os.path.join(cache_path, ".gitignore"), "w") as f:
                f.write("*\n")
            # Create some files in the cache directory
            with open(os.path.join(cache_path, "somefile.txt"), "w") as f:
                f.write("cached data\n")
            with open(os.path.join(cache_path, "data.json"), "w") as f:
                f.write("{}\n")

        # Create a normal untracked file
        with open(os.path.join(self.repo.path, "untracked.txt"), "w") as f:
            f.write("untracked content\n")

        # Test with exclude_ignored=True (default for status)
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # Cache directories should NOT be in untracked since all their contents are ignored
        self.assertEqual({"untracked.txt"}, untracked)

        # Test with exclude_ignored=False
        untracked_with_ignored = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=False,
                untracked_files="normal",
            )
        )
        # Cache directories should be included when not excluding ignored
        expected = {"untracked.txt"}
        for cache_dir in cache_dirs:
            expected.add(cache_dir + os.sep)
        self.assertEqual(expected, untracked_with_ignored)

        # Test status() which uses exclude_ignored=True by default
        status = porcelain.status(self.repo)
        self.assertEqual([os.fsencode("untracked.txt")], status.untracked)

        # Test status() with ignored=True which uses exclude_ignored=False
        status_with_ignored = porcelain.status(self.repo, ignored=True)
        # Should include cache directories
        self.assertIn(os.fsencode("untracked.txt"), status_with_ignored.untracked)
        for cache_dir in cache_dirs:
            self.assertIn(
                os.fsencode(cache_dir + os.sep), status_with_ignored.untracked
            )

    def test_get_untracked_paths_mixed_directory(self) -> None:
        """Test directory with both ignored and non-ignored files."""
        # Create a directory with mixed content
        mixed_dir = os.path.join(self.repo.path, "mixed")
        os.mkdir(mixed_dir)

        # Create .gitignore that ignores .log files
        with open(os.path.join(mixed_dir, ".gitignore"), "w") as f:
            f.write("*.log\n")

        # Create ignored and non-ignored files
        with open(os.path.join(mixed_dir, "debug.log"), "w") as f:
            f.write("debug info\n")
        with open(os.path.join(mixed_dir, "readme.txt"), "w") as f:
            f.write("important\n")

        # Test with exclude_ignored=True and normal mode
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # In normal mode, should show the directory (matching git behavior)
        self.assertEqual({os.path.join("mixed", "")}, untracked)

        # Test with untracked_files="all"
        untracked_all = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="all",
            )
        )
        # Should list the non-ignored files
        expected = {
            os.path.join("mixed", ".gitignore"),
            os.path.join("mixed", "readme.txt"),
        }
        self.assertEqual(expected, untracked_all)

    def test_get_untracked_paths_specific_ignore_pattern(self) -> None:
        """Test directory with .gitignore that ignores specific files, not all."""
        # Create a directory
        test_dir = os.path.join(self.repo.path, "testdir")
        os.mkdir(test_dir)

        # Create .gitignore that ignores only files named "test"
        with open(os.path.join(test_dir, ".gitignore"), "w") as f:
            f.write("test\n")

        # Create files
        with open(os.path.join(test_dir, "test"), "w") as f:
            f.write("ignored\n")
        with open(os.path.join(test_dir, "other.txt"), "w") as f:
            f.write("not ignored\n")

        # Test with exclude_ignored=True and normal mode
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # Directory should be shown because it has non-ignored files
        self.assertEqual({os.path.join("testdir", "")}, untracked)

    def test_get_untracked_paths_nested_subdirs_all_ignored(self) -> None:
        """Test directory containing only subdirectories where all files are ignored."""
        # Create parent directory with .gitignore that ignores everything
        parent_dir = os.path.join(self.repo.path, "parent")
        os.mkdir(parent_dir)
        with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
            f.write("*\n")

        # Create subdirectories with files (all should be ignored by parent's .gitignore)
        sub1 = os.path.join(parent_dir, "sub1")
        sub2 = os.path.join(parent_dir, "sub2")
        os.mkdir(sub1)
        os.mkdir(sub2)

        # Create files in subdirectories
        with open(os.path.join(sub1, "file1.txt"), "w") as f:
            f.write("content1\n")
        with open(os.path.join(sub2, "file2.txt"), "w") as f:
            f.write("content2\n")

        # Create another normal untracked file
        with open(os.path.join(self.repo.path, "normal.txt"), "w") as f:
            f.write("normal\n")

        # Test with exclude_ignored=True
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # Parent directory should NOT be shown since all nested files are ignored
        self.assertEqual({"normal.txt"}, untracked)

    def test_get_untracked_paths_nested_subdirs_mixed(self) -> None:
        """Test directory containing only subdirectories where some files are ignored, some aren't."""
        # Create parent directory with .gitignore that ignores .log files
        parent_dir = os.path.join(self.repo.path, "parent")
        os.mkdir(parent_dir)
        with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
            f.write("*.log\n")

        # Create subdirectories
        sub1 = os.path.join(parent_dir, "sub1")
        sub2 = os.path.join(parent_dir, "sub2")
        os.mkdir(sub1)
        os.mkdir(sub2)

        # sub1: only ignored files
        with open(os.path.join(sub1, "debug.log"), "w") as f:
            f.write("log content\n")
        with open(os.path.join(sub1, "error.log"), "w") as f:
            f.write("error log\n")

        # sub2: mix of ignored and non-ignored files
        with open(os.path.join(sub2, "access.log"), "w") as f:
            f.write("access log\n")
        with open(os.path.join(sub2, "readme.txt"), "w") as f:
            f.write("important info\n")

        # Test with exclude_ignored=True
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # Parent directory SHOULD be shown since sub2 has non-ignored files
        self.assertEqual({os.path.join("parent", "")}, untracked)

    def test_get_untracked_paths_deeply_nested_all_ignored(self) -> None:
        """Test deeply nested directories where all files are eventually ignored."""
        # Create nested structure: parent/sub/subsub/
        parent_dir = os.path.join(self.repo.path, "parent")
        sub_dir = os.path.join(parent_dir, "sub")
        subsub_dir = os.path.join(sub_dir, "subsub")
        os.makedirs(subsub_dir)

        # Parent has .gitignore that ignores everything
        with open(os.path.join(parent_dir, ".gitignore"), "w") as f:
            f.write("*\n")

        # Create files at different levels
        with open(os.path.join(subsub_dir, "deep_file.txt"), "w") as f:
            f.write("deep content\n")
        with open(os.path.join(sub_dir, "mid_file.txt"), "w") as f:
            f.write("mid content\n")

        # Test with exclude_ignored=True
        untracked = set(
            porcelain.get_untracked_paths(
                self.repo.path,
                self.repo.path,
                self.repo.open_index(),
                exclude_ignored=True,
                untracked_files="normal",
            )
        )
        # Parent directory should NOT be shown since all nested files are ignored
        self.assertEqual(set(), untracked)

    def test_get_untracked_paths_subdir(self) -> None:
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("subdir/\nignored")
        with open(os.path.join(self.repo.path, "notignored"), "w") as f:
            f.write("blah\n")
        os.mkdir(os.path.join(self.repo.path, "subdir"))
        with open(os.path.join(self.repo.path, "ignored"), "w") as f:
            f.write("foo")
        with open(os.path.join(self.repo.path, "subdir", "ignored"), "w") as f:
            f.write("foo")

        self.assertEqual(
            {
                ".gitignore",
                "notignored",
                "ignored",
                os.path.join("subdir", ""),
            },
            set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    self.repo.open_index(),
                )
            ),
        )
        self.assertEqual(
            {".gitignore", "notignored"},
            set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    self.repo.open_index(),
                    exclude_ignored=True,
                )
            ),
        )

    def test_get_untracked_paths_invalid_untracked_files(self) -> None:
        with self.assertRaises(ValueError):
            list(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    self.repo.open_index(),
                    untracked_files="invalid_value",
                )
            )

    def test_get_untracked_paths_normal(self) -> None:
        # Create an untracked directory with files
        untracked_dir = os.path.join(self.repo.path, "untracked_dir")
        os.mkdir(untracked_dir)
        with open(os.path.join(untracked_dir, "file1.txt"), "w") as f:
            f.write("untracked content")
        with open(os.path.join(untracked_dir, "file2.txt"), "w") as f:
            f.write("more untracked content")

        # Test that "normal" mode works and returns only the directory
        _, _, untracked = porcelain.status(
            repo=self.repo.path, untracked_files="normal"
        )
        self.assertEqual(untracked, [os.fsencode("untracked_dir" + os.sep)])

    def test_get_untracked_paths_top_level_issue_1247(self) -> None:
        """Test for issue #1247: ensure top-level untracked files are detected."""
        # Create a single top-level untracked file
        with open(os.path.join(self.repo.path, "sample.txt"), "w") as f:
            f.write("test content")

        # Test get_untracked_paths directly
        untracked = list(
            porcelain.get_untracked_paths(
                self.repo.path, self.repo.path, self.repo.open_index()
            )
        )
        self.assertIn(
            "sample.txt",
            untracked,
            "Top-level file 'sample.txt' should be in untracked list",
        )

        # Test via status
        status = porcelain.status(self.repo)
        self.assertIn(
            os.fsencode("sample.txt"),
            status.untracked,
            "Top-level file 'sample.txt' should be in status.untracked",
        )

    def test_get_untracked_paths_precompose_unicode(self) -> None:
        """Test that precompose_unicode normalizes NFD paths to NFC."""
        import unicodedata

        # macOS git ships with core.precomposeunicode=true in the system
        # config. Override it at the repo level so open_index() below does
        # not apply NFC normalization to lookups; that would mask the
        # "NFD present, NFC in index" assertion we are exercising.
        config = self.repo.get_config()
        config.set((b"core",), b"precomposeunicode", False)
        config.write_to_path()

        # NFC (precomposed) and NFD (decomposed) forms of "ä"
        nfc_name = unicodedata.normalize("NFC", "t\u00e4st.txt")
        nfd_name = unicodedata.normalize("NFD", "t\u00e4st.txt")

        # Create a file with NFC name, add and commit it
        fullpath = os.path.join(self.repo.path, nfc_name)
        with open(fullpath, "w") as f:
            f.write("content\n")
        porcelain.add(self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Add unicode file",
            author=b"Test <test@example.com>",
        )

        # Now rename the file to NFD form (simulating what macOS HFS+/APFS does)
        nfd_path = os.path.join(self.repo.path, nfd_name)
        if nfc_name != nfd_name:
            # Only rename if the filesystem actually distinguishes NFC/NFD
            try:
                os.rename(fullpath, nfd_path)
            except OSError:
                # Filesystem normalizes names (e.g., macOS); skip rename test
                return

            if not os.path.exists(nfd_path):
                return

            # Without precompose_unicode, the NFD file should appear as untracked
            # (since the index has the NFC version)
            index = self.repo.open_index()
            untracked_without = set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    index,
                    precompose_unicode=False,
                )
            )
            self.assertIn(nfd_name, untracked_without)

            # With precompose_unicode, the NFD file should be recognized as tracked
            untracked_with = set(
                porcelain.get_untracked_paths(
                    self.repo.path,
                    self.repo.path,
                    index,
                    precompose_unicode=True,
                )
            )
            self.assertNotIn(nfd_name, untracked_with)
            self.assertNotIn(nfc_name, untracked_with)

    def test_status_precompose_unicode_config(self) -> None:
        """Test that status reads core.precomposeunicode from config."""
        config = self.repo.get_config()
        config.set(b"core", b"precomposeunicode", b"true")
        config.write_to_path()

        # Verify the config is read (status should not error)
        results = porcelain.status(self.repo)
        self.assertIsNotNone(results)


# TODO(jelmer): Add test for dulwich.porcelain.daemon


class ShortlogTests(PorcelainTestCase):
    def test_shortlog(self) -> None:
        """Test porcelain.shortlog function with multiple authors and commits."""

        # Create first file and commit
        file_a = os.path.join(self.repo.path, "a.txt")
        with open(file_a, "w") as f:
            f.write("hello")
        porcelain.add(self.repo.path, paths=[file_a])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Initial commit",
            author=b"John <john@example.com>",
        )

        # Create second file and commit
        file_b = os.path.join(self.repo.path, "b.txt")
        with open(file_b, "w") as f:
            f.write("update")
        porcelain.add(self.repo.path, paths=[file_b])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Update file",
            author=b"Doe <doe@example.com>",
        )

        # Call shortlog
        output = porcelain.shortlog(self.repo.path)
        expected = [
            {"author": "John <john@example.com>", "messages": "Initial commit"},
            {"author": "Doe <doe@example.com>", "messages": "Update file"},
        ]
        self.assertCountEqual(output, expected)

        # Test summary output (count of messages)
        output_summary = [
            {"author": entry["author"], "count": len(entry["messages"].splitlines())}
            for entry in output
        ]
        expected_summary = [
            {"author": "John <john@example.com>", "count": 1},
            {"author": "Doe <doe@example.com>", "count": 1},
        ]
        self.assertCountEqual(output_summary, expected_summary)


class UploadPackTests(PorcelainTestCase):
    """Tests for upload_pack."""

    def test_upload_pack(self) -> None:
        outf = BytesIO()
        exitcode = porcelain.upload_pack(self.repo.path, BytesIO(b"0000"), outf)
        outlines = outf.getvalue().splitlines()
        self.assertEqual([b"0000"], outlines)
        self.assertEqual(0, exitcode)


class ReceivePackTests(PorcelainTestCase):
    """Tests for receive_pack."""

    def test_receive_pack(self) -> None:
        filename = "foo"
        fullpath = os.path.join(self.repo.path, filename)
        with open(fullpath, "w") as f:
            f.write("stuff")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        self.repo.get_worktree().commit(
            message=b"test status",
            committer=b"committer <email>",
            author=b"author <email>",
            commit_timestamp=1402354300,
            commit_timezone=0,
            author_timestamp=1402354300,
            author_timezone=0,
        )
        outf = BytesIO()
        exitcode = porcelain.receive_pack(self.repo.path, BytesIO(b"0000"), outf)
        outlines = outf.getvalue().splitlines()
        self.assertEqual(
            [
                b"00ab319b56ce3aee2d489f759736a79cc552c9bb86d9 HEAD\x00 report-status "
                b"delete-refs quiet atomic ofs-delta side-band-64k "
                b"no-done object-format=sha1 symref=HEAD:refs/heads/master",
                b"003f319b56ce3aee2d489f759736a79cc552c9bb86d9 refs/heads/master",
                b"0000",
            ],
            outlines,
        )
        self.assertEqual(0, exitcode)


class BranchListTests(PorcelainTestCase):
    def test_standard(self) -> None:
        self.assertEqual(set(), set(porcelain.branch_list(self.repo)))

    def test_new_branch(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.branch_create(self.repo, b"foo")
        self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))

    def test_sort_by_refname(self) -> None:
        """Test branch.sort=refname (default alphabetical)."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id

        # Create branches in non-alphabetical order
        porcelain.branch_create(self.repo, b"zebra")
        porcelain.branch_create(self.repo, b"alpha")
        porcelain.branch_create(self.repo, b"beta")

        # Set branch.sort to refname (though it's the default)
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"refname")
        config.write_to_path()

        # Should be sorted alphabetically
        branches = porcelain.branch_list(self.repo)
        self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)

    def test_sort_by_refname_reverse(self) -> None:
        """Test branch.sort=-refname (reverse alphabetical)."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id

        # Create branches
        porcelain.branch_create(self.repo, b"zebra")
        porcelain.branch_create(self.repo, b"alpha")
        porcelain.branch_create(self.repo, b"beta")

        # Set branch.sort to -refname
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"-refname")
        config.write_to_path()

        # Should be sorted reverse alphabetically
        branches = porcelain.branch_list(self.repo)
        self.assertEqual([b"zebra", b"master", b"beta", b"alpha"], branches)

    def test_sort_by_committerdate(self) -> None:
        """Test branch.sort=committerdate."""
        # Use build_commit_graph to create proper commits with specific times
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2], [3]],
            attrs={
                1: {"commit_time": 1000},  # oldest
                2: {"commit_time": 2000},  # newest
                3: {"commit_time": 1500},  # middle
            },
        )

        self.repo[b"HEAD"] = c1.id

        # Create branches pointing to different commits
        self.repo.refs[b"refs/heads/master"] = c1.id  # master points to oldest
        self.repo.refs[b"refs/heads/oldest"] = c1.id
        self.repo.refs[b"refs/heads/newest"] = c2.id
        self.repo.refs[b"refs/heads/middle"] = c3.id

        # Set branch.sort to committerdate
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"committerdate")
        config.write_to_path()

        # Should be sorted by commit time (oldest first)
        branches = porcelain.branch_list(self.repo)
        self.assertEqual([b"master", b"oldest", b"middle", b"newest"], branches)

    def test_sort_by_committerdate_reverse(self) -> None:
        """Test branch.sort=-committerdate."""
        # Use build_commit_graph to create proper commits with specific times
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2], [3]],
            attrs={
                1: {"commit_time": 1000},  # oldest
                2: {"commit_time": 2000},  # newest
                3: {"commit_time": 1500},  # middle
            },
        )

        self.repo[b"HEAD"] = c1.id

        # Create branches pointing to different commits
        self.repo.refs[b"refs/heads/master"] = c1.id  # master points to oldest
        self.repo.refs[b"refs/heads/oldest"] = c1.id
        self.repo.refs[b"refs/heads/newest"] = c2.id
        self.repo.refs[b"refs/heads/middle"] = c3.id

        # Set branch.sort to -committerdate
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"-committerdate")
        config.write_to_path()

        # Should be sorted by commit time (newest first)
        branches = porcelain.branch_list(self.repo)
        self.assertEqual([b"newest", b"middle", b"master", b"oldest"], branches)

    def test_sort_default(self) -> None:
        """Test default sorting (no config)."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id

        # Create branches in non-alphabetical order
        porcelain.branch_create(self.repo, b"zebra")
        porcelain.branch_create(self.repo, b"alpha")
        porcelain.branch_create(self.repo, b"beta")

        # No config set - should default to alphabetical
        branches = porcelain.branch_list(self.repo)
        self.assertEqual([b"alpha", b"beta", b"master", b"zebra"], branches)


class BranchRemoteListTests(PorcelainTestCase):
    def test_no_remote_branches(self) -> None:
        """Test with no remote branches."""
        result = porcelain.branch_remotes_list(self.repo)
        self.assertEqual([], result)

    def test_remote_branches_refname_sort(self) -> None:
        """Test remote branches sorting with refname (alphabetical)."""
        # Create some remote branches
        [c1] = build_commit_graph(self.repo.object_store, [[1]])

        # Create remote branches is non-alphabetical order
        self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-4"] = c1.id

        # Set branch.sort to refname
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"refname")
        config.write_to_path()

        # Should return only branch names, sorted alphabetically
        branches = porcelain.branch_remotes_list(self.repo)
        expected = [
            b"origin/feature-1",
            b"origin/feature-2",
            b"origin/feature-3",
            b"origin/feature-4",
        ]
        self.assertEqual(expected, branches)

    def test_remote_branches_refname_reverse_sort(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])

        self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-4"] = c1.id

        # Set branch.sort to -refname
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"-refname")
        config.write_to_path()

        branches = porcelain.branch_remotes_list(self.repo)
        expected = [
            b"origin/feature-4",
            b"origin/feature-3",
            b"origin/feature-2",
            b"origin/feature-1",
        ]
        self.assertEqual(expected, branches)

    def test_remote_branches_committerdate_sort(self) -> None:
        """Test remote branches sorting with committerdate"""
        # Create commits with different timestamps
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2], [3]],
            attrs={
                1: {"commit_time": 1000},
                2: {"commit_time": 2000},
                3: {"commit_time": 3000},
            },
        )

        self.repo.refs[b"refs/remotes/origin/oldest"] = c1.id
        self.repo.refs[b"refs/remotes/origin/middle"] = c2.id
        self.repo.refs[b"refs/remotes/origin/newest"] = c3.id

        # Set branch.sort to committerdate
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"committerdate")
        config.write_to_path()

        branches = porcelain.branch_remotes_list(self.repo)
        # Should be sorted by commit time (oldest first), then alphabetically for same time
        expected = [b"origin/oldest", b"origin/middle", b"origin/newest"]
        self.assertEqual(expected, branches)

    def test_remote_branches_committerdate_reverse_sort(self) -> None:
        """Test remote branches sorting with -committerdate."""
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2], [3]],
            attrs={
                1: {"commit_time": 1000},
                2: {"commit_time": 2000},
                3: {"commit_time": 1500},
            },
        )

        self.repo.refs[b"refs/remotes/origin/oldest"] = c1.id
        self.repo.refs[b"refs/remotes/origin/newest"] = c2.id
        self.repo.refs[b"refs/remotes/origin/middle"] = c3.id

        # Set branch.sort to -committerdate
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"-committerdate")
        config.write_to_path()

        branches = porcelain.branch_remotes_list(self.repo)
        # Should be sorted by commit time (newest first), then alphabetically for same time
        expected = [b"origin/newest", b"origin/middle", b"origin/oldest"]
        self.assertEqual(expected, branches)

    def test_remote_branches_authordate_sort(self) -> None:
        """Test remote branches sorting with authordate."""
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2], [3]],
            attrs={
                1: {"author_time": 1500},
                2: {"author_time": 2000},
                3: {"author_time": 1000},
            },
        )

        self.repo.refs[b"refs/remotes/origin/middle"] = c1.id
        self.repo.refs[b"refs/remotes/origin/newest"] = c2.id
        self.repo.refs[b"refs/remotes/origin/oldest"] = c3.id

        # Set branch.sort to authordate
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"authordate")
        config.write_to_path()

        branches = porcelain.branch_remotes_list(self.repo)
        expected = [b"origin/oldest", b"origin/middle", b"origin/newest"]
        self.assertEqual(expected, branches)

    def test_unknown_sort_key(self) -> None:
        """Test that unknown sort key raises ValueError."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/remotes/origin/master"] = c1.id

        # Set branch.sort to unknown key
        config = self.repo.get_config()
        config.set((b"branch",), b"sort", b"unknown")
        config.write_to_path()

        with self.assertRaises(ValueError) as cm:
            porcelain.branch_remotes_list(self.repo)
        self.assertIn("Unknown sort key: unknown", str(cm.exception))

    def test_default_sort_no_config(self) -> None:
        """Test default sorting when no config is set."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])

        self.repo.refs[b"refs/remotes/origin/feature-1"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-2"] = c1.id
        self.repo.refs[b"refs/remotes/origin/feature-3"] = c1.id

        # No config set - should default to alphabetical
        branches = porcelain.branch_remotes_list(self.repo)
        expected = [b"origin/feature-1", b"origin/feature-2", b"origin/feature-3"]
        self.assertEqual(expected, branches)


class BranchMergedTests(PorcelainTestCase):
    def test_no_merged_branches(self) -> None:
        """Test with no merged branches."""
        # Create complete graph: c1 → c2 (master), c1 → c3 (feature)
        [_c1, c2, c3] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 1],  # c3 → c1 (diverged feature branch)
            ],
        )

        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature"] = c3.id

        result = list(porcelain.merged_branches(self.repo))
        self.assertEqual([b"master"], result)

    def test_all_branches_merged(self) -> None:
        """Test when all branches are merged into current."""
        # Create linear history: c1 → c2 → c3 (HEAD)
        [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])

        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature-1"] = c2.id  # Merged (ancestor)
        self.repo.refs[b"refs/heads/feature-2"] = c1.id  # Merged (ancestor)

        branches = list(porcelain.merged_branches(self.repo))
        expected = [b"feature-1", b"master", b"feature-2"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

    def test_some_branches_merged(self) -> None:
        """Test when some branches are merged, some are not."""
        # c1 → c2 → c3 (HEAD/master)
        # c1 → c4 → c5 (feature-1 - diverged)
        [_c1, c2, c3, _c4, c5] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 2],  # c3 → c2 (HEAD)
                [4, 1],  # c4 → c1 (feature branch)
                [5, 4],  # c5 → c4 (feature branch)
            ],
        )

        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature-1"] = c5.id  # Not merged (diverged)
        self.repo.refs[b"refs/heads/feature-2"] = c2.id  # Merged (ancestor)

        branches = list(porcelain.merged_branches(self.repo))
        expected = [b"feature-2", b"master"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

    def test_only_current_branch_merged(self) -> None:
        """Test when only current branch exists."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id
        self.repo.refs[b"refs/heads/master"] = c1.id

        result = list(porcelain.merged_branches(self.repo))
        self.assertEqual([b"master"], result)


class BranchNoMergedTests(PorcelainTestCase):
    def test_all_branches_merged(self) -> None:
        """Test when all branches are merged - should return empty list."""
        # Create linear history: c1 → c2 → c3 (HEAD)
        [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature-1"] = c2.id  # Merged (ancestor)
        self.repo.refs[b"refs/heads/feature-2"] = c1.id  # Merged (ancestor)

        result = list(porcelain.no_merged_branches(self.repo))
        self.assertEqual([], result)

    def test_no_merged_branches(self) -> None:
        """Test with some non-merged branches."""
        # Create complete graph: c1 → c2 (master), c1 → c3 (feature)
        [_c1, c2, c3] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 1],  # c3 → c1 (diverged feature branch)
            ],
        )
        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature"] = c3.id

        result = list(porcelain.no_merged_branches(self.repo))
        self.assertEqual([b"feature"], result)

    def test_some_branches_not_merged(self) -> None:
        """Test when some branches are merged, some are not."""
        # c1 → c2 → c3 (HEAD/master)
        # c1 → c4 → c5 (feature-1 - diverged)
        [_c1, c2, c3, _c4, c5] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 2],  # c3 → c2 (HEAD)
                [4, 1],  # c4 → c1 (feature branch)
                [5, 4],  # c5 → c4 (feature branch)
            ],
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature-1"] = c5.id  # Not merged (diverged)
        self.repo.refs[b"refs/heads/feature-2"] = c2.id  # Merged (ancestor)

        result = list(porcelain.no_merged_branches(self.repo))
        self.assertEqual([b"feature-1"], result)

    def test_multiple_branches_not_merged(self) -> None:
        """Test with multiple non-merged branches."""
        # c1 → c2 (HEAD/master)
        # c1 → c3 (feature-1 - diverged)
        # c1 → c4 (feature-2 - diverged)
        [_c1, c2, c3, c4] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 1],  # c3 → c1 (feature-1 branch)
                [4, 1],  # c4 → c1 (feature-2 branch)
            ],
        )
        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature-1"] = c3.id  # Not merged (diverged)
        self.repo.refs[b"refs/heads/feature-2"] = c4.id  # Not merged (diverged)

        branches = list(porcelain.no_merged_branches(self.repo))
        expected = [b"feature-1", b"feature-2"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

    def test_only_current_branch_exists(self) -> None:
        """Test when only current branch exists - should return empty list."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id
        self.repo.refs[b"refs/heads/master"] = c1.id

        result = list(porcelain.no_merged_branches(self.repo))
        self.assertEqual([], result)


class BranchContainsTests(PorcelainTestCase):
    def test_commit_in_single_branch(self) -> None:
        """Test commit contained in only one branch."""
        # Create: c1 → c2 (master), c1 → c3 (feature)
        [_c1, c2, c3] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master line)
                [3, 1],  # c3 → c1 (feature branch)
            ],
        )

        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature"] = c3.id

        # c2 is only in master branch
        result = list(porcelain.branches_containing(self.repo, c2.id.decode()))
        self.assertEqual([b"master"], result)

        # c3 is only in feature branch
        result = list(porcelain.branches_containing(self.repo, c3.id.decode()))
        self.assertEqual([b"feature"], result)

    def test_commit_in_multiple_branches(self) -> None:
        """Test commit contained in multiple branches."""
        # Create: c1 → c2 → c3 (master), c2 → c4 (feature)
        [c1, c2, c3, c4] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1
                [3, 2],  # c3 → c2 (master)
                [4, 2],  # c4 → c2 (feature)
            ],
        )

        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature"] = c4.id

        # c2 is in both branches (common ancestor)
        branches = list(porcelain.branches_containing(self.repo, c2.id.decode()))
        expected = [b"master", b"feature"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

        # c1 is in both branches (older common ancestor)
        branches = list(porcelain.branches_containing(self.repo, c1.id.decode()))
        expected = [b"master", b"feature"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

    def test_commit_in_all_branches(self) -> None:
        """Test commit contained in all branches."""
        # Create linear history: c1 → c2 → c3 (HEAD/master)
        [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])

        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature-1"] = c3.id  # Same as master
        self.repo.refs[b"refs/heads/feature-2"] = c2.id  # Ancestor

        # c1 is in all branches
        branches = list(porcelain.branches_containing(self.repo, c1.id.decode()))
        expected = [b"master", b"feature-1", b"feature-2"]
        expected.sort()
        branches.sort()
        self.assertEqual(expected, branches)

    def test_commit_in_no_branches(self) -> None:
        """Test commit not contained in any branch."""
        # Create: c1 → c2 (master), c1 → c3 (feature), orphan c4
        [_c1, c2, c3, c4] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master)
                [3, 1],  # c3 → c1 (feature)
                [4],  # c4 (orphan commit)
            ],
        )

        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature"] = c3.id

        # c4 is not in any branch
        result = list(porcelain.branches_containing(self.repo, c4.id.decode()))
        self.assertEqual([], result)

    def test_commit_ref_by_branch_name(self) -> None:
        """Test using branch name as commit reference."""
        # Create: c1 → c2 (master), c1 → c3 (feature)
        [_c1, c2, c3] = build_commit_graph(
            self.repo.object_store,
            [
                [1],  # c1
                [2, 1],  # c2 → c1 (master)
                [3, 1],  # c3 → c1 (feature)
            ],
        )

        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/heads/feature"] = c3.id

        # Use "master" as commit reference - should find master branch
        result = list(porcelain.branches_containing(self.repo, "master"))
        self.assertEqual([b"master"], result)

        # Use "feature" as commit reference - should find feature branch
        result = list(porcelain.branches_containing(self.repo, "feature"))
        self.assertEqual([b"feature"], result)

    def test_commit_ref_by_head(self) -> None:
        """Test using HEAD as commit reference."""
        # Create: c1 → c2 → c3 (HEAD/master)
        [_c1, c2, c3] = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 2]]
        )

        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/feature"] = c2.id  # Ancestor

        # Use "HEAD" as commit reference
        result = list(porcelain.branches_containing(self.repo, "HEAD"))
        self.assertEqual([b"master"], result)

    def test_invalid_commit_ref(self) -> None:
        """Test with invalid commit reference."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id
        self.repo.refs[b"refs/heads/master"] = c1.id

        # Test with non-existent commit
        with self.assertRaises(KeyError) as cm:
            list(porcelain.branches_containing(self.repo, "nonexistent"))
        self.assertEqual(b"nonexistent", cm.exception.args[0])

        # Test with invalid SHA
        with self.assertRaises(KeyError) as cm:
            list(porcelain.branches_containing(self.repo, "invalid-sha"))
        self.assertEqual(b"invalid-sha", cm.exception.args[0])

    def test_short_sha_reference(self) -> None:
        """Test using short SHA as commit reference."""
        # Create: c1 → c2 (master)
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2, 1]])

        self.repo.refs[b"HEAD"] = c2.id
        self.repo.refs[b"refs/heads/master"] = c2.id

        # Use short SHA (first 7 characters)
        short_sha = c1.id.decode()[:7]
        result = list(porcelain.branches_containing(self.repo, short_sha))
        self.assertEqual([b"master"], result)


class FilterBranchesByPatternTests(PorcelainTestCase):
    """Tests for filter_branches_by_pattern function."""

    def test_empty_branches(self) -> None:
        """Test with empty branches list."""
        result = porcelain.filter_branches_by_pattern([], "feature-*")
        self.assertEqual([], result)

    def test_star_pattern(self) -> None:
        """Test wildcard pattern matching."""
        branches = [b"main", b"feature-1", b"feature-2", b"develop", b"hotfix-bug"]

        result = porcelain.filter_branches_by_pattern(branches, "feature-*")
        expected = [b"feature-1", b"feature-2"]
        self.assertEqual(expected, result)

    def test_question_mark_pattern(self) -> None:
        """Test single character wildcard pattern."""
        branches = [b"main", b"feature-1", b"feature-2", b"feature-a", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "feature-?")
        expected = [b"feature-1", b"feature-2", b"feature-a"]
        self.assertEqual(expected, result)

    def test_specific_pattern(self) -> None:
        """Test exact match pattern."""
        branches = [b"main", b"feature-1", b"feature-2", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "feature-1")
        expected = [b"feature-1"]
        self.assertEqual(expected, result)

    def test_no_matches(self) -> None:
        """Test pattern that matches no branches."""
        branches = [b"main", b"feature-1", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "release-*")
        self.assertEqual([], result)

    def test_case_sensitive_pattern(self) -> None:
        """Test case-sensitive pattern matching."""
        branches = [b"Main", b"main", b"MAIN", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "main")
        expected = [b"main"]
        self.assertEqual(expected, result)

    def test_multiple_patterns_behavior(self) -> None:
        """Test that pattern works with multiple wildcards."""
        branches = [
            b"feature-login",
            b"feature-signup",
            b"bugfix-login",
            b"hotfix-signup",
        ]

        result = porcelain.filter_branches_by_pattern(branches, "feature-*")
        expected = [b"feature-login", b"feature-signup"]
        self.assertEqual(expected, result)

    def test_mixed_encoding_branches(self) -> None:
        """Test with branches that have special characters."""
        branches = [b"feature-1", b"feature/test", b"feature@prod", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "feature/*")
        expected = [b"feature/test"]
        self.assertEqual(expected, result)

    def test_pattern_with_square_brackets(self) -> None:
        """Test pattern with character classes."""
        branches = [b"feature-1", b"feature-2", b"feature-a", b"feature-b", b"develop"]

        result = porcelain.filter_branches_by_pattern(branches, "feature-[12]")
        expected = [b"feature-1", b"feature-2"]
        self.assertEqual(expected, result)


class BranchCreateTests(PorcelainTestCase):
    def test_branch_exists(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.branch_create(self.repo, b"foo")
        self.assertRaises(porcelain.Error, porcelain.branch_create, self.repo, b"foo")
        porcelain.branch_create(self.repo, b"foo", force=True)

    def test_new_branch(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.branch_create(self.repo, b"foo")
        self.assertEqual({b"master", b"foo"}, set(porcelain.branch_list(self.repo)))

    def test_auto_setup_merge_true_from_remote_tracking(self) -> None:
        """Test branch.autoSetupMerge=true sets up tracking from remote-tracking branch."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        # Create a remote-tracking branch
        self.repo.refs[b"refs/remotes/origin/feature"] = c1.id

        # Set branch.autoSetupMerge to true (default)
        config = self.repo.get_config()
        config.set((b"branch",), b"autoSetupMerge", b"true")
        config.write_to_path()

        # Create branch from remote-tracking branch
        porcelain.branch_create(self.repo, "myfeature", "origin/feature")

        # Verify tracking was set up
        config = self.repo.get_config()
        self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
        self.assertEqual(
            config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
        )

    def test_auto_setup_merge_false(self) -> None:
        """Test branch.autoSetupMerge=false disables tracking setup."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        # Create a remote-tracking branch
        self.repo.refs[b"refs/remotes/origin/feature"] = c1.id

        # Set branch.autoSetupMerge to false
        config = self.repo.get_config()
        config.set((b"branch",), b"autoSetupMerge", b"false")
        config.write_to_path()

        # Create branch from remote-tracking branch
        porcelain.branch_create(self.repo, "myfeature", "origin/feature")

        # Verify tracking was NOT set up
        config = self.repo.get_config()
        self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"remote")
        self.assertRaises(KeyError, config.get, (b"branch", b"myfeature"), b"merge")

    def test_auto_setup_merge_always(self) -> None:
        """Test branch.autoSetupMerge=always sets up tracking even from local branches."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        self.repo.refs[b"refs/heads/main"] = c1.id

        # Set branch.autoSetupMerge to always
        config = self.repo.get_config()
        config.set((b"branch",), b"autoSetupMerge", b"always")
        config.write_to_path()

        # Create branch from local branch - normally wouldn't set up tracking
        porcelain.branch_create(self.repo, "feature", "main")

        # With always, tracking should NOT be set up from local branches
        # (Git only sets up tracking from remote-tracking branches even with always)
        config = self.repo.get_config()
        self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"remote")
        self.assertRaises(KeyError, config.get, (b"branch", b"feature"), b"merge")

    def test_auto_setup_merge_always_from_remote(self) -> None:
        """Test branch.autoSetupMerge=always still sets up tracking from remote branches."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        # Create a remote-tracking branch
        self.repo.refs[b"refs/remotes/origin/feature"] = c1.id

        # Set branch.autoSetupMerge to always
        config = self.repo.get_config()
        config.set((b"branch",), b"autoSetupMerge", b"always")
        config.write_to_path()

        # Create branch from remote-tracking branch
        porcelain.branch_create(self.repo, "myfeature", "origin/feature")

        # Verify tracking was set up
        config = self.repo.get_config()
        self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
        self.assertEqual(
            config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
        )

    def test_auto_setup_merge_default(self) -> None:
        """Test default behavior (no config) is same as true."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        # Create a remote-tracking branch
        self.repo.refs[b"refs/remotes/origin/feature"] = c1.id

        # Don't set any config - should default to true

        # Create branch from remote-tracking branch
        porcelain.branch_create(self.repo, "myfeature", "origin/feature")

        # Verify tracking was set up
        config = self.repo.get_config()
        self.assertEqual(config.get((b"branch", b"myfeature"), b"remote"), b"origin")
        self.assertEqual(
            config.get((b"branch", b"myfeature"), b"merge"), b"refs/heads/feature"
        )


class BranchDeleteTests(PorcelainTestCase):
    def test_simple(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.branch_create(self.repo, b"foo")
        self.assertIn(b"foo", porcelain.branch_list(self.repo))
        porcelain.branch_delete(self.repo, b"foo")
        self.assertNotIn(b"foo", porcelain.branch_list(self.repo))

    def test_simple_unicode(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id
        porcelain.branch_create(self.repo, "foo")
        self.assertIn(b"foo", porcelain.branch_list(self.repo))
        porcelain.branch_delete(self.repo, "foo")
        self.assertNotIn(b"foo", porcelain.branch_list(self.repo))


class FetchTests(PorcelainTestCase):
    def test_simple(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        # create a file for initial commit
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Setup target repo
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        target_repo = porcelain.clone(
            self.repo.path, target=target_path, errstream=errstream
        )

        # create a second file to be pushed
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test2",
            author=b"test2 <email>",
            committer=b"test2 <email>",
        )

        self.assertNotIn(self.repo[b"HEAD"].id, target_repo)
        target_repo.close()

        # Fetch changes into the cloned repo
        porcelain.fetch(target_path, "origin", outstream=outstream, errstream=errstream)

        # Assert that fetch updated the local image of the remote
        self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())

        # Check the target repo for pushed changes
        with Repo(target_path) as r:
            self.assertIn(self.repo[b"HEAD"].id, r)

    def test_with_remote_name(self) -> None:
        remote_name = "origin"
        outstream = BytesIO()
        errstream = BytesIO()

        # create a file for initial commit
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test",
            author=b"test <email>",
            committer=b"test <email>",
        )

        # Setup target repo
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        target_repo = porcelain.clone(
            self.repo.path, target=target_path, errstream=errstream
        )

        # Capture current refs
        target_refs = target_repo.get_refs()

        # create a second file to be pushed
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test2",
            author=b"test2 <email>",
            committer=b"test2 <email>",
        )

        self.assertNotIn(self.repo[b"HEAD"].id, target_repo)

        target_config = target_repo.get_config()
        target_config.set(
            (b"remote", remote_name.encode()), b"url", self.repo.path.encode()
        )
        target_repo.close()

        # Fetch changes into the cloned repo
        porcelain.fetch(
            target_path, remote_name, outstream=outstream, errstream=errstream
        )

        # Assert that fetch updated the local image of the remote
        self.assert_correct_remote_refs(target_repo.get_refs(), self.repo.get_refs())

        # Check the target repo for pushed changes, as well as updates
        # for the refs
        with Repo(target_path) as r:
            self.assertIn(self.repo[b"HEAD"].id, r)
            self.assertNotEqual(self.repo.get_refs(), target_refs)

    def test_unshallow(self) -> None:
        outstream = BytesIO()
        errstream = BytesIO()

        # Create three commits in source repo
        for i in range(3):
            handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
            os.close(handle)
            porcelain.add(repo=self.repo.path, paths=fullpath)
            porcelain.commit(
                repo=self.repo.path,
                message=f"commit {i}".encode(),
                author=b"test <email>",
                committer=b"test <email>",
            )

        # Create a shallow clone with depth=1
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        target_repo = porcelain.clone(
            self.repo.path, target=target_path, depth=1, errstream=errstream
        )

        # Verify the clone is shallow
        shallow = target_repo.get_shallow()
        self.assertTrue(len(shallow) > 0, "Repository should be shallow")

        target_repo.close()

        # Unshallow the repository
        porcelain.fetch(
            target_path,
            "origin",
            outstream=outstream,
            errstream=errstream,
            unshallow=True,
        )

        # Verify the repository is no longer shallow
        with Repo(target_path) as r:
            shallow_after = r.get_shallow()
            self.assertEqual(len(shallow_after), 0, "Repository should not be shallow")
            # Verify all commits are now present
            self.assertIn(self.repo[b"HEAD"].id, r)

    def test_unshallow_with_depth_raises(self) -> None:
        # Verify that specifying both unshallow and depth raises an error
        target_path = tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, target_path)
        target_repo = porcelain.clone(
            self.repo.path, target=target_path, depth=1, errstream=BytesIO()
        )
        target_repo.close()

        with self.assertRaises(ValueError):
            porcelain.fetch(
                target_path,
                "origin",
                depth=2,
                unshallow=True,
            )

    def assert_correct_remote_refs(
        self, local_refs, remote_refs, remote_name=b"origin"
    ) -> None:
        """Assert that known remote refs corresponds to actual remote refs."""
        local_ref_prefix = b"refs/heads"
        remote_ref_prefix = b"refs/remotes/" + remote_name

        locally_known_remote_refs = {
            k[len(remote_ref_prefix) + 1 :]: v
            for k, v in local_refs.items()
            if k.startswith(remote_ref_prefix)
        }

        normalized_remote_refs = {
            k[len(local_ref_prefix) + 1 :]: v
            for k, v in remote_refs.items()
            if k.startswith(local_ref_prefix)
        }
        if b"HEAD" in locally_known_remote_refs and b"HEAD" in remote_refs:
            normalized_remote_refs[b"HEAD"] = remote_refs[b"HEAD"]

        self.assertEqual(locally_known_remote_refs, normalized_remote_refs)


class RepackTests(PorcelainTestCase):
    def test_empty(self) -> None:
        porcelain.repack(self.repo)

    def test_simple(self) -> None:
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.repack(self.repo)

    def test_write_bitmaps(self) -> None:
        """Test that write_bitmaps generates bitmap files."""
        # Create some content
        handle, fullpath = tempfile.mkstemp(dir=self.repo.path)
        os.close(handle)
        with open(fullpath, "w") as f:
            f.write("test content")
        porcelain.add(repo=self.repo.path, paths=fullpath)
        porcelain.commit(
            repo=self.repo.path,
            message=b"test commit",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

        # Repack with bitmaps
        porcelain.repack(self.repo, write_bitmaps=True)

        # Check that bitmap files were created
        pack_dir = os.path.join(self.repo.path, ".git", "objects", "pack")
        bitmap_files = [f for f in os.listdir(pack_dir) if f.endswith(".bitmap")]
        self.assertGreater(len(bitmap_files), 0)


class LsTreeTests(PorcelainTestCase):
    def test_empty(self) -> None:
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        f = StringIO()
        porcelain.ls_tree(self.repo, b"HEAD", outstream=f)
        self.assertEqual(f.getvalue(), "")

    def test_simple(self) -> None:
        # Commit a dummy file then modify it
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("origstuff")

        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        output = StringIO()
        porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
        self.assertEqual(
            output.getvalue(),
            "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tfoo\n",
        )

    def test_recursive(self) -> None:
        # Create a directory then write a dummy file in it
        dirpath = os.path.join(self.repo.path, "adir")
        filepath = os.path.join(dirpath, "afile")
        os.mkdir(dirpath)
        with open(filepath, "w") as f:
            f.write("origstuff")
        porcelain.add(repo=self.repo.path, paths=[filepath])
        porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )
        output = StringIO()
        porcelain.ls_tree(self.repo, b"HEAD", outstream=output)
        self.assertEqual(
            output.getvalue(),
            "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n",
        )
        output2 = StringIO()
        porcelain.ls_tree(self.repo, b"HEAD", outstream=output2, recursive=True)
        self.assertEqual(
            output2.getvalue(),
            "40000 tree b145cc69a5e17693e24d8a7be0016ed8075de66d\tadir\n"
            "100644 blob 8b82634d7eae019850bb883f06abf428c58bc9aa\tadir"
            "/afile\n",
        )


class LsRemoteTests(PorcelainTestCase):
    def test_empty(self) -> None:
        result = porcelain.ls_remote(self.repo.path)
        self.assertEqual({}, result.refs)
        self.assertEqual({}, result.symrefs)

    def test_some(self) -> None:
        cid = porcelain.commit(
            repo=self.repo.path,
            message=b"test status",
            author=b"author <email>",
            committer=b"committer <email>",
        )

        result = porcelain.ls_remote(self.repo.path)
        self.assertEqual(
            {b"refs/heads/master": cid, b"HEAD": cid},
            result.refs,
        )
        # HEAD should be a symref to refs/heads/master
        self.assertEqual({b"HEAD": b"refs/heads/master"}, result.symrefs)


class LsFilesTests(PorcelainTestCase):
    def test_empty(self) -> None:
        self.assertEqual([], list(porcelain.ls_files(self.repo)))

    def test_simple(self) -> None:
        # Commit a dummy file then modify it
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("origstuff")

        porcelain.add(repo=self.repo.path, paths=[fullpath])
        self.assertEqual([b"foo"], list(porcelain.ls_files(self.repo)))


class RemoteAddTests(PorcelainTestCase):
    def test_new(self) -> None:
        porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
        c = self.repo.get_config()
        self.assertEqual(
            c.get((b"remote", b"jelmer"), b"url"),
            b"git://jelmer.uk/code/dulwich",
        )

    def test_exists(self) -> None:
        porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
        self.assertRaises(
            porcelain.RemoteExists,
            porcelain.remote_add,
            self.repo,
            "jelmer",
            "git://jelmer.uk/code/dulwich",
        )


class RemoteRemoveTests(PorcelainTestCase):
    def test_remove(self) -> None:
        porcelain.remote_add(self.repo, "jelmer", "git://jelmer.uk/code/dulwich")
        c = self.repo.get_config()
        self.assertEqual(
            c.get((b"remote", b"jelmer"), b"url"),
            b"git://jelmer.uk/code/dulwich",
        )
        porcelain.remote_remove(self.repo, "jelmer")
        self.assertRaises(KeyError, porcelain.remote_remove, self.repo, "jelmer")
        c = self.repo.get_config()
        self.assertRaises(KeyError, c.get, (b"remote", b"jelmer"), b"url")


class CheckIgnoreTests(PorcelainTestCase):
    def test_check_ignored(self) -> None:
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("foo")
        foo_path = os.path.join(self.repo.path, "foo")
        with open(foo_path, "w") as f:
            f.write("BAR")
        bar_path = os.path.join(self.repo.path, "bar")
        with open(bar_path, "w") as f:
            f.write("BAR")
        self.assertEqual(["foo"], list(porcelain.check_ignore(self.repo, [foo_path])))
        self.assertEqual([], list(porcelain.check_ignore(self.repo, [bar_path])))

    def test_check_added_abs(self) -> None:
        path = os.path.join(self.repo.path, "foo")
        with open(path, "w") as f:
            f.write("BAR")
        self.repo.get_worktree().stage(["foo"])
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("foo\n")
        self.assertEqual([], list(porcelain.check_ignore(self.repo, [path])))
        self.assertEqual(
            ["foo"],
            list(porcelain.check_ignore(self.repo, [path], no_index=True)),
        )

    def test_check_added_rel(self) -> None:
        with open(os.path.join(self.repo.path, "foo"), "w") as f:
            f.write("BAR")
        self.repo.get_worktree().stage(["foo"])
        with open(os.path.join(self.repo.path, ".gitignore"), "w") as f:
            f.write("foo\n")
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.mkdir(os.path.join(self.repo.path, "bar"))
        os.chdir(os.path.join(self.repo.path, "bar"))
        self.assertEqual(list(porcelain.check_ignore(self.repo, ["../foo"])), [])
        self.assertEqual(
            ["../foo"],
            list(porcelain.check_ignore(self.repo, ["../foo"], no_index=True)),
        )


class UpdateHeadTests(PorcelainTestCase):
    def test_set_to_branch(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/heads/blah"] = c1.id
        porcelain.update_head(self.repo, "blah")
        self.assertEqual(c1.id, self.repo.head())
        self.assertEqual(b"ref: refs/heads/blah", self.repo.refs.read_ref(b"HEAD"))

    def test_set_to_branch_detached(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/heads/blah"] = c1.id
        porcelain.update_head(self.repo, "blah", detached=True)
        self.assertEqual(c1.id, self.repo.head())
        self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))

    def test_set_to_commit_detached(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/heads/blah"] = c1.id
        porcelain.update_head(self.repo, c1.id, detached=True)
        self.assertEqual(c1.id, self.repo.head())
        self.assertEqual(c1.id, self.repo.refs.read_ref(b"HEAD"))

    def test_set_new_branch(self) -> None:
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/heads/blah"] = c1.id
        porcelain.update_head(self.repo, "blah", new_branch="bar")
        self.assertEqual(c1.id, self.repo.head())
        self.assertEqual(b"ref: refs/heads/bar", self.repo.refs.read_ref(b"HEAD"))


class MailmapTests(PorcelainTestCase):
    def test_no_mailmap(self) -> None:
        self.assertEqual(
            b"Jelmer Vernooij <jelmer@samba.org>",
            porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
        )

    def test_mailmap_lookup(self) -> None:
        with open(os.path.join(self.repo.path, ".mailmap"), "wb") as f:
            f.write(
                b"""\
Jelmer Vernooij <jelmer@debian.org>
"""
            )
        self.assertEqual(
            b"Jelmer Vernooij <jelmer@debian.org>",
            porcelain.check_mailmap(self.repo, b"Jelmer Vernooij <jelmer@samba.org>"),
        )


class FsckTests(PorcelainTestCase):
    def test_none(self) -> None:
        self.assertEqual([], list(porcelain.fsck(self.repo)))

    def test_git_dir(self) -> None:
        obj = Tree()
        a = Blob()
        a.data = b"foo"
        obj.add(b".git", 0o100644, a.id)
        self.repo.object_store.add_objects([(a, None), (obj, None)])
        self.assertEqual(
            [(obj.id, "invalid name .git")],
            [(sha, str(e)) for (sha, e) in porcelain.fsck(self.repo)],
        )


class DescribeTests(PorcelainTestCase):
    def test_no_commits(self) -> None:
        self.assertRaises(KeyError, porcelain.describe, self.repo.path)

    def test_single_commit(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        self.assertEqual(
            "g{}".format(sha[:7].decode("ascii")),
            porcelain.describe(self.repo.path),
        )

    def test_tag(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
        )
        self.assertEqual("tryme", porcelain.describe(self.repo.path))

    def test_tag_and_commit(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
        )
        with open(fullpath, "w") as f:
            f.write("BAR2")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        self.assertEqual(
            "tryme-1-g{}".format(sha[:7].decode("ascii")),
            porcelain.describe(self.repo.path),
        )

    def test_tag_and_commit_full(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        porcelain.tag_create(
            self.repo.path,
            b"tryme",
            b"foo <foo@bar.com>",
            b"bar",
            annotated=True,
        )
        with open(fullpath, "w") as f:
            f.write("BAR2")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        self.assertEqual(
            "tryme-1-g{}".format(sha.decode("ascii")),
            porcelain.describe(self.repo.path, abbrev=40),
        )

    def test_untagged_commit_abbreviation(self) -> None:
        _, _, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1, 2]])
        self.repo.refs[b"HEAD"] = c3.id
        brief_description, complete_description = (
            porcelain.describe(self.repo),
            porcelain.describe(self.repo, abbrev=40),
        )
        self.assertTrue(complete_description.startswith(brief_description))
        self.assertEqual(
            "g{}".format(c3.id.decode("ascii")),
            complete_description,
        )

    def test_hash_length_dynamic(self) -> None:
        """Test that hash length adjusts based on uniqueness."""
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("content")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        sha = porcelain.commit(
            self.repo.path,
            message=b"commit",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )

        # When abbrev is None, it should use find_unique_abbrev
        result = porcelain.describe(self.repo.path)
        # Should start with 'g' and have at least 7 characters
        self.assertTrue(result.startswith("g"))
        self.assertGreaterEqual(len(result[1:]), 7)
        # Should be a prefix of the full SHA
        self.assertTrue(sha.decode("ascii").startswith(result[1:]))


class PathToTreeTests(PorcelainTestCase):
    def setUp(self) -> None:
        super().setUp()
        self.fp = os.path.join(self.test_dir, "bar")
        with open(self.fp, "w") as f:
            f.write("something")
        oldcwd = os.getcwd()
        self.addCleanup(os.chdir, oldcwd)
        os.chdir(self.test_dir)

    def test_path_to_tree_path_base(self) -> None:
        self.assertEqual(b"bar", porcelain.path_to_tree_path(self.test_dir, self.fp))
        self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))
        self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "bar"))
        cwd = os.getcwd()
        self.assertEqual(
            b"bar", porcelain.path_to_tree_path(".", os.path.join(cwd, "bar"))
        )
        self.assertEqual(b"bar", porcelain.path_to_tree_path(cwd, "bar"))

    def test_path_to_tree_path_syntax(self) -> None:
        self.assertEqual(b"bar", porcelain.path_to_tree_path(".", "./bar"))

    def test_path_to_tree_path_error(self) -> None:
        with self.assertRaises(ValueError):
            with tempfile.TemporaryDirectory() as od:
                porcelain.path_to_tree_path(od, self.fp)

    def test_path_to_tree_path_rel(self) -> None:
        cwd = os.getcwd()
        self.addCleanup(os.chdir, cwd)
        os.mkdir(os.path.join(self.repo.path, "foo"))
        os.mkdir(os.path.join(self.repo.path, "foo/bar"))
        os.chdir(os.path.join(self.repo.path, "foo/bar"))
        with open("baz", "w") as f:
            f.write("contents")
        self.assertEqual(b"bar/baz", porcelain.path_to_tree_path("..", "baz"))
        self.assertEqual(
            b"bar/baz",
            porcelain.path_to_tree_path(
                os.path.join(os.getcwd(), ".."),
                os.path.join(os.getcwd(), "baz"),
            ),
        )
        self.assertEqual(
            b"bar/baz",
            porcelain.path_to_tree_path("..", os.path.join(os.getcwd(), "baz")),
        )
        self.assertEqual(
            b"bar/baz",
            porcelain.path_to_tree_path(os.path.join(os.getcwd(), ".."), "baz"),
        )


class GetObjectByPathTests(PorcelainTestCase):
    def test_simple(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
        )
        self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
        self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)

    def test_encoding(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        porcelain.commit(
            self.repo.path,
            message=b"Some message",
            author=b"Joe <joe@example.com>",
            committer=b"Bob <bob@example.com>",
            encoding=b"utf-8",
        )
        self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, "foo").data)
        self.assertEqual(b"BAR", porcelain.get_object_by_path(self.repo, b"foo").data)

    def test_missing(self) -> None:
        self.assertRaises(KeyError, porcelain.get_object_by_path, self.repo, "foo")


class WriteTreeTests(PorcelainTestCase):
    def test_simple(self) -> None:
        fullpath = os.path.join(self.repo.path, "foo")
        with open(fullpath, "w") as f:
            f.write("BAR")
        porcelain.add(repo=self.repo.path, paths=[fullpath])
        self.assertEqual(
            b"d2092c8a9f311f0311083bf8d177f2ca0ab5b241",
            porcelain.write_tree(self.repo),
        )


class ActiveBranchTests(PorcelainTestCase):
    def test_simple(self) -> None:
        self.assertEqual(b"master", porcelain.active_branch(self.repo))


class BranchTrackingTests(PorcelainTestCase):
    def test_get_branch_merge(self) -> None:
        # Set up branch tracking configuration
        config = self.repo.get_config()
        config.set((b"branch", b"master"), b"remote", b"origin")
        config.set((b"branch", b"master"), b"merge", b"refs/heads/main")
        config.write_to_path()

        # Test getting merge ref for current branch
        merge_ref = porcelain.get_branch_merge(self.repo)
        self.assertEqual(b"refs/heads/main", merge_ref)

        # Test getting merge ref for specific branch
        merge_ref = porcelain.get_branch_merge(self.repo, b"master")
        self.assertEqual(b"refs/heads/main", merge_ref)

        # Test branch without merge config
        with self.assertRaises(KeyError):
            porcelain.get_branch_merge(self.repo, b"nonexistent")

    def test_set_branch_tracking(self) -> None:
        # Create a new branch
        _sha, _ = _commit_file_with_content(self.repo, "foo", "content\n")
        porcelain.branch_create(self.repo, "feature")

        # Set up tracking
        porcelain.set_branch_tracking(
            self.repo, b"feature", b"upstream", b"refs/heads/main"
        )

        # Verify configuration was written
        config = self.repo.get_config()
        self.assertEqual(b"upstream", config.get((b"branch", b"feature"), b"remote"))
        self.assertEqual(
            b"refs/heads/main", config.get((b"branch", b"feature"), b"merge")
        )


class FindUniqueAbbrevTests(PorcelainTestCase):
    def test_simple(self) -> None:
        c1, _c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.assertEqual(
            c1.id.decode("ascii")[:7],
            porcelain.find_unique_abbrev(self.repo.object_store, c1.id),
        )


class PackRefsTests(PorcelainTestCase):
    def test_all(self) -> None:
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/tags/foo"] = c1.id

        porcelain.pack_refs(self.repo, all=True)

        self.assertEqual(
            self.repo.refs.get_packed_refs(),
            {
                b"refs/heads/master": c2.id,
                b"refs/tags/foo": c1.id,
            },
        )

    def test_not_all(self) -> None:
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
        )
        self.repo.refs[b"HEAD"] = c3.id
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs[b"refs/tags/foo"] = c1.id

        porcelain.pack_refs(self.repo)

        self.assertEqual(
            self.repo.refs.get_packed_refs(),
            {
                b"refs/tags/foo": c1.id,
            },
        )


class ServerTests(PorcelainTestCase):
    @contextlib.contextmanager
    def _serving(self):
        with make_server("localhost", 0, self.app) as server:
            thread = threading.Thread(target=server.serve_forever, daemon=True)
            thread.start()

            try:
                yield f"http://localhost:{server.server_port}"

            finally:
                server.shutdown()
                thread.join(10)

    def setUp(self) -> None:
        super().setUp()

        self.served_repo_path = os.path.join(self.test_dir, "served_repo.git")
        self.served_repo = Repo.init_bare(self.served_repo_path, mkdir=True)
        self.addCleanup(self.served_repo.close)

        backend = DictBackend({"/": self.served_repo})
        self.app = make_wsgi_chain(backend)

    def test_pull(self) -> None:
        (c1,) = build_commit_graph(self.served_repo.object_store, [[1]])
        self.served_repo.refs[b"refs/heads/master"] = c1.id

        with self._serving() as url:
            porcelain.pull(self.repo, url, "master")

    def test_push(self) -> None:
        (c1,) = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"refs/heads/master"] = c1.id

        with self._serving() as url:
            porcelain.push(self.repo, url, "master")


class ForEachTests(PorcelainTestCase):
    def setUp(self) -> None:
        super().setUp()
        c1, c2, c3, c4 = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1, 2], [4]]
        )
        porcelain.tag_create(
            self.repo.path,
            b"v0.1",
            objectish=c1.id,
            annotated=True,
            message=b"0.1",
        )
        porcelain.tag_create(
            self.repo.path,
            b"v1.0",
            objectish=c2.id,
            annotated=True,
            message=b"1.0",
        )
        porcelain.tag_create(self.repo.path, b"simple-tag", objectish=c3.id)
        porcelain.tag_create(
            self.repo.path,
            b"v1.1",
            objectish=c4.id,
            annotated=True,
            message=b"1.1",
        )
        porcelain.branch_create(
            self.repo.path, b"feat", objectish=c2.id.decode("ascii")
        )
        self.repo.refs[b"HEAD"] = c4.id

    def test_for_each_ref(self) -> None:
        refs = porcelain.for_each_ref(self.repo)

        self.assertEqual(
            [(object_type, tag) for _, object_type, tag in refs],
            [
                (b"commit", b"refs/heads/feat"),
                (b"commit", b"refs/heads/master"),
                (b"commit", b"refs/tags/simple-tag"),
                (b"tag", b"refs/tags/v0.1"),
                (b"tag", b"refs/tags/v1.0"),
                (b"tag", b"refs/tags/v1.1"),
            ],
        )

    def test_for_each_ref_pattern(self) -> None:
        versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v*")
        self.assertEqual(
            [(object_type, tag) for _, object_type, tag in versions],
            [
                (b"tag", b"refs/tags/v0.1"),
                (b"tag", b"refs/tags/v1.0"),
                (b"tag", b"refs/tags/v1.1"),
            ],
        )

        versions = porcelain.for_each_ref(self.repo, pattern="refs/tags/v1.?")
        self.assertEqual(
            [(object_type, tag) for _, object_type, tag in versions],
            [
                (b"tag", b"refs/tags/v1.0"),
                (b"tag", b"refs/tags/v1.1"),
            ],
        )


class SparseCheckoutTests(PorcelainTestCase):
    """Integration tests for Dulwich's sparse checkout feature."""

    # NOTE: We do NOT override `setUp()` here because the parent class
    #       (PorcelainTestCase) already:
    #         1) Creates self.test_dir = a unique temp dir
    #         2) Creates a subdir named "repo"
    #         3) Calls Repo.init() on that path
    #       Re-initializing again caused FileExistsError.

    #
    # Utility/Placeholder
    #
    def sparse_checkout(self, repo, patterns, force=False):
        """Wrapper around the actual porcelain.sparse_checkout function
        to handle any test-specific setup or logging.
        """
        return porcelain.sparse_checkout(repo, patterns, force=force)

    def _write_file(self, rel_path, content):
        """Helper to write a file in the repository working tree."""
        abs_path = os.path.join(self.repo_path, rel_path)
        os.makedirs(os.path.dirname(abs_path), exist_ok=True)
        with open(abs_path, "w") as f:
            f.write(content)
        return abs_path

    def _commit_file(self, rel_path, content):
        """Helper to write, add, and commit a file."""
        abs_path = self._write_file(rel_path, content)
        add(self.repo_path, paths=[abs_path])
        commit(self.repo_path, message=b"Added " + rel_path.encode("utf-8"))

    def _list_wtree_files(self):
        """Return a set of all files (not dirs) present
        in the working tree, ignoring .git/.
        """
        found_files = set()
        for root, dirs, files in os.walk(self.repo_path):
            # Skip .git in the walk
            if ".git" in dirs:
                dirs.remove(".git")

            for filename in files:
                file_rel = os.path.relpath(os.path.join(root, filename), self.repo_path)
                found_files.add(file_rel)
        return found_files

    def test_only_included_paths_appear_in_wtree(self):
        """Only included paths remain in the working tree, excluded paths are removed.

        Commits two files, "keep_me.txt" and "exclude_me.txt". Then applies a
        sparse-checkout pattern containing only "keep_me.txt". Ensures that
        the latter remains in the working tree, while "exclude_me.txt" is
        removed. This verifies correct application of sparse-checkout patterns
        to remove files not listed.
        """
        self._commit_file("keep_me.txt", "I'll stay\n")
        self._commit_file("exclude_me.txt", "I'll be excluded\n")

        patterns = ["keep_me.txt"]
        self.sparse_checkout(self.repo, patterns)

        actual_files = self._list_wtree_files()
        expected_files = {"keep_me.txt"}
        self.assertEqual(
            expected_files,
            actual_files,
            f"Expected only {expected_files}, but found {actual_files}",
        )

    def test_previously_included_paths_become_excluded(self):
        """Previously included files become excluded after pattern changes.

        Verifies that files initially brought into the working tree (e.g.,
        by including `data/`) can later be excluded by narrowing the
        sparse-checkout pattern to just `data/included_1.txt`. Confirms that
        the file `data/included_2.txt` remains in the index with
        skip-worktree set (rather than being removed entirely), ensuring
        data is not lost and Dulwich correctly updates the index flags.
        """
        self._commit_file("data/included_1.txt", "some content\n")
        self._commit_file("data/included_2.txt", "other content\n")

        initial_patterns = ["data/"]
        self.sparse_checkout(self.repo, initial_patterns)

        updated_patterns = ["data/included_1.txt"]
        self.sparse_checkout(self.repo, updated_patterns)

        actual_files = self._list_wtree_files()
        expected_files = {os.path.join("data", "included_1.txt")}
        self.assertEqual(expected_files, actual_files)

        idx = self.repo.open_index()
        self.assertIn(b"data/included_2.txt", idx)
        entry = idx[b"data/included_2.txt"]
        self.assertTrue(entry.skip_worktree)

    def test_force_removes_local_changes_for_excluded_paths(self):
        """Forced sparse checkout removes local modifications for newly excluded paths.

        Verifies that specifying force=True allows destructive operations
        which discard uncommitted changes. First, we commit "file1.txt" and
        then modify it. Next, we apply a pattern that excludes the file,
        using force=True. The local modifications (and the file) should
        be removed, leaving the working tree empty.
        """
        self._commit_file("file1.txt", "original content\n")

        file1_path = os.path.join(self.repo_path, "file1.txt")
        with open(file1_path, "a") as f:
            f.write("local changes!\n")

        new_patterns = ["some_other_file.txt"]
        self.sparse_checkout(self.repo, new_patterns, force=True)

        actual_files = self._list_wtree_files()
        self.assertEqual(
            set(),
            actual_files,
            "Force-sparse-checkout did not remove file with local changes.",
        )

    def test_destructive_refuse_uncommitted_changes_without_force(self):
        """Fail on uncommitted changes for newly excluded paths without force.

        Ensures that a sparse checkout is blocked if it would remove local
        modifications from the working tree. We commit 'config.yaml', then
        modify it, and finally attempt to exclude it via new patterns without
        using force=True. This should raise a CheckoutError rather than
        discarding the local changes.
        """
        self._commit_file("config.yaml", "initial\n")
        cfg_path = os.path.join(self.repo_path, "config.yaml")
        with open(cfg_path, "a") as f:
            f.write("local modifications\n")

        exclude_patterns = ["docs/"]
        with self.assertRaises(CheckoutError):
            self.sparse_checkout(self.repo, exclude_patterns, force=False)

    def test_fnmatch_gitignore_pattern_expansion(self):
        """Reading/writing patterns align with gitignore/fnmatch expansions.

        Ensures that `sparse_checkout` interprets wildcard patterns (like `*.py`)
        in the same way Git's sparse-checkout would. Multiple files are committed
        to `src/` (e.g. `foo.py`, `foo_test.py`, `foo_helper.py`) and to `docs/`.
        Then the pattern `src/foo*.py` is applied, confirming that only the
        matching Python files remain in the working tree while the Markdown file
        under `docs/` is excluded.

        Finally, verifies that the `.git/info/sparse-checkout` file contains the
        specified wildcard pattern (`src/foo*.py`), ensuring correct round-trip
        of user-supplied patterns.
        """
        self._commit_file("src/foo.py", "print('hello')\n")
        self._commit_file("src/foo_test.py", "print('test')\n")
        self._commit_file("docs/readme.md", "# docs\n")
        self._commit_file("src/foo_helper.py", "print('helper')\n")

        patterns = ["src/foo*.py"]
        self.sparse_checkout(self.repo, patterns)

        actual_files = self._list_wtree_files()
        expected_files = {
            os.path.join("src", "foo.py"),
            os.path.join("src", "foo_test.py"),
            os.path.join("src", "foo_helper.py"),
        }
        self.assertEqual(
            expected_files,
            actual_files,
            "Wildcard pattern not matched as expected. Either too strict or too broad.",
        )

        sc_file = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
        self.assertTrue(os.path.isfile(sc_file))
        with open(sc_file) as f:
            lines = f.read().strip().split()
            self.assertIn("src/foo*.py", lines)


class ConeModeTests(PorcelainTestCase):
    """Provide integration tests for Dulwich's cone mode sparse checkout.

    This test suite verifies the expected behavior for:
      * cone_mode_init
      * cone_mode_set
      * cone_mode_add
    Although Dulwich does not yet implement cone mode, these tests are
    prepared in advance to guide future development.
    """

    def setUp(self):
        """Set up a fresh repository for each test.

        This method creates a new empty repo_path and Repo object
        as provided by the PorcelainTestCase base class.
        """
        super().setUp()

    def _commit_file(self, rel_path, content=b"contents"):
        """Add a file at the given relative path and commit it.

        Creates necessary directories, writes the file content,
        stages, and commits. The commit message and author/committer
        are also provided.
        """
        full_path = os.path.join(self.repo_path, rel_path)
        os.makedirs(os.path.dirname(full_path), exist_ok=True)
        with open(full_path, "wb") as f:
            f.write(content)
        porcelain.add(self.repo_path, paths=[full_path])
        porcelain.commit(
            self.repo_path,
            message=b"Adding " + rel_path.encode("utf-8"),
            author=b"Test Author <author@example.com>",
            committer=b"Test Committer <committer@example.com>",
        )

    def _list_wtree_files(self):
        """Return a set of all file paths relative to the repository root.

        Walks the working tree, skipping the .git directory.
        """
        found_files = set()
        for root, dirs, files in os.walk(self.repo_path):
            if ".git" in dirs:
                dirs.remove(".git")
            for fn in files:
                relp = os.path.relpath(os.path.join(root, fn), self.repo_path)
                found_files.add(relp)
        return found_files

    def test_init_excludes_everything(self):
        """Verify that cone_mode_init writes minimal patterns and empties the working tree.

        Make some dummy files, commit them, then call cone_mode_init. Confirm
        that the working tree is empty, the sparse-checkout file has the
        minimal patterns (/*, !/*/), and the relevant config values are set.
        """
        self._commit_file("docs/readme.md", b"# doc\n")
        self._commit_file("src/main.py", b"print('hello')\n")

        porcelain.cone_mode_init(self.repo)

        actual_files = self._list_wtree_files()
        self.assertEqual(
            set(),
            actual_files,
            "cone_mode_init did not exclude all files from the working tree.",
        )

        sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
        with open(sp_path) as f:
            lines = [ln.strip() for ln in f if ln.strip()]

        self.assertIn("/*", lines)
        self.assertIn("!/*/", lines)

        config = self.repo.get_config()
        self.assertEqual(config.get((b"core",), b"sparseCheckout"), b"true")
        self.assertEqual(config.get((b"core",), b"sparseCheckoutCone"), b"true")

    def test_set_specific_dirs(self):
        """Verify that cone_mode_set overwrites the included directories to only the specified ones.

        Initializes cone mode, commits some files, then calls cone_mode_set with
        a list of directories. Expects that only those directories remain in the
        working tree.
        """
        porcelain.cone_mode_init(self.repo)
        self._commit_file("docs/readme.md", b"# doc\n")
        self._commit_file("src/main.py", b"print('hello')\n")
        self._commit_file("tests/test_foo.py", b"# tests\n")

        # Everything is still excluded initially by init.

        porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])

        actual_files = self._list_wtree_files()
        expected_files = {
            os.path.join("docs", "readme.md"),
            os.path.join("src", "main.py"),
        }
        self.assertEqual(
            expected_files,
            actual_files,
            "Did not see only the 'docs/' and 'src/' dirs in the working tree.",
        )

        sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
        with open(sp_path) as f:
            lines = [ln.strip() for ln in f if ln.strip()]

        # For standard cone mode, we'd expect lines like:
        #    /*           (include top-level files)
        #    !/*/         (exclude subdirectories)
        #    !/docs/      (re-include docs)
        #    !/src/       (re-include src)
        # Instead of the wildcard-based lines the old test used.
        self.assertIn("/*", lines)
        self.assertIn("!/*/", lines)
        self.assertIn("/docs/", lines)
        self.assertIn("/src/", lines)
        self.assertNotIn("/tests/", lines)

    def test_set_overwrites_old_dirs(self):
        """Ensure that calling cone_mode_set again overwrites old includes.

        Initializes cone mode, includes two directories, then calls
        cone_mode_set again with a different directory to confirm the
        new set of includes replaces the old.
        """
        porcelain.cone_mode_init(self.repo)
        self._commit_file("docs/readme.md")
        self._commit_file("src/main.py")
        self._commit_file("tests/test_bar.py")

        porcelain.cone_mode_set(self.repo, dirs=["docs", "src"])
        self.assertEqual(
            {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
            self._list_wtree_files(),
        )

        # Overwrite includes, now only 'tests'
        porcelain.cone_mode_set(self.repo, dirs=["tests"], force=True)

        actual_files = self._list_wtree_files()
        expected_files = {os.path.join("tests", "test_bar.py")}
        self.assertEqual(expected_files, actual_files)

    def test_force_removal_of_local_mods(self):
        """Confirm that force=True removes local changes in excluded paths.

        cone_mode_init and cone_mode_set are called, a file is locally modified,
        and then cone_mode_set is called again with force=True to exclude that path.
        The excluded file should be removed with no CheckoutError.
        """
        porcelain.cone_mode_init(self.repo)
        porcelain.cone_mode_set(self.repo, dirs=["docs"])

        self._commit_file("docs/readme.md", b"Docs stuff\n")
        self._commit_file("src/main.py", b"print('hello')\n")

        # Modify src/main.py
        with open(os.path.join(self.repo_path, "src/main.py"), "ab") as f:
            f.write(b"extra line\n")

        # Exclude src/ with force=True
        porcelain.cone_mode_set(self.repo, dirs=["docs"], force=True)

        actual_files = self._list_wtree_files()
        expected_files = {os.path.join("docs", "readme.md")}
        self.assertEqual(expected_files, actual_files)

    def test_add_and_merge_dirs(self):
        """Verify that cone_mode_add merges new directories instead of overwriting them.

        After initializing cone mode and including a single directory, call
        cone_mode_add with a new directory. Confirm that both directories
        remain included. Repeat for an additional directory to ensure it
        is merged, not overwritten.
        """
        porcelain.cone_mode_init(self.repo)
        self._commit_file("docs/readme.md", b"# doc\n")
        self._commit_file("src/main.py", b"print('hello')\n")
        self._commit_file("tests/test_bar.py", b"# tests\n")

        # Include "docs" only
        porcelain.cone_mode_set(self.repo, dirs=["docs"])
        self.assertEqual({os.path.join("docs", "readme.md")}, self._list_wtree_files())

        # Add "src"
        porcelain.cone_mode_add(self.repo, dirs=["src"])
        actual_files = self._list_wtree_files()
        self.assertEqual(
            {os.path.join("docs", "readme.md"), os.path.join("src", "main.py")},
            actual_files,
        )

        # Add "tests" as well
        porcelain.cone_mode_add(self.repo, dirs=["tests"])
        actual_files = self._list_wtree_files()
        expected_files = {
            os.path.join("docs", "readme.md"),
            os.path.join("src", "main.py"),
            os.path.join("tests", "test_bar.py"),
        }
        self.assertEqual(expected_files, actual_files)

        # Check .git/info/sparse-checkout
        sp_path = os.path.join(self.repo_path, ".git", "info", "sparse-checkout")
        with open(sp_path) as f:
            lines = [ln.strip() for ln in f if ln.strip()]

        # Standard cone mode lines:
        # "/*"    -> include top-level
        # "!/*/"  -> exclude subdirectories
        # "!/docs/", "!/src/", "!/tests/" -> re-include the directories we added
        self.assertIn("/*", lines)
        self.assertIn("!/*/", lines)
        self.assertIn("/docs/", lines)
        self.assertIn("/src/", lines)
        self.assertIn("/tests/", lines)


class UnpackObjectsTest(PorcelainTestCase):
    def test_unpack_objects(self):
        """Test unpacking objects from a pack file."""
        # Create a test repository with some objects
        b1 = Blob()
        b1.data = b"test content 1"
        b2 = Blob()
        b2.data = b"test content 2"

        # Add objects to the repo
        self.repo.object_store.add_object(b1)
        self.repo.object_store.add_object(b2)

        # Create a pack file with these objects
        pack_path = os.path.join(self.test_dir, "test_pack")
        with (
            open(pack_path + ".pack", "wb") as pack_f,
            open(pack_path + ".idx", "wb") as idx_f,
        ):
            porcelain.pack_objects(
                self.repo,
                [b1.id, b2.id],
                pack_f,
                idx_f,
            )

        # Create a new repository to unpack into
        target_repo_path = os.path.join(self.test_dir, "target_repo")
        target_repo = Repo.init(target_repo_path, mkdir=True)
        self.addCleanup(target_repo.close)

        # Unpack the objects
        count = porcelain.unpack_objects(pack_path + ".pack", target_repo_path)

        # Verify the objects were unpacked
        self.assertEqual(2, count)
        self.assertIn(b1.id, target_repo.object_store)
        self.assertIn(b2.id, target_repo.object_store)

        # Verify the content is correct
        unpacked_b1 = target_repo.object_store[b1.id]
        unpacked_b2 = target_repo.object_store[b2.id]
        self.assertEqual(b1.data, unpacked_b1.data)
        self.assertEqual(b2.data, unpacked_b2.data)


class CountObjectsTests(PorcelainTestCase):
    def test_count_objects_empty_repo(self):
        """Test counting objects in an empty repository."""
        stats = porcelain.count_objects(self.repo)
        self.assertEqual(0, stats.count)
        self.assertEqual(0, stats.size)

    def test_count_objects_verbose_empty_repo(self):
        """Test verbose counting in an empty repository."""
        stats = porcelain.count_objects(self.repo, verbose=True)
        self.assertEqual(0, stats.count)
        self.assertEqual(0, stats.size)
        self.assertEqual(0, stats.in_pack)
        self.assertEqual(0, stats.packs)
        self.assertEqual(0, stats.size_pack)

    def test_count_objects_with_loose_objects(self):
        """Test counting loose objects."""
        # Create some loose objects
        blob1 = make_object(Blob, data=b"data1")
        blob2 = make_object(Blob, data=b"data2")
        self.repo.object_store.add_object(blob1)
        self.repo.object_store.add_object(blob2)

        stats = porcelain.count_objects(self.repo)
        self.assertEqual(2, stats.count)
        self.assertGreater(stats.size, 0)

    def test_count_objects_verbose_with_objects(self):
        """Test verbose counting with both loose and packed objects."""
        # Add some loose objects
        for i in range(3):
            blob = make_object(Blob, data=f"data{i}".encode())
            self.repo.object_store.add_object(blob)

        # Create a simple commit to have some objects in a pack
        tree = Tree()
        c1 = make_commit(tree=tree.id, message=b"Test commit")
        self.repo.object_store.add_objects([(tree, None), (c1, None)])
        self.repo.refs[b"HEAD"] = c1.id

        # Repack to create a pack file
        porcelain.repack(self.repo)

        stats = porcelain.count_objects(self.repo, verbose=True)

        # After repacking, loose objects might be cleaned up
        self.assertIsInstance(stats.count, int)
        self.assertIsInstance(stats.size, int)
        self.assertGreater(stats.in_pack, 0)  # Should have packed objects
        self.assertGreater(stats.packs, 0)  # Should have at least one pack
        self.assertGreater(stats.size_pack, 0)  # Pack should have size

        # Verify it's the correct dataclass type
        self.assertIsInstance(stats, CountObjectsResult)


class PruneTests(PorcelainTestCase):
    def test_prune_removes_old_tempfiles(self):
        """Test that prune removes old temporary files."""
        # Create an old temporary file in the objects directory
        objects_dir = os.path.join(self.repo.path, ".git", "objects")
        tmp_pack_path = os.path.join(objects_dir, "tmp_pack_test")
        with open(tmp_pack_path, "wb") as f:
            f.write(b"old temporary data")

        # Make it old
        old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
        os.utime(tmp_pack_path, (old_time, old_time))

        # Run prune
        porcelain.prune(self.repo.path)

        # Verify the file was removed
        self.assertFalse(os.path.exists(tmp_pack_path))

    def test_prune_keeps_recent_tempfiles(self):
        """Test that prune keeps recent temporary files."""
        # Create a recent temporary file
        objects_dir = os.path.join(self.repo.path, ".git", "objects")
        tmp_pack_path = os.path.join(objects_dir, "tmp_pack_recent")
        with open(tmp_pack_path, "wb") as f:
            f.write(b"recent temporary data")
        self.addCleanup(os.remove, tmp_pack_path)

        # Run prune
        porcelain.prune(self.repo.path)

        # Verify the file was NOT removed
        self.assertTrue(os.path.exists(tmp_pack_path))

    def test_prune_with_custom_grace_period(self):
        """Test prune with custom grace period."""
        # Create a 1-hour-old temporary file
        objects_dir = os.path.join(self.repo.path, ".git", "objects")
        tmp_pack_path = os.path.join(objects_dir, "tmp_pack_1hour")
        with open(tmp_pack_path, "wb") as f:
            f.write(b"1 hour old data")

        # Make it 1 hour old
        old_time = time.time() - 3600
        os.utime(tmp_pack_path, (old_time, old_time))

        # Prune with 30-minute grace period should remove it
        porcelain.prune(self.repo.path, grace_period=1800)

        # Verify the file was removed
        self.assertFalse(os.path.exists(tmp_pack_path))

    def test_prune_dry_run(self):
        """Test prune in dry-run mode."""
        # Create an old temporary file
        objects_dir = os.path.join(self.repo.path, ".git", "objects")
        tmp_pack_path = os.path.join(objects_dir, "tmp_pack_dryrun")
        with open(tmp_pack_path, "wb") as f:
            f.write(b"old temporary data")
        self.addCleanup(os.remove, tmp_pack_path)

        # Make it old
        old_time = time.time() - (DEFAULT_TEMPFILE_GRACE_PERIOD + 3600)
        os.utime(tmp_pack_path, (old_time, old_time))

        # Run prune in dry-run mode
        porcelain.prune(self.repo.path, dry_run=True)

        # Verify the file was NOT removed (dry run)
        self.assertTrue(os.path.exists(tmp_pack_path))


class FilterBranchTests(PorcelainTestCase):
    def setUp(self):
        super().setUp()
        # Create initial commits with different authors
        from dulwich.objects import Commit, Tree

        # Create actual tree and blob objects
        tree = Tree()
        self.repo.object_store.add_object(tree)

        c1 = Commit()
        c1.tree = tree.id
        c1.parents = []
        c1.author = b"Old Author <old@example.com>"
        c1.author_time = 1000
        c1.author_timezone = 0
        c1.committer = b"Old Committer <old@example.com>"
        c1.commit_time = 1000
        c1.commit_timezone = 0
        c1.message = b"Initial commit"
        self.repo.object_store.add_object(c1)

        c2 = Commit()
        c2.tree = tree.id
        c2.parents = [c1.id]
        c2.author = b"Another Author <another@example.com>"
        c2.author_time = 2000
        c2.author_timezone = 0
        c2.committer = b"Another Committer <another@example.com>"
        c2.commit_time = 2000
        c2.commit_timezone = 0
        c2.message = b"Second commit\n\nWith body"
        self.repo.object_store.add_object(c2)

        c3 = Commit()
        c3.tree = tree.id
        c3.parents = [c2.id]
        c3.author = b"Third Author <third@example.com>"
        c3.author_time = 3000
        c3.author_timezone = 0
        c3.committer = b"Third Committer <third@example.com>"
        c3.commit_time = 3000
        c3.commit_timezone = 0
        c3.message = b"Third commit"
        self.repo.object_store.add_object(c3)

        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")

        # Store IDs for test assertions
        self.c1_id = c1.id
        self.c2_id = c2.id
        self.c3_id = c3.id

    def test_filter_branch_author(self):
        """Test filtering branch with author changes."""

        def filter_author(author):
            # Change all authors to "New Author"
            return b"New Author <new@example.com>"

        result = porcelain.filter_branch(
            self.repo_path, "master", filter_author=filter_author
        )

        # Check that we have mappings for all commits
        self.assertEqual(len(result), 3)

        # Verify the branch ref was updated
        new_head = self.repo.refs[b"refs/heads/master"]
        self.assertNotEqual(new_head, self.c3_id)

        # Verify the original ref was saved
        original_ref = self.repo.refs[b"refs/original/refs/heads/master"]
        self.assertEqual(original_ref, self.c3_id)

        # Check that authors were updated
        new_commit = self.repo[new_head]
        self.assertEqual(new_commit.author, b"New Author <new@example.com>")

        # Check parent chain
        parent = self.repo[new_commit.parents[0]]
        self.assertEqual(parent.author, b"New Author <new@example.com>")

    def test_filter_branch_message(self):
        """Test filtering branch with message changes."""

        def filter_message(message):
            # Add prefix to all messages
            return b"[FILTERED] " + message

        porcelain.filter_branch(self.repo_path, "master", filter_message=filter_message)

        # Verify messages were updated
        new_head = self.repo.refs[b"refs/heads/master"]
        new_commit = self.repo[new_head]
        self.assertTrue(new_commit.message.startswith(b"[FILTERED] "))

    def test_filter_branch_custom_filter(self):
        """Test filtering branch with custom filter function."""

        def custom_filter(commit):
            # Change both author and message
            return {
                "author": b"Custom Author <custom@example.com>",
                "message": b"Custom: " + commit.message,
            }

        porcelain.filter_branch(self.repo_path, "master", filter_fn=custom_filter)

        # Verify custom filter was applied
        new_head = self.repo.refs[b"refs/heads/master"]
        new_commit = self.repo[new_head]
        self.assertEqual(new_commit.author, b"Custom Author <custom@example.com>")
        self.assertTrue(new_commit.message.startswith(b"Custom: "))

    def test_filter_branch_no_changes(self):
        """Test filtering branch with no changes."""
        result = porcelain.filter_branch(self.repo_path, "master")

        # All commits should map to themselves
        for old_sha, new_sha in result.items():
            self.assertEqual(old_sha, new_sha)

        # HEAD should be unchanged
        self.assertEqual(self.repo.refs[b"refs/heads/master"], self.c3_id)

    def test_filter_branch_force(self):
        """Test force filtering a previously filtered branch."""
        # First filter
        porcelain.filter_branch(
            self.repo_path, "master", filter_message=lambda m: b"First: " + m
        )

        # Try again without force - should fail
        with self.assertRaises(porcelain.Error):
            porcelain.filter_branch(
                self.repo_path, "master", filter_message=lambda m: b"Second: " + m
            )

        # Try again with force - should succeed
        porcelain.filter_branch(
            self.repo_path,
            "master",
            filter_message=lambda m: b"Second: " + m,
            force=True,
        )

        # Verify second filter was applied
        new_head = self.repo.refs[b"refs/heads/master"]
        new_commit = self.repo[new_head]
        self.assertTrue(new_commit.message.startswith(b"Second: First: "))


class StashTests(PorcelainTestCase):
    def setUp(self) -> None:
        super().setUp()
        # Create initial commit
        with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
            f.write(b"initial content")
        porcelain.add(repo=self.repo.path, paths=["initial.txt"])
        porcelain.commit(
            repo=self.repo.path,
            message=b"Initial commit",
            author=b"Test Author <test@example.com>",
            committer=b"Test Committer <test@example.com>",
        )

    def test_stash_push_and_pop(self) -> None:
        # Create a new file and stage it
        new_file = os.path.join(self.repo.path, "new.txt")
        with open(new_file, "wb") as f:
            f.write(b"new file content")
        porcelain.add(repo=self.repo.path, paths=["new.txt"])

        # Modify existing file
        with open(os.path.join(self.repo.path, "initial.txt"), "wb") as f:
            f.write(b"modified content")

        # Push to stash
        porcelain.stash_push(self.repo.path)

        # Verify files are reset
        self.assertFalse(os.path.exists(new_file))
        with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
            self.assertEqual(b"initial content", f.read())

        # Pop the stash
        porcelain.stash_pop(self.repo.path)

        # Verify files are restored
        self.assertTrue(os.path.exists(new_file))
        with open(new_file, "rb") as f:
            self.assertEqual(b"new file content", f.read())
        with open(os.path.join(self.repo.path, "initial.txt"), "rb") as f:
            self.assertEqual(b"modified content", f.read())

        # Verify new file is in the index
        from dulwich.index import Index

        index = Index(os.path.join(self.repo.path, ".git", "index"))
        self.assertIn(b"new.txt", index)

    def test_stash_list(self) -> None:
        # Initially no stashes
        stashes = list(porcelain.stash_list(self.repo.path))
        self.assertEqual(0, len(stashes))

        # Create a file and stash it
        test_file = os.path.join(self.repo.path, "test.txt")
        with open(test_file, "wb") as f:
            f.write(b"test content")
        porcelain.add(repo=self.repo.path, paths=["test.txt"])

        # Push first stash
        porcelain.stash_push(self.repo.path)

        # Create another file and stash it
        test_file2 = os.path.join(self.repo.path, "test2.txt")
        with open(test_file2, "wb") as f:
            f.write(b"test content 2")
        porcelain.add(repo=self.repo.path, paths=["test2.txt"])

        # Push second stash
        porcelain.stash_push(self.repo.path)

        # Check stash list
        stashes = list(porcelain.stash_list(self.repo.path))
        self.assertEqual(2, len(stashes))

        # Stashes are returned in order (most recent first)
        self.assertEqual(0, stashes[0][0])
        self.assertEqual(1, stashes[1][0])

    def test_stash_drop(self) -> None:
        # Create and stash some changes
        test_file = os.path.join(self.repo.path, "test.txt")
        with open(test_file, "wb") as f:
            f.write(b"test content")
        porcelain.add(repo=self.repo.path, paths=["test.txt"])
        porcelain.stash_push(self.repo.path)

        # Create another stash
        test_file2 = os.path.join(self.repo.path, "test2.txt")
        with open(test_file2, "wb") as f:
            f.write(b"test content 2")
        porcelain.add(repo=self.repo.path, paths=["test2.txt"])
        porcelain.stash_push(self.repo.path)

        # Verify we have 2 stashes
        stashes = list(porcelain.stash_list(self.repo.path))
        self.assertEqual(2, len(stashes))

        # Drop the first stash (index 0)
        porcelain.stash_drop(self.repo.path, 0)

        # Verify we have 1 stash left
        stashes = list(porcelain.stash_list(self.repo.path))
        self.assertEqual(1, len(stashes))

        # The remaining stash should be the one we created first
        # Pop it and verify it's the first file
        porcelain.stash_pop(self.repo.path)
        self.assertTrue(os.path.exists(test_file))
        self.assertFalse(os.path.exists(test_file2))

    def test_stash_pop_empty(self) -> None:
        # Attempting to pop from empty stash should raise an error
        with self.assertRaises(IndexError):
            porcelain.stash_pop(self.repo.path)

    def test_stash_with_untracked_files(self) -> None:
        # Create an untracked file
        untracked_file = os.path.join(self.repo.path, "untracked.txt")
        with open(untracked_file, "wb") as f:
            f.write(b"untracked content")

        # Create a tracked change
        tracked_file = os.path.join(self.repo.path, "tracked.txt")
        with open(tracked_file, "wb") as f:
            f.write(b"tracked content")
        porcelain.add(repo=self.repo.path, paths=["tracked.txt"])

        # Stash (by default, untracked files are not included)
        porcelain.stash_push(self.repo.path)

        # Untracked file should still exist
        self.assertTrue(os.path.exists(untracked_file))
        # Tracked file should be gone
        self.assertFalse(os.path.exists(tracked_file))

        # Pop the stash
        porcelain.stash_pop(self.repo.path)

        # Tracked file should be restored
        self.assertTrue(os.path.exists(tracked_file))


class BisectTests(PorcelainTestCase):
    """Tests for bisect porcelain functions."""

    def test_bisect_start(self):
        """Test starting a bisect session."""
        # Create some commits
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2]],
            attrs={
                1: {"message": b"initial"},
                2: {"message": b"second"},
                3: {"message": b"third"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"HEAD"] = c3.id

        # Start bisect
        porcelain.bisect_start(self.repo_path)

        # Check that bisect state files exist
        self.assertTrue(
            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
        )
        self.assertTrue(
            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_TERMS"))
        )
        self.assertTrue(
            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_NAMES"))
        )
        self.assertTrue(
            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_LOG"))
        )

    def test_bisect_workflow(self):
        """Test a complete bisect workflow."""
        # Create some commits
        c1, c2, c3, c4 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2], [4, 3]],
            attrs={
                1: {"message": b"good commit 1"},
                2: {"message": b"good commit 2"},
                3: {"message": b"bad commit"},
                4: {"message": b"bad commit 2"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c4.id
        self.repo.refs[b"HEAD"] = c4.id

        # Start bisect with bad and good
        next_sha = porcelain.bisect_start(self.repo_path, bad=c4.id, good=c1.id)

        # Should return the middle commit
        self.assertIsNotNone(next_sha)
        self.assertIn(next_sha, [c2.id, c3.id])

        # Mark the middle commit as good or bad
        if next_sha == c2.id:
            # c2 is good, next should be c3
            next_sha = porcelain.bisect_good(self.repo_path)
            self.assertEqual(next_sha, c3.id)
            # Mark c3 as bad - bisect complete
            next_sha = porcelain.bisect_bad(self.repo_path)
            self.assertIsNone(next_sha)
        else:
            # c3 is bad, next should be c2
            next_sha = porcelain.bisect_bad(self.repo_path)
            self.assertEqual(next_sha, c2.id)
            # Mark c2 as good - bisect complete
            next_sha = porcelain.bisect_good(self.repo_path)
            self.assertIsNone(next_sha)

    def test_bisect_log(self):
        """Test getting bisect log."""
        # Create some commits
        c1, _c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2]],
            attrs={
                1: {"message": b"initial"},
                2: {"message": b"second"},
                3: {"message": b"third"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"HEAD"] = c3.id

        # Start bisect and mark commits
        porcelain.bisect_start(self.repo_path)
        porcelain.bisect_bad(self.repo_path, c3.id)
        porcelain.bisect_good(self.repo_path, c1.id)

        # Get log
        log = porcelain.bisect_log(self.repo_path)

        self.assertIn("git bisect start", log)
        self.assertIn("git bisect bad", log)
        self.assertIn("git bisect good", log)

    def test_bisect_reset(self):
        """Test resetting bisect state."""
        # Create some commits
        c1, _c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2]],
            attrs={
                1: {"message": b"initial"},
                2: {"message": b"second"},
                3: {"message": b"third"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")

        # Start bisect
        porcelain.bisect_start(self.repo_path)
        porcelain.bisect_bad(self.repo_path)
        porcelain.bisect_good(self.repo_path, c1.id)

        # Reset
        porcelain.bisect_reset(self.repo_path)

        # Check that bisect state files are removed
        self.assertFalse(
            os.path.exists(os.path.join(self.repo.controldir(), "BISECT_START"))
        )
        self.assertFalse(
            os.path.exists(os.path.join(self.repo.controldir(), "refs", "bisect"))
        )

        # HEAD should be back to being a symbolic ref to master
        head_target, _ = self.repo.refs.follow(b"HEAD")
        self.assertEqual(head_target[-1], b"refs/heads/master")

    def test_bisect_skip(self):
        """Test skipping commits during bisect."""
        # Create some commits
        c1, c2, _c3, _c4, c5 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2], [4, 3], [5, 4]],
            attrs={
                1: {"message": b"good"},
                2: {"message": b"skip this"},
                3: {"message": b"bad"},
                4: {"message": b"bad"},
                5: {"message": b"bad"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c5.id
        self.repo.refs[b"HEAD"] = c5.id

        # Start bisect
        porcelain.bisect_start(self.repo_path, bad=c5.id, good=c1.id)

        # Skip c2 if it's selected
        next_sha = porcelain.bisect_skip(self.repo_path, [c2.id])
        self.assertIsNotNone(next_sha)


class ReflogTest(PorcelainTestCase):
    def test_reflog_head(self):
        """Test reading HEAD reflog."""
        # Create a commit
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit = Commit()
        commit.tree = tree.id
        commit.author = b"Test Author <test@example.com>"
        commit.committer = b"Test Author <test@example.com>"
        commit.commit_time = 1234567890
        commit.commit_timezone = 0
        commit.author_time = 1234567890
        commit.author_timezone = 0
        commit.message = b"Initial commit"
        self.repo.object_store.add_object(commit)

        # Write a reflog entry
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1234567890,
            0,
            b"commit (initial): Initial commit",
        )

        # Read reflog using porcelain
        entries = list(porcelain.reflog(self.repo_path))
        self.assertEqual(1, len(entries))
        self.assertEqual(ZERO_SHA, entries[0].old_sha)
        self.assertEqual(commit.id, entries[0].new_sha)
        self.assertEqual(b"Test Author <test@example.com>", entries[0].committer)
        self.assertEqual(1234567890, entries[0].timestamp)
        self.assertEqual(0, entries[0].timezone)
        self.assertEqual(b"commit (initial): Initial commit", entries[0].message)

    def test_reflog_with_string_ref(self):
        """Test reading reflog with string reference."""
        # Create a commit
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit = Commit()
        commit.tree = tree.id
        commit.author = b"Test Author <test@example.com>"
        commit.committer = b"Test Author <test@example.com>"
        commit.commit_time = 1234567890
        commit.commit_timezone = 0
        commit.author_time = 1234567890
        commit.author_timezone = 0
        commit.message = b"Initial commit"
        self.repo.object_store.add_object(commit)

        # Write a reflog entry
        self.repo._write_reflog(
            b"refs/heads/master",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1234567890,
            0,
            b"commit (initial): Initial commit",
        )

        # Read reflog using porcelain with string ref
        entries = list(porcelain.reflog(self.repo_path, "refs/heads/master"))
        self.assertEqual(1, len(entries))
        self.assertEqual(commit.id, entries[0].new_sha)

    def test_reflog_all(self):
        """Test reading all reflogs."""
        # Create a commit
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit = Commit()
        commit.tree = tree.id
        commit.author = b"Test Author <test@example.com>"
        commit.committer = b"Test Author <test@example.com>"
        commit.commit_time = 1234567890
        commit.commit_timezone = 0
        commit.author_time = 1234567890
        commit.author_timezone = 0
        commit.message = b"Initial commit"
        self.repo.object_store.add_object(commit)

        # Write reflog entries for HEAD and master
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1234567890,
            0,
            b"commit (initial): Initial commit",
        )
        self.repo._write_reflog(
            b"refs/heads/master",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1234567891,
            0,
            b"branch: Created from HEAD",
        )

        # Read all reflogs using porcelain
        entries = list(porcelain.reflog(self.repo_path, all=True))

        # Should have at least 2 entries (HEAD and refs/heads/master)
        self.assertGreaterEqual(len(entries), 2)

        # Check that we got entries from different refs
        refs_seen = set()
        for ref, entry in entries:
            refs_seen.add(ref)
            self.assertEqual(commit.id, entry.new_sha)

        # Should have seen at least HEAD and refs/heads/master
        self.assertIn(b"HEAD", refs_seen)
        self.assertIn(b"refs/heads/master", refs_seen)

    def test_reflog_expire_by_time(self):
        """Test expiring reflog entries by timestamp."""
        # Create commits
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit1 = Commit()
        commit1.tree = tree.id
        commit1.author = b"Test Author <test@example.com>"
        commit1.committer = b"Test Author <test@example.com>"
        commit1.commit_time = 1000000000
        commit1.commit_timezone = 0
        commit1.author_time = 1000000000
        commit1.author_timezone = 0
        commit1.message = b"Old commit"
        self.repo.object_store.add_object(commit1)

        commit2 = Commit()
        commit2.tree = tree.id
        commit2.author = b"Test Author <test@example.com>"
        commit2.committer = b"Test Author <test@example.com>"
        commit2.commit_time = 2000000000
        commit2.commit_timezone = 0
        commit2.author_time = 2000000000
        commit2.author_timezone = 0
        commit2.message = b"Recent commit"
        self.repo.object_store.add_object(commit2)

        # Write reflog entries with different timestamps
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit1.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: Old commit",
        )
        self.repo._write_reflog(
            b"HEAD",
            commit1.id,
            commit2.id,
            b"Test Author <test@example.com>",
            2000000000,
            0,
            b"commit: Recent commit",
        )

        # Expire entries older than timestamp 1500000000
        result = porcelain.reflog_expire(
            self.repo_path, ref=b"HEAD", expire_time=1500000000
        )
        self.assertEqual(1, result[b"HEAD"])

        # Check that only the recent entry remains
        entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
        self.assertEqual(1, len(entries))
        self.assertEqual(commit2.id, entries[0].new_sha)

    def test_reflog_expire_all(self):
        """Test expiring reflog entries for all refs."""
        # Create a commit
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit = Commit()
        commit.tree = tree.id
        commit.author = b"Test Author <test@example.com>"
        commit.committer = b"Test Author <test@example.com>"
        commit.commit_time = 1000000000
        commit.commit_timezone = 0
        commit.author_time = 1000000000
        commit.author_timezone = 0
        commit.message = b"Test commit"
        self.repo.object_store.add_object(commit)

        # Write old reflog entries for multiple refs
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: Test commit",
        )
        self.repo._write_reflog(
            b"refs/heads/master",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: Test commit",
        )

        # Expire all old entries
        result = porcelain.reflog_expire(
            self.repo_path, all=True, expire_time=2000000000
        )

        # Should have expired entries from both refs
        self.assertIn(b"HEAD", result)
        self.assertIn(b"refs/heads/master", result)
        self.assertEqual(1, result[b"HEAD"])
        self.assertEqual(1, result[b"refs/heads/master"])

    def test_reflog_expire_dry_run(self):
        """Test dry-run mode for reflog expire."""
        # Create a commit
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit = Commit()
        commit.tree = tree.id
        commit.author = b"Test Author <test@example.com>"
        commit.committer = b"Test Author <test@example.com>"
        commit.commit_time = 1000000000
        commit.commit_timezone = 0
        commit.author_time = 1000000000
        commit.author_timezone = 0
        commit.message = b"Test commit"
        self.repo.object_store.add_object(commit)

        # Write old reflog entry
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: Test commit",
        )

        # Dry run expire
        result = porcelain.reflog_expire(
            self.repo_path, ref=b"HEAD", expire_time=2000000000, dry_run=True
        )
        self.assertEqual(1, result[b"HEAD"])

        # Entry should still exist
        entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
        self.assertEqual(1, len(entries))

    def test_reflog_delete(self):
        """Test deleting specific reflog entry."""
        # Create commits
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        commit1 = Commit()
        commit1.tree = tree.id
        commit1.author = b"Test Author <test@example.com>"
        commit1.committer = b"Test Author <test@example.com>"
        commit1.commit_time = 1000000000
        commit1.commit_timezone = 0
        commit1.author_time = 1000000000
        commit1.author_timezone = 0
        commit1.message = b"First commit"
        self.repo.object_store.add_object(commit1)

        commit2 = Commit()
        commit2.tree = tree.id
        commit2.author = b"Test Author <test@example.com>"
        commit2.committer = b"Test Author <test@example.com>"
        commit2.commit_time = 2000000000
        commit2.commit_timezone = 0
        commit2.author_time = 2000000000
        commit2.author_timezone = 0
        commit2.message = b"Second commit"
        self.repo.object_store.add_object(commit2)

        # Write two reflog entries
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit1.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: First commit",
        )
        self.repo._write_reflog(
            b"HEAD",
            commit1.id,
            commit2.id,
            b"Test Author <test@example.com>",
            2000000000,
            0,
            b"commit: Second commit",
        )

        # Delete the most recent entry (index 0)
        porcelain.reflog_delete(self.repo_path, ref=b"HEAD", index=0)

        # Should only have one entry left
        entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
        self.assertEqual(1, len(entries))
        self.assertEqual(commit1.id, entries[0].new_sha)

    def test_reflog_expire_unreachable(self):
        """Test expiring unreachable reflog entries."""
        # Create commits
        blob = Blob.from_string(b"test content")
        self.repo.object_store.add_object(blob)

        tree = Tree()
        tree.add(b"test", 0o100644, blob.id)
        self.repo.object_store.add_object(tree)

        # Create commit 1 - will be reachable (pointed to by HEAD)
        commit1 = Commit()
        commit1.tree = tree.id
        commit1.author = b"Test Author <test@example.com>"
        commit1.committer = b"Test Author <test@example.com>"
        commit1.commit_time = 1000000000
        commit1.commit_timezone = 0
        commit1.author_time = 1000000000
        commit1.author_timezone = 0
        commit1.message = b"Reachable commit"
        self.repo.object_store.add_object(commit1)

        # Create commit 2 - will be unreachable
        commit2 = Commit()
        commit2.tree = tree.id
        commit2.author = b"Test Author <test@example.com>"
        commit2.committer = b"Test Author <test@example.com>"
        commit2.commit_time = 1500000000
        commit2.commit_timezone = 0
        commit2.author_time = 1500000000
        commit2.author_timezone = 0
        commit2.message = b"Unreachable commit"
        self.repo.object_store.add_object(commit2)

        # Create commit 3 - will also be reachable (pointed to by master)
        commit3 = Commit()
        commit3.tree = tree.id
        commit3.author = b"Test Author <test@example.com>"
        commit3.committer = b"Test Author <test@example.com>"
        commit3.commit_time = 2000000000
        commit3.commit_timezone = 0
        commit3.author_time = 2000000000
        commit3.author_timezone = 0
        commit3.message = b"Another reachable commit"
        self.repo.object_store.add_object(commit3)

        # Set up refs to make commit1 and commit3 reachable
        # HEAD is a symbolic ref to refs/heads/master by default, so we set master first
        self.repo.refs[b"refs/heads/master"] = commit1.id
        # Create another branch pointing to commit3
        self.repo.refs[b"refs/heads/feature"] = commit3.id

        # Write reflog entries for all three commits
        self.repo._write_reflog(
            b"HEAD",
            ZERO_SHA,
            commit1.id,
            b"Test Author <test@example.com>",
            1000000000,
            0,
            b"commit: Reachable commit",
        )
        self.repo._write_reflog(
            b"HEAD",
            commit1.id,
            commit2.id,
            b"Test Author <test@example.com>",
            1500000000,
            0,
            b"commit: Unreachable commit",
        )
        self.repo._write_reflog(
            b"HEAD",
            commit2.id,
            commit3.id,
            b"Test Author <test@example.com>",
            2000000000,
            0,
            b"commit: Another reachable commit",
        )

        # Expire unreachable entries older than a time that includes commit2
        # but not the reachable commits
        result = porcelain.reflog_expire(
            self.repo_path,
            ref=b"HEAD",
            expire_unreachable_time=1600000000,  # After commit2, before commit3
        )

        # Should have expired only commit2
        self.assertEqual(1, result[b"HEAD"])

        # Verify the remaining entries
        entries = list(porcelain.reflog(self.repo_path, b"HEAD"))
        self.assertEqual(2, len(entries))
        # commit1 and commit3 should remain (both reachable)
        self.assertEqual(commit1.id, entries[0].new_sha)
        self.assertEqual(commit3.id, entries[1].new_sha)


class WriteCommitGraphTests(PorcelainTestCase):
    """Tests for the write_commit_graph porcelain function."""

    def test_write_commit_graph_empty_repo(self):
        """Test writing commit graph on empty repository."""
        # Should not fail on empty repo
        porcelain.write_commit_graph(self.repo_path)

        # No commit graph should be created for empty repo
        graph_path = os.path.join(
            self.repo_path, ".git", "objects", "info", "commit-graph"
        )
        self.assertFalse(os.path.exists(graph_path))

    def test_write_commit_graph_with_commits(self):
        """Test writing commit graph with commits."""
        # Create some commits
        c1, c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 1, 2]],
            attrs={
                1: {"message": b"First commit"},
                2: {"message": b"Second commit"},
                3: {"message": b"Merge commit"},
            },
        )
        self.repo.refs[b"refs/heads/master"] = c3.id
        self.repo.refs[b"refs/heads/branch"] = c2.id

        # Write commit graph
        porcelain.write_commit_graph(self.repo_path)

        # Verify commit graph was created
        graph_path = os.path.join(
            self.repo_path, ".git", "objects", "info", "commit-graph"
        )
        self.assertTrue(os.path.exists(graph_path))

        # Load and verify the commit graph
        from dulwich.commit_graph import read_commit_graph

        commit_graph = read_commit_graph(graph_path)
        self.assertIsNotNone(commit_graph)
        self.assertEqual(3, len(commit_graph))

        # Verify all commits are in the graph
        for commit_obj in [c1, c2, c3]:
            entry = commit_graph.get_entry_by_oid(commit_obj.id)
            self.assertIsNotNone(entry)

    def test_write_commit_graph_config_disabled(self):
        """Test that commit graph is not written when disabled by config."""
        # Create a commit
        (c1,) = build_commit_graph(
            self.repo.object_store, [[1]], attrs={1: {"message": b"First commit"}}
        )
        self.repo.refs[b"refs/heads/master"] = c1.id

        # Disable commit graph in config
        config = self.repo.get_config()
        config.set((b"core",), b"commitGraph", b"false")
        config.write_to_path()

        # Write commit graph (should respect config)
        porcelain.write_commit_graph(self.repo_path)

        # Verify commit graph still gets written
        # (porcelain.write_commit_graph explicitly writes regardless of config)
        graph_path = os.path.join(
            self.repo_path, ".git", "objects", "info", "commit-graph"
        )
        self.assertTrue(os.path.exists(graph_path))

    def test_write_commit_graph_reachable_false(self):
        """Test writing commit graph with reachable=False."""
        # Create commits
        _c1, _c2, c3 = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 2]],
            attrs={
                1: {"message": b"First commit"},
                2: {"message": b"Second commit"},
                3: {"message": b"Third commit"},
            },
        )
        # Only reference the third commit
        self.repo.refs[b"refs/heads/master"] = c3.id

        # Write commit graph with reachable=False
        porcelain.write_commit_graph(self.repo_path, reachable=False)

        # Verify commit graph was created
        graph_path = os.path.join(
            self.repo_path, ".git", "objects", "info", "commit-graph"
        )
        self.assertTrue(os.path.exists(graph_path))

        # Load and verify the commit graph
        from dulwich.commit_graph import read_commit_graph

        commit_graph = read_commit_graph(graph_path)
        self.assertIsNotNone(commit_graph)

        # With reachable=False, only directly referenced commits should be included
        # In this case, only c3 (from refs/heads/master)
        self.assertEqual(1, len(commit_graph))
        entry = commit_graph.get_entry_by_oid(c3.id)
        self.assertIsNotNone(entry)


class WorktreePorcelainTests(PorcelainTestCase):
    """Tests for porcelain worktree functions."""

    def test_worktree_list_single(self):
        """Test listing worktrees when only main worktree exists."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # List worktrees
        worktrees = porcelain.worktree_list(self.repo_path)

        self.assertEqual(len(worktrees), 1)
        self.assertEqual(worktrees[0].path, self.repo_path)
        self.assertFalse(worktrees[0].bare)
        self.assertIsNotNone(worktrees[0].head)
        self.assertIsNotNone(worktrees[0].branch)

    def test_worktree_add_branch(self):
        """Test adding a worktree with a new branch."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add worktree
        wt_path = os.path.join(self.test_dir, "worktree1")
        result_path = porcelain.worktree_add(self.repo_path, wt_path, branch=b"feature")

        self.assertEqual(result_path, wt_path)
        self.assertTrue(os.path.exists(wt_path))
        self.assertTrue(os.path.exists(os.path.join(wt_path, ".git")))

        # Check it appears in the list
        worktrees = porcelain.worktree_list(self.repo_path)
        self.assertEqual(len(worktrees), 2)

        # Find the new worktree
        new_wt = None
        for wt in worktrees:
            if wt.path == wt_path:
                new_wt = wt
                break

        self.assertIsNotNone(new_wt)
        self.assertEqual(new_wt.branch, b"refs/heads/feature")
        self.assertFalse(new_wt.detached)

    def test_worktree_add_detached(self):
        """Test adding a detached worktree."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        sha = porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add detached worktree
        wt_path = os.path.join(self.test_dir, "detached")
        porcelain.worktree_add(self.repo_path, wt_path, commit=sha, detach=True)

        # Check it's detached
        worktrees = porcelain.worktree_list(self.repo_path)
        for wt in worktrees:
            if wt.path == wt_path:
                self.assertTrue(wt.detached)
                self.assertIsNone(wt.branch)
                self.assertEqual(wt.head, sha)

    def test_worktree_remove(self):
        """Test removing a worktree."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add and remove worktree
        wt_path = os.path.join(self.test_dir, "to-remove")
        porcelain.worktree_add(self.repo_path, wt_path)

        # Verify it exists
        self.assertTrue(os.path.exists(wt_path))
        self.assertEqual(len(porcelain.worktree_list(self.repo_path)), 2)

        # Remove it
        porcelain.worktree_remove(self.repo_path, wt_path)

        # Verify it's gone
        self.assertFalse(os.path.exists(wt_path))
        self.assertEqual(len(porcelain.worktree_list(self.repo_path)), 1)

    def test_worktree_prune(self):
        """Test pruning worktrees."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add worktree
        wt_path = os.path.join(self.test_dir, "to-prune")
        porcelain.worktree_add(self.repo_path, wt_path)

        # Manually remove directory
        shutil.rmtree(wt_path)

        # Prune should remove the administrative files
        pruned = porcelain.worktree_prune(self.repo_path)
        self.assertEqual(len(pruned), 1)

        # Verify it's gone from the list
        worktrees = porcelain.worktree_list(self.repo_path)
        self.assertEqual(len(worktrees), 1)

    def test_worktree_lock_unlock(self):
        """Test locking and unlocking worktrees."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add worktree
        wt_path = os.path.join(self.test_dir, "lockable")
        porcelain.worktree_add(self.repo_path, wt_path)

        # Lock it
        porcelain.worktree_lock(self.repo_path, wt_path, reason="Testing")

        # Verify it's locked
        worktrees = porcelain.worktree_list(self.repo_path)
        for wt in worktrees:
            if wt.path == wt_path:
                self.assertTrue(wt.locked)

        # Unlock it
        porcelain.worktree_unlock(self.repo_path, wt_path)

        # Verify it's unlocked
        worktrees = porcelain.worktree_list(self.repo_path)
        for wt in worktrees:
            if wt.path == wt_path:
                self.assertFalse(wt.locked)

    def test_worktree_move(self):
        """Test moving a worktree."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test content")
        porcelain.add(self.repo_path, ["test.txt"])
        porcelain.commit(self.repo_path, message=b"Initial commit")

        # Add worktree
        old_path = os.path.join(self.test_dir, "old-location")
        porcelain.worktree_add(self.repo_path, old_path)

        # Create a file in the worktree
        test_file = os.path.join(old_path, "workspace.txt")
        with open(test_file, "w") as f:
            f.write("workspace content")

        # Move it
        new_path = os.path.join(self.test_dir, "new-location")
        porcelain.worktree_move(self.repo_path, old_path, new_path)

        # Verify old path doesn't exist, new path does
        self.assertFalse(os.path.exists(old_path))
        self.assertTrue(os.path.exists(new_path))
        self.assertTrue(os.path.exists(os.path.join(new_path, "workspace.txt")))

        # Verify it's in the list at new location
        worktrees = porcelain.worktree_list(self.repo_path)
        paths = [wt.path for wt in worktrees]
        self.assertIn(new_path, paths)
        self.assertNotIn(old_path, paths)


class VarTests(PorcelainTestCase):
    """Tests for the var command."""

    def test_var_author_ident(self):
        """Test getting GIT_AUTHOR_IDENT."""
        # Set up user config
        config = self.repo.get_config()
        config.set((b"user",), b"name", b"Test Author")
        config.set((b"user",), b"email", b"author@example.com")
        config.write_to_path()

        result = porcelain.var(self.repo_path, variable="GIT_AUTHOR_IDENT")
        self.assertIn("Test Author <author@example.com>", result)
        # Check that timestamp and timezone are included
        # Format: Name <email> timestamp timezone
        # "Test Author" is 2 words, so we have: Test, Author, <email>, timestamp, timezone
        parts = result.split()
        self.assertGreaterEqual(
            len(parts), 4
        )  # At least name, <email>, timestamp, timezone
        # Check last two parts are timestamp and timezone
        self.assertTrue(parts[-2].isdigit())  # timestamp
        self.assertRegex(parts[-1], r"[+-]\d{4}")  # timezone

    def test_var_committer_ident(self):
        """Test getting GIT_COMMITTER_IDENT."""
        # Set up user config
        config = self.repo.get_config()
        config.set((b"user",), b"name", b"Test Committer")
        config.set((b"user",), b"email", b"committer@example.com")
        config.write_to_path()

        result = porcelain.var(self.repo_path, variable="GIT_COMMITTER_IDENT")
        self.assertIn("Test Committer <committer@example.com>", result)
        # Check that timestamp and timezone are included
        parts = result.split()
        self.assertGreaterEqual(
            len(parts), 4
        )  # At least name, <email>, timestamp, timezone
        # Check last two parts are timestamp and timezone
        self.assertTrue(parts[-2].isdigit())  # timestamp
        self.assertRegex(parts[-1], r"[+-]\d{4}")  # timezone

    def test_var_editor(self):
        """Test getting GIT_EDITOR."""
        # Test with environment variable
        self.overrideEnv("GIT_EDITOR", "vim")
        result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
        self.assertEqual(result, "vim")

    def test_var_editor_from_config(self):
        """Test getting GIT_EDITOR from config."""
        # Set up editor in config
        config = self.repo.get_config()
        config.set((b"core",), b"editor", b"emacs")
        config.write_to_path()

        # Make sure env var is not set
        self.overrideEnv("GIT_EDITOR", None)
        result = porcelain.var(self.repo_path, variable="GIT_EDITOR")
        self.assertEqual(result, "emacs")

    def test_var_pager(self):
        """Test getting GIT_PAGER."""
        # Test with environment variable
        self.overrideEnv("GIT_PAGER", "less")
        result = porcelain.var(self.repo_path, variable="GIT_PAGER")
        self.assertEqual(result, "less")

    def test_var_pager_from_config(self):
        """Test getting GIT_PAGER from config."""
        # Set up pager in config
        config = self.repo.get_config()
        config.set((b"core",), b"pager", b"more")
        config.write_to_path()

        # Make sure env var is not set
        self.overrideEnv("GIT_PAGER", None)
        result = porcelain.var(self.repo_path, variable="GIT_PAGER")
        self.assertEqual(result, "more")

    def test_var_default_branch(self):
        """Test getting GIT_DEFAULT_BRANCH."""
        # Set up default branch in config
        config = self.repo.get_config()
        config.set((b"init",), b"defaultBranch", b"main")
        config.write_to_path()

        result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
        self.assertEqual(result, "main")

    def test_var_default_branch_default(self):
        """Test getting GIT_DEFAULT_BRANCH with default value."""
        result = porcelain.var(self.repo_path, variable="GIT_DEFAULT_BRANCH")
        self.assertEqual(result, "master")

    def test_var_list_all(self):
        """Test listing all logical variables."""
        # Set up some config
        config = self.repo.get_config()
        config.set((b"user",), b"name", b"Test User")
        config.set((b"user",), b"email", b"test@example.com")
        config.write_to_path()

        result = porcelain.var_list(self.repo_path)
        self.assertIsInstance(result, dict)
        # Check that logical variables are present
        self.assertIn("GIT_AUTHOR_IDENT", result)
        self.assertIn("GIT_COMMITTER_IDENT", result)
        self.assertIn("GIT_DEFAULT_BRANCH", result)
        # Config variables should NOT be included (deprecated feature)
        self.assertNotIn("user.name", result)
        self.assertNotIn("user.email", result)
        # Verify only logical variables are present
        for key in result.keys():
            self.assertTrue(key.startswith("GIT_"))

    def test_var_unknown_variable(self):
        """Test requesting an unknown variable."""
        with self.assertRaises(KeyError):
            porcelain.var(self.repo_path, variable="UNKNOWN_VARIABLE")


class MergeBaseTests(PorcelainTestCase):
    """Tests for merge-base, is_ancestor, and independent_commits."""

    def test_merge_base_linear_history(self):
        """Test merge-base with linear history."""
        # Create linear history: c1 <- c2 <- c3
        _c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
        self.repo.refs[b"refs/heads/branch1"] = c2.id
        self.repo.refs[b"refs/heads/branch2"] = c3.id

        result = porcelain.merge_base(
            self.repo.path, committishes=["refs/heads/branch1", "refs/heads/branch2"]
        )
        self.assertEqual([c2.id], result)

    def test_merge_base_diverged(self):
        """Test merge-base with diverged branches."""
        # Create diverged history: c1 <- c2a, c1 <- c2b
        c1, c2a, c2b = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 1]])
        self.repo.refs[b"refs/heads/branch-a"] = c2a.id
        self.repo.refs[b"refs/heads/branch-b"] = c2b.id

        result = porcelain.merge_base(
            self.repo.path, committishes=["refs/heads/branch-a", "refs/heads/branch-b"]
        )
        self.assertEqual([c1.id], result)

    def test_merge_base_all(self):
        """Test merge-base with --all flag."""
        # Create history with multiple common ancestors
        commits = build_commit_graph(
            self.repo.object_store,
            [[1], [2, 1], [3, 1], [4, 2, 3], [5, 2, 3]],
        )
        _c1, c2, c3, c4, c5 = commits
        self.repo.refs[b"refs/heads/branch1"] = c4.id
        self.repo.refs[b"refs/heads/branch2"] = c5.id

        # Without --all, should return only first result
        result = porcelain.merge_base(
            self.repo.path,
            committishes=["refs/heads/branch1", "refs/heads/branch2"],
            all=False,
        )
        self.assertEqual(1, len(result))

        # With --all, should return all merge bases
        result_all = porcelain.merge_base(
            self.repo.path,
            committishes=["refs/heads/branch1", "refs/heads/branch2"],
            all=True,
        )
        self.assertEqual(2, len(result_all))
        self.assertIn(c2.id, result_all)
        self.assertIn(c3.id, result_all)

    def test_merge_base_octopus(self):
        """Test merge-base with --octopus flag."""
        # Create three-way diverged history
        commits = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1], [4, 1]]
        )
        c1, c2, c3, c4 = commits
        self.repo.refs[b"refs/heads/a"] = c2.id
        self.repo.refs[b"refs/heads/b"] = c3.id
        self.repo.refs[b"refs/heads/c"] = c4.id

        result = porcelain.merge_base(
            self.repo.path,
            committishes=["refs/heads/a", "refs/heads/b", "refs/heads/c"],
            octopus=True,
        )
        self.assertEqual([c1.id], result)

    def test_merge_base_requires_two_commits(self):
        """Test merge-base requires at least two commits."""
        with self.assertRaises(ValueError):
            porcelain.merge_base(self.repo.path, committishes=["HEAD"])

    def test_is_ancestor_true(self):
        """Test is_ancestor returns True when commit is an ancestor."""
        # Create linear history: c1 <- c2 <- c3
        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
        self.repo.refs[b"refs/heads/main"] = c3.id

        # c1 is ancestor of c3
        result = porcelain.is_ancestor(
            self.repo.path, ancestor=c1.id.decode(), descendant=c3.id.decode()
        )
        self.assertTrue(result)

        # c2 is ancestor of c3
        result = porcelain.is_ancestor(
            self.repo.path, ancestor=c2.id.decode(), descendant=c3.id.decode()
        )
        self.assertTrue(result)

    def test_is_ancestor_false(self):
        """Test is_ancestor returns False when commit is not an ancestor."""
        # Create diverged history: c1 <- c2a, c1 <- c2b
        _c1, c2a, c2b = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1]]
        )

        # c2a is not ancestor of c2b
        result = porcelain.is_ancestor(
            self.repo.path, ancestor=c2a.id.decode(), descendant=c2b.id.decode()
        )
        self.assertFalse(result)

        # c2b is not ancestor of c2a
        result = porcelain.is_ancestor(
            self.repo.path, ancestor=c2b.id.decode(), descendant=c2a.id.decode()
        )
        self.assertFalse(result)

    def test_is_ancestor_requires_both(self):
        """Test is_ancestor requires both ancestor and descendant."""
        with self.assertRaises(ValueError):
            porcelain.is_ancestor(self.repo.path, ancestor="HEAD", descendant=None)
        with self.assertRaises(ValueError):
            porcelain.is_ancestor(self.repo.path, ancestor=None, descendant="HEAD")

    def test_independent_commits_linear(self):
        """Test independent_commits with linear history."""
        # Create linear history: c1 <- c2 <- c3
        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])

        # Only c3 is independent (c1 and c2 are ancestors)
        result = porcelain.independent_commits(
            self.repo.path,
            committishes=[c1.id.decode(), c2.id.decode(), c3.id.decode()],
        )
        self.assertEqual([c3.id], result)

    def test_independent_commits_diverged(self):
        """Test independent_commits with diverged branches."""
        # Create diverged history: c1 <- c2a, c1 <- c2b
        _c1, c2a, c2b = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1]]
        )

        # c2a and c2b are both independent (neither is ancestor of the other)
        result = porcelain.independent_commits(
            self.repo.path, committishes=[c2a.id.decode(), c2b.id.decode()]
        )
        self.assertEqual(2, len(result))
        self.assertIn(c2a.id, result)
        self.assertIn(c2b.id, result)

    def test_independent_commits_mixed(self):
        """Test independent_commits with mixed history."""
        # Create mixed history
        commits = build_commit_graph(
            self.repo.object_store, [[1], [2, 1], [3, 1], [4, 2]]
        )
        _c1, c2, c3, c4 = commits

        # c4 and c3 are independent; c2 is ancestor of c4
        result = porcelain.independent_commits(
            self.repo.path,
            committishes=[c2.id.decode(), c3.id.decode(), c4.id.decode()],
        )
        self.assertEqual(2, len(result))
        self.assertIn(c3.id, result)
        self.assertIn(c4.id, result)
        self.assertNotIn(c2.id, result)

    def test_independent_commits_empty(self):
        """Test independent_commits with empty list."""
        result = porcelain.independent_commits(self.repo.path, committishes=[])
        self.assertEqual([], result)


class CherryTests(PorcelainTestCase):
    """Tests for cherry command."""

    def test_cherry_no_changes(self):
        """Test cherry when head and upstream are the same."""
        # Create a simple commit
        commit_sha = self.repo.get_worktree().commit(
            b"Initial commit", committer=b"Test <test@example.com>"
        )

        # Cherry should return empty when comparing a commit to itself
        results = porcelain.cherry(
            self.repo.path, upstream=commit_sha.decode(), head=commit_sha.decode()
        )
        self.assertEqual([], results)

    def test_cherry_unique_commits(self):
        """Test cherry with commits unique to head."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
            f.write("base content\n")
        self.repo.get_worktree().stage(["file1.txt"])
        base_commit = self.repo.get_worktree().commit(
            b"Base commit", committer=b"Test <test@example.com>"
        )

        # Create a new commit on head
        with open(os.path.join(self.repo_path, "file2.txt"), "w") as f:
            f.write("new content\n")
        self.repo.get_worktree().stage(["file2.txt"])
        head_commit = self.repo.get_worktree().commit(
            b"New commit", committer=b"Test <test@example.com>"
        )

        # Cherry should show the new commit as unique
        results = porcelain.cherry(
            self.repo.path, upstream=base_commit.decode(), head=head_commit.decode()
        )
        self.assertEqual(1, len(results))
        status, commit_sha, message = results[0]
        self.assertEqual("+", status)
        self.assertEqual(head_commit, commit_sha)
        self.assertIsNone(message)

    def test_cherry_verbose(self):
        """Test cherry with verbose flag."""
        # Create initial commit
        with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
            f.write("base content\n")
        self.repo.get_worktree().stage(["file1.txt"])
        base_commit = self.repo.get_worktree().commit(
            b"Base commit", committer=b"Test <test@example.com>"
        )

        # Create a new commit on head
        with open(os.path.join(self.repo_path, "file2.txt"), "w") as f:
            f.write("new content\n")
        self.repo.get_worktree().stage(["file2.txt"])
        head_commit = self.repo.get_worktree().commit(
            b"New commit on head", committer=b"Test <test@example.com>"
        )

        # Cherry with verbose should include commit message
        results = porcelain.cherry(
            self.repo.path,
            upstream=base_commit.decode(),
            head=head_commit.decode(),
            verbose=True,
        )
        self.assertEqual(1, len(results))
        status, commit_sha, message = results[0]
        self.assertEqual("+", status)
        self.assertEqual(head_commit, commit_sha)
        self.assertEqual(b"New commit on head", message)

    def test_cherry_equivalent_patches(self):
        """Test cherry with equivalent patches (cherry-picked commits)."""
        # Create base commit
        with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
            f.write("line1\n")
        self.repo.get_worktree().stage(["file.txt"])
        base_commit = self.repo.get_worktree().commit(
            b"Base commit", committer=b"Test <test@example.com>"
        )

        # Create upstream branch with a change
        with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
            f.write("line1\nline2\n")
        self.repo.get_worktree().stage(["file.txt"])
        upstream_commit = self.repo.get_worktree().commit(
            b"Add line2", committer=b"Test <test@example.com>"
        )

        # Reset to base and create same change on head branch
        self.repo.refs[b"HEAD"] = base_commit
        self.repo.get_worktree().reset_index()
        with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
            f.write("line1\nline2\n")
        self.repo.get_worktree().stage(["file.txt"])
        head_commit = self.repo.get_worktree().commit(
            b"Add line2 (different metadata)",
            committer=b"Different <different@example.com>",
        )

        # Cherry should mark this as equivalent (-)
        results = porcelain.cherry(
            self.repo.path,
            upstream=upstream_commit.decode(),
            head=head_commit.decode(),
        )
        self.assertEqual(1, len(results))
        status, commit_sha, _message = results[0]
        self.assertEqual("-", status)
        self.assertEqual(head_commit, commit_sha)


class GrepTests(PorcelainTestCase):
    def test_basic_grep(self) -> None:
        """Test basic pattern matching in files."""
        # Create some test files
        with open(os.path.join(self.repo_path, "foo.txt"), "w") as f:
            f.write("hello world\ngoodbye world\n")
        with open(os.path.join(self.repo_path, "bar.txt"), "w") as f:
            f.write("foo bar\nbaz qux\n")

        porcelain.add(self.repo, paths=["foo.txt", "bar.txt"])
        porcelain.commit(self.repo, message=b"Add test files")

        # Search for "world"
        outstream = StringIO()
        porcelain.grep(self.repo, "world", outstream=outstream)
        output = outstream.getvalue().replace("\r\n", "\n")

        self.assertEqual("foo.txt:hello world\nfoo.txt:goodbye world\n", output)

    def test_grep_with_line_numbers(self) -> None:
        """Test grep with line numbers."""
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("line one\nline two\nline three\n")

        porcelain.add(self.repo, paths=["test.txt"])
        porcelain.commit(self.repo, message=b"Add test file")

        outstream = StringIO()
        porcelain.grep(self.repo, "line", outstream=outstream, line_number=True)
        output = outstream.getvalue().replace("\r\n", "\n")

        self.assertEqual(
            "test.txt:1:line one\ntest.txt:2:line two\ntest.txt:3:line three\n",
            output,
        )

    def test_grep_case_insensitive(self) -> None:
        """Test case-insensitive grep."""
        with open(os.path.join(self.repo_path, "case.txt"), "w") as f:
            f.write("Hello WORLD\nGoodbye world\n")

        porcelain.add(self.repo, paths=["case.txt"])
        porcelain.commit(self.repo, message=b"Add case file")

        outstream = StringIO()
        porcelain.grep(self.repo, "HELLO", outstream=outstream, ignore_case=True)
        output = outstream.getvalue().replace("\r\n", "\n")

        self.assertEqual("case.txt:Hello WORLD\n", output)

    def test_grep_with_pathspec(self) -> None:
        """Test grep with pathspec filtering."""
        os.makedirs(os.path.join(self.repo_path, "subdir"))
        with open(os.path.join(self.repo_path, "file1.txt"), "w") as f:
            f.write("pattern match\n")
        with open(os.path.join(self.repo_path, "subdir", "file2.txt"), "w") as f:
            f.write("pattern match\n")

        porcelain.add(self.repo, paths=["file1.txt", "subdir/file2.txt"])
        porcelain.commit(self.repo, message=b"Add files")

        # Search only in subdir
        outstream = StringIO()
        porcelain.grep(self.repo, "pattern", outstream=outstream, pathspecs=["subdir/"])
        output = outstream.getvalue().replace("\r\n", "\n")

        self.assertEqual("subdir/file2.txt:pattern match\n", output)

    def test_grep_no_matches(self) -> None:
        """Test grep with no matches."""
        with open(os.path.join(self.repo_path, "empty.txt"), "w") as f:
            f.write("nothing to see here\n")

        porcelain.add(self.repo, paths=["empty.txt"])
        porcelain.commit(self.repo, message=b"Add empty file")

        outstream = StringIO()
        porcelain.grep(self.repo, "nonexistent", outstream=outstream)
        output = outstream.getvalue()

        self.assertEqual("", output)

    def test_grep_regex_pattern(self) -> None:
        """Test grep with regex patterns."""
        with open(os.path.join(self.repo_path, "regex.txt"), "w") as f:
            f.write("test123\ntest456\nnotest\n")

        porcelain.add(self.repo, paths=["regex.txt"])
        porcelain.commit(self.repo, message=b"Add regex file")

        # Search for "test" followed by digits
        outstream = StringIO()
        porcelain.grep(self.repo, r"test\d+", outstream=outstream)
        output = outstream.getvalue().replace("\r\n", "\n")

        self.assertEqual("regex.txt:test123\nregex.txt:test456\n", output)

    def test_grep_invalid_pattern(self) -> None:
        """Test grep with invalid regex pattern."""
        with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
            f.write("test\n")

        porcelain.add(self.repo, paths=["test.txt"])
        porcelain.commit(self.repo, message=b"Add test file")

        outstream = StringIO()
        with self.assertRaises(ValueError):
            porcelain.grep(self.repo, "[invalid", outstream=outstream)

    def test_grep_no_head(self) -> None:
        """Test grep fails when there's no HEAD commit."""
        # Create a fresh repo with no commits
        empty_repo_path = os.path.join(self.test_dir, "empty_repo")
        empty_repo = Repo.init(empty_repo_path, mkdir=True)
        self.addCleanup(empty_repo.close)

        outstream = StringIO()
        with self.assertRaises(ValueError):
            porcelain.grep(empty_repo, "pattern", outstream=outstream)


class ReplaceListTests(PorcelainTestCase):
    def test_empty(self) -> None:
        """Test listing replacements when there are none."""
        replacements = porcelain.replace_list(self.repo)
        self.assertEqual([], replacements)

    def test_list_replacements(self) -> None:
        """Test listing replacement refs."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo[b"HEAD"] = c1.id

        # Create a replacement
        porcelain.replace_create(self.repo, c1.id, c2.id)

        # List replacements
        replacements = porcelain.replace_list(self.repo)
        self.assertEqual(1, len(replacements))
        self.assertEqual((c1.id, c2.id), replacements[0])


class ReplaceCreateTests(PorcelainTestCase):
    def test_create_replacement(self) -> None:
        """Test creating a replacement ref."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo[b"HEAD"] = c1.id

        # Create a replacement
        porcelain.replace_create(self.repo, c1.id, c2.id)

        # Verify the replacement ref was created (c1.id is already 40-char hex bytes)
        replace_ref = b"refs/replace/" + c1.id
        self.assertIn(replace_ref, self.repo.refs)
        self.assertEqual(c2.id, self.repo.refs[replace_ref])

    def test_create_replacement_with_bytes(self) -> None:
        """Test creating a replacement ref with bytes arguments."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo[b"HEAD"] = c1.id

        # Create a replacement using bytes arguments
        porcelain.replace_create(self.repo, c1.id, c2.id)

        # Verify the replacement ref was created
        replace_ref = b"refs/replace/" + c1.id
        self.assertIn(replace_ref, self.repo.refs)
        self.assertEqual(c2.id, self.repo.refs[replace_ref])


class ReplaceDeleteTests(PorcelainTestCase):
    def test_delete_replacement(self) -> None:
        """Test deleting a replacement ref."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo[b"HEAD"] = c1.id

        # Create a replacement
        porcelain.replace_create(self.repo, c1.id, c2.id)

        # Verify it exists
        replacements = porcelain.replace_list(self.repo)
        self.assertEqual(1, len(replacements))

        # Delete the replacement
        porcelain.replace_delete(self.repo, c1.id)

        # Verify it's gone
        replacements = porcelain.replace_list(self.repo)
        self.assertEqual(0, len(replacements))

    def test_delete_replacement_with_bytes(self) -> None:
        """Test deleting a replacement ref with bytes argument."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo[b"HEAD"] = c1.id

        # Create a replacement
        porcelain.replace_create(self.repo, c1.id, c2.id)

        # Delete using bytes argument
        porcelain.replace_delete(self.repo, c1.id)

        # Verify it's gone
        replacements = porcelain.replace_list(self.repo)
        self.assertEqual(0, len(replacements))

    def test_delete_nonexistent_replacement(self) -> None:
        """Test deleting a replacement ref that doesn't exist raises KeyError."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo[b"HEAD"] = c1.id

        # Try to delete a non-existent replacement
        with self.assertRaises(KeyError):
            porcelain.replace_delete(self.repo, c1.id)


class GitReflogActionTests(PorcelainTestCase):
    """Tests for GIT_REFLOG_ACTION environment variable support."""

    def test_reset_with_git_reflog_action(self) -> None:
        """Test that reset respects GIT_REFLOG_ACTION environment variable."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo.refs[b"HEAD"] = c2.id

        # Set GIT_REFLOG_ACTION environment variable
        self.overrideEnv("GIT_REFLOG_ACTION", "custom reset action")

        # Reset to c1
        porcelain.reset(self.repo, "hard", c1.id.decode())

        # Check reflog message - HEAD is a symref to refs/heads/master
        entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
        self.assertEqual(1, len(entries))
        self.assertEqual(b"custom reset action", entries[0].message)

    def test_commit_amend_with_git_reflog_action(self) -> None:
        """Test that commit --amend respects GIT_REFLOG_ACTION environment variable."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id

        # Set GIT_REFLOG_ACTION environment variable
        self.overrideEnv("GIT_REFLOG_ACTION", "custom amend action")

        # Amend the commit
        porcelain.commit(self.repo, b"amended message", amend=True)

        # Check reflog message - HEAD is a symref to refs/heads/master
        entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
        self.assertEqual(1, len(entries))
        self.assertEqual(b"custom amend action", entries[0].message)

    def test_branch_create_with_git_reflog_action(self) -> None:
        """Test that branch_create respects GIT_REFLOG_ACTION environment variable."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id

        # Set GIT_REFLOG_ACTION environment variable
        self.overrideEnv("GIT_REFLOG_ACTION", "custom branch action")

        # Create a new branch
        porcelain.branch_create(self.repo, b"test-branch")

        # Check reflog message
        entries = list(porcelain.reflog(self.repo_path, b"refs/heads/test-branch"))
        self.assertEqual(1, len(entries))
        self.assertEqual(b"custom branch action", entries[0].message)

    def test_reset_without_git_reflog_action(self) -> None:
        """Test that reset uses default message when GIT_REFLOG_ACTION is not set."""
        [c1, c2] = build_commit_graph(self.repo.object_store, [[1], [2]])
        self.repo.refs[b"HEAD"] = c2.id

        # Reset to c1 without GIT_REFLOG_ACTION
        porcelain.reset(self.repo, "hard", c1.id.decode())

        # Check reflog message contains default format - HEAD is a symref to refs/heads/master
        entries = list(porcelain.reflog(self.repo_path, b"refs/heads/master"))
        self.assertEqual(1, len(entries))
        self.assertTrue(entries[0].message.startswith(b"reset: moving to"))

    def test_branch_create_without_git_reflog_action(self) -> None:
        """Test that branch_create uses default message when GIT_REFLOG_ACTION is not set."""
        [c1] = build_commit_graph(self.repo.object_store, [[1]])
        self.repo.refs[b"HEAD"] = c1.id

        # Create a new branch without GIT_REFLOG_ACTION
        porcelain.branch_create(self.repo, b"test-branch")

        # Check reflog message contains default format
        entries = list(porcelain.reflog(self.repo_path, b"refs/heads/test-branch"))
        self.assertEqual(1, len(entries))
        self.assertTrue(entries[0].message.startswith(b"branch: Created from"))


class PorcelainMailinfoTests(TestCase):
    """Tests for porcelain.mailinfo function."""

    def test_mailinfo_basic(self) -> None:
        """Test basic mailinfo functionality."""
        from io import BytesIO

        email_content = b"""From: Test User <test@example.com>
Subject: [PATCH] Add feature
Date: Mon, 1 Jan 2024 12:00:00 +0000

Commit message here.

---
diff --git a/file.txt b/file.txt
"""
        result = porcelain.mailinfo(BytesIO(email_content))
        self.assertEqual("Test User", result.author_name)
        self.assertEqual("test@example.com", result.author_email)
        self.assertEqual("Add feature", result.subject)
        self.assertIn("Commit message here.", result.message)
        self.assertIn("diff --git", result.patch)

    def test_mailinfo_write_to_files(self) -> None:
        """Test mailinfo writing message and patch to files."""
        from io import BytesIO

        email_content = b"""From: Test <test@example.com>
Subject: Test

Message content

---
Patch content
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            msg_file = os.path.join(tmpdir, "msg")
            patch_file = os.path.join(tmpdir, "patch")

            result = porcelain.mailinfo(
                BytesIO(email_content), msg_file=msg_file, patch_file=patch_file
            )

            # Check that files were written
            self.assertTrue(os.path.exists(msg_file))
            self.assertTrue(os.path.exists(patch_file))

            # Check file contents
            with open(msg_file) as f:
                msg_content = f.read()
                self.assertIn("Message content", msg_content)

            with open(patch_file) as f:
                patch_content = f.read()
                self.assertIn("Patch content", patch_content)

            # Check return value
            self.assertEqual("Test", result.subject)

    def test_mailinfo_with_options(self) -> None:
        """Test mailinfo with various options."""
        from io import BytesIO

        email_content = b"""From: Test <test@example.com>
Subject: [RFC][PATCH] Feature
Message-ID: <id@example.com>

Text before scissors

-- >8 --

Text after scissors
"""
        # Test with keep_non_patch, scissors, and message_id
        result = porcelain.mailinfo(
            BytesIO(email_content),
            keep_non_patch=True,
            scissors=True,
            message_id=True,
        )

        self.assertEqual("[RFC] Feature", result.subject)
        self.assertIn("Text after scissors", result.message)
        self.assertNotIn("Text before scissors", result.message)
        self.assertIn("Message-ID:", result.message)

    def test_mailinfo_from_file_path(self) -> None:
        """Test mailinfo reading from file path."""
        email_content = b"""From: Test <test@example.com>
Subject: Test

Body
"""
        with tempfile.TemporaryDirectory() as tmpdir:
            email_path = os.path.join(tmpdir, "email.txt")
            with open(email_path, "wb") as f:
                f.write(email_content)

            result = porcelain.mailinfo(input_path=email_path)
            self.assertEqual("Test", result.subject)
            self.assertEqual("test@example.com", result.author_email)


class ApplyPatchTests(PorcelainTestCase):
    def test_apply_simple_modification(self) -> None:
        """Test applying a simple patch to the working tree."""
        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # Create a patch
        patch_content = b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
"""
        patch_file = os.path.join(self.repo_path, "change.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline two\nline 3\n")

        # The index entry must keep a valid file mode, otherwise native git
        # aborts with "unsupported ce_mode: 0" (see issue #2218).
        index = self.repo.open_index()
        self.assertEqual(index[b"test.txt"].mode, 0o100644)

    def test_apply_new_file(self) -> None:
        """Test applying a patch that creates a new file."""
        # Create an initial commit with a dummy file
        dummy_path = os.path.join(self.repo_path, "dummy.txt")
        with open(dummy_path, "wb") as f:
            f.write(b"dummy\n")
        porcelain.add(self.repo_path, paths=["dummy.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        patch_content = b"""\
diff --git a/newfile.txt b/newfile.txt
new file mode 100644
index 0000000..1234567
--- /dev/null
+++ b/newfile.txt
@@ -0,0 +1,2 @@
+new line 1
+new line 2
"""
        patch_file = os.path.join(self.repo_path, "new.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        new_file_path = os.path.join(self.repo_path, "newfile.txt")
        self.assertTrue(os.path.exists(new_file_path))
        with open(new_file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"new line 1\nnew line 2\n")

    def test_apply_check_only(self) -> None:
        """Test checking if a patch can be applied without applying it."""
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        patch_content = b"""\
diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
"""
        patch_file = os.path.join(self.repo_path, "change.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file, check=True)

        # File should not be modified
        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline 2\nline 3\n")

    def test_apply_reverse(self) -> None:
        """Test applying a patch in reverse."""
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline two\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # Patch that changes "line 2" to "line two" - apply in reverse
        patch_content = b"""\
diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
"""
        patch_file = os.path.join(self.repo_path, "change.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file, reverse=True)

        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline 2\nline 3\n")

    def test_apply_from_file_object(self) -> None:
        """Test applying a patch from a file-like object."""
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"hello\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        patch_content = b"""\
diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1 +1 @@
-hello
+goodbye
"""

        porcelain.apply_patch(self.repo_path, patch_file=BytesIO(patch_content))

        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"goodbye\n")

    def test_apply_rename(self) -> None:
        """Test applying a patch that renames a file."""
        # Create a file and commit it
        old_file = os.path.join(self.repo_path, "old.txt")
        with open(old_file, "wb") as f:
            f.write(b"content\n")
        porcelain.add(self.repo_path, paths=["old.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # Create a rename patch
        patch_content = b"""\
diff --git a/old.txt b/new.txt
similarity index 100%
rename from old.txt
rename to new.txt
"""
        patch_file = os.path.join(self.repo_path, "rename.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        # Old file should be gone, new file should exist
        self.assertFalse(os.path.exists(old_file))
        new_file = os.path.join(self.repo_path, "new.txt")
        self.assertTrue(os.path.exists(new_file))
        with open(new_file, "rb") as f:
            self.assertEqual(f.read(), b"content\n")

    def test_apply_rename_with_modification(self) -> None:
        """Test applying a patch that renames and modifies a file."""
        old_file = os.path.join(self.repo_path, "old.txt")
        with open(old_file, "wb") as f:
            f.write(b"line 1\nline 2\n")
        porcelain.add(self.repo_path, paths=["old.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        patch_content = b"""\
diff --git a/old.txt b/new.txt
similarity index 90%
rename from old.txt
rename to new.txt
--- a/old.txt
+++ b/new.txt
@@ -1,2 +1,2 @@
 line 1
-line 2
+modified line 2
"""
        patch_file = os.path.join(self.repo_path, "rename_mod.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        self.assertFalse(os.path.exists(old_file))
        new_file = os.path.join(self.repo_path, "new.txt")
        self.assertTrue(os.path.exists(new_file))
        with open(new_file, "rb") as f:
            self.assertEqual(f.read(), b"line 1\nmodified line 2\n")

    def test_apply_copy(self) -> None:
        """Test applying a patch that copies a file."""
        src_file = os.path.join(self.repo_path, "source.txt")
        with open(src_file, "wb") as f:
            f.write(b"original content\n")
        porcelain.add(self.repo_path, paths=["source.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        patch_content = b"""\
diff --git a/source.txt b/copy.txt
similarity index 100%
copy from source.txt
copy to copy.txt
"""
        patch_file = os.path.join(self.repo_path, "copy.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        # Both files should exist
        self.assertTrue(os.path.exists(src_file))
        copy_file = os.path.join(self.repo_path, "copy.txt")
        self.assertTrue(os.path.exists(copy_file))
        with open(copy_file, "rb") as f:
            self.assertEqual(f.read(), b"original content\n")

    def test_apply_three_way(self) -> None:
        """Test applying a patch with 3-way merge fallback."""
        if importlib.util.find_spec("merge3") is None:
            raise DependencyMissing("merge3")

        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nlocal change\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # Patch that won't apply cleanly but can be merged
        patch_content = b"""\
diff --git a/test.txt b/test.txt
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+patch change
 line 3
"""
        patch_file = os.path.join(self.repo_path, "change.patch")
        with open(patch_file, "wb") as f:
            f.write(patch_content)

        # Without 3-way, this should fail
        with self.assertRaises(PatchApplicationFailure):
            porcelain.apply_patch(self.repo_path, patch_file=patch_file)

        # Reset file
        with open(file_path, "wb") as f:
            f.write(b"line 1\nlocal change\nline 3\n")

        # With 3-way, it should merge
        porcelain.apply_patch(self.repo_path, patch_file=patch_file, three_way=True)

        # Should have conflict markers or merged result
        with open(file_path, "rb") as f:
            result = f.read()
        # The merge should have occurred (may have conflict markers)
        self.assertIsNotNone(result)


class AmTests(PorcelainTestCase):
    def _make_email_patch(
        self,
        subject: str,
        diff: bytes,
        author_name: str = "Test Author",
        author_email: str = "test@example.com",
        date: str = "Mon, 1 Jan 2024 12:00:00 +0000",
        body: str = "",
    ) -> bytes:
        """Build a raw email-format patch message."""
        lines = [
            f"From: {author_name} <{author_email}>",
            f"Date: {date}",
            f"Subject: {subject}",
            "",
        ]
        if body:
            lines.append(body)
            lines.append("")
        lines.append("---")
        raw = "\n".join(lines).encode("utf-8") + b"\n" + diff
        return raw

    def test_am_single_patch(self) -> None:
        """Test applying a single email-format patch."""
        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        diff = b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
"""
        email_patch = self._make_email_patch(
            subject="[PATCH] Change line 2",
            diff=diff,
            body="This changes line 2 to line two.",
        )

        import io

        shas = porcelain.am(self.repo_path, patches=[io.BytesIO(email_patch)])
        self.assertEqual(len(shas), 1)

        # Verify file content
        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline two\nline 3\n")

        # Verify commit metadata
        with porcelain.open_repo_closing(self.repo_path) as r:
            commit_obj = r[shas[0]]
            self.assertEqual(commit_obj.author, b"Test Author <test@example.com>")
            self.assertIn(b"Change line 2", commit_obj.message)
            self.assertIn(b"This changes line 2 to line two.", commit_obj.message)

    def test_am_multiple_patches(self) -> None:
        """Test applying an mbox with multiple patches."""
        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # Build an mbox with two patches
        patch1 = self._make_email_patch(
            subject="[PATCH 1/2] Change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
""",
        )
        patch2 = self._make_email_patch(
            subject="[PATCH 2/2] Change line 3",
            diff=b"""\
diff --git a/test.txt b/test.txt
index abcdefg..1234567 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
 line two
-line 3
+line three
""",
            date="Mon, 1 Jan 2024 13:00:00 +0000",
        )

        # Build mbox format
        mbox_content = b"From nobody Mon Jan  1 12:00:00 2024\n" + patch1
        mbox_content += b"\nFrom nobody Mon Jan  1 13:00:00 2024\n" + patch2

        # Write to a file
        mbox_path = os.path.join(self.repo_path, "patches.mbox")
        with open(mbox_path, "wb") as f:
            f.write(mbox_content)

        shas = porcelain.am(self.repo_path, patches=mbox_path)
        self.assertEqual(len(shas), 2)

        # Verify final file content
        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline two\nline three\n")

    def test_am_subject_munging(self) -> None:
        """Test that [PATCH] prefix is stripped from commit message."""
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        diff = b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
"""
        email_patch = self._make_email_patch(
            subject="[PATCH v2 1/1] Improve line 2",
            diff=diff,
        )

        import io

        shas = porcelain.am(self.repo_path, patches=[io.BytesIO(email_patch)])
        self.assertEqual(len(shas), 1)

        # Subject should have [PATCH v2 1/1] stripped
        with porcelain.open_repo_closing(self.repo_path) as r:
            commit_obj = r[shas[0]]
            # Should start with the actual subject, not the [PATCH...] prefix
            self.assertTrue(commit_obj.message.startswith(b"Improve line 2"))

    def test_am_abort(self) -> None:
        """Test aborting am restores original HEAD."""
        from dulwich.am import AmConflict

        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        orig_sha = porcelain.commit(self.repo_path, message=b"initial")

        # First patch applies fine
        patch1 = self._make_email_patch(
            subject="[PATCH 1/2] Change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
""",
        )
        # Second patch conflicts (expects "line 2" but it's now "line two")
        patch2 = self._make_email_patch(
            subject="[PATCH 2/2] Also change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line TWO
 line 3
""",
            date="Mon, 1 Jan 2024 13:00:00 +0000",
        )

        import io

        with self.assertRaises(AmConflict):
            porcelain.am(
                self.repo_path,
                patches=[io.BytesIO(patch1), io.BytesIO(patch2)],
            )

        # Abort should restore to original HEAD
        porcelain.am_abort(self.repo_path)

        with porcelain.open_repo_closing(self.repo_path) as r:
            self.assertEqual(r.head(), orig_sha)

        # Working tree should be restored
        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline 2\nline 3\n")

    def test_am_continue(self) -> None:
        """Test continuing am after resolving a conflict."""
        from dulwich.am import AmConflict

        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # First patch applies fine
        patch1 = self._make_email_patch(
            subject="[PATCH 1/2] Change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
""",
        )
        # Second patch conflicts
        patch2 = self._make_email_patch(
            subject="[PATCH 2/2] Also change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line TWO
 line 3
""",
            date="Mon, 1 Jan 2024 13:00:00 +0000",
        )

        import io

        with self.assertRaises(AmConflict):
            porcelain.am(
                self.repo_path,
                patches=[io.BytesIO(patch1), io.BytesIO(patch2)],
            )

        # Manually resolve: write the desired result and stage it
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline TWO\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])

        # Continue should create the commit
        shas = porcelain.am_continue(self.repo_path)
        self.assertEqual(len(shas), 1)

        # Verify the commit
        with porcelain.open_repo_closing(self.repo_path) as r:
            commit_obj = r[shas[0]]
            self.assertIn(b"Also change line 2", commit_obj.message)

        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline TWO\nline 3\n")

    def test_am_skip(self) -> None:
        """Test skipping a patch during am."""
        from dulwich.am import AmConflict

        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # First patch applies fine
        patch1 = self._make_email_patch(
            subject="[PATCH 1/2] Change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
""",
        )
        # Second patch conflicts
        patch2 = self._make_email_patch(
            subject="[PATCH 2/2] Also change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line TWO
 line 3
""",
            date="Mon, 1 Jan 2024 13:00:00 +0000",
        )

        import io

        with self.assertRaises(AmConflict):
            porcelain.am(
                self.repo_path,
                patches=[io.BytesIO(patch1), io.BytesIO(patch2)],
            )

        # Skip the conflicting patch
        shas = porcelain.am_skip(self.repo_path)
        self.assertEqual(len(shas), 0)  # No more patches after skipping the last one

        # File should be back to what patch1 left it as
        with open(file_path, "rb") as f:
            result = f.read()
        self.assertEqual(result, b"line 1\nline two\nline 3\n")

        # State should be cleaned up
        with porcelain.open_repo_closing(self.repo_path) as r:
            state_dir = os.path.join(r.controldir(), "rebase-apply")
            self.assertFalse(os.path.exists(state_dir))

    def test_am_quit(self) -> None:
        """Test quitting am keeps partial progress."""
        from dulwich.am import AmConflict

        # Create a file and commit it
        file_path = os.path.join(self.repo_path, "test.txt")
        with open(file_path, "wb") as f:
            f.write(b"line 1\nline 2\nline 3\n")
        porcelain.add(self.repo_path, paths=["test.txt"])
        porcelain.commit(self.repo_path, message=b"initial")

        # First patch applies fine
        patch1 = self._make_email_patch(
            subject="[PATCH 1/2] Change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line two
 line 3
""",
        )
        # Second patch conflicts
        patch2 = self._make_email_patch(
            subject="[PATCH 2/2] Also change line 2",
            diff=b"""\
diff --git a/test.txt b/test.txt
index 1234567..abcdefg 100644
--- a/test.txt
+++ b/test.txt
@@ -1,3 +1,3 @@
 line 1
-line 2
+line TWO
 line 3
""",
            date="Mon, 1 Jan 2024 13:00:00 +0000",
        )

        import io

        with self.assertRaises(AmConflict):
            porcelain.am(
                self.repo_path,
                patches=[io.BytesIO(patch1), io.BytesIO(patch2)],
            )

        # Remember HEAD before quit (should include patch1's commit)
        with porcelain.open_repo_closing(self.repo_path) as r:
            head_before_quit = r.head()

        porcelain.am_quit(self.repo_path)

        # HEAD should be unchanged (partial progress kept)
        with porcelain.open_repo_closing(self.repo_path) as r:
            self.assertEqual(r.head(), head_before_quit)

        # State should be cleaned up
        with porcelain.open_repo_closing(self.repo_path) as r:
            state_dir = os.path.join(r.controldir(), "rebase-apply")
            self.assertFalse(os.path.exists(state_dir))


class RequestPullTests(PorcelainTestCase):
    def _linear_history(self, count, branch=b"refs/heads/master"):
        """Build a linear history of ``count`` commits and return them.

        Each commit adds one new file, so the tree accumulates over time.
        """
        commits = []
        tree = Tree()
        for i in range(count):
            blob = Blob.from_string(f"content {i}\n".encode())
            tree = tree.copy()
            tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
            parents = [commits[-1].id] if commits else []
            commit = make_commit(
                tree=tree.id,
                parents=parents,
                message=f"Commit {i}".encode(),
                author_time=1262304000 + i,
                commit_time=1262304000 + i,
            )
            self.repo.object_store.add_objects(
                [(blob, None), (tree, None), (commit, None)]
            )
            commits.append(commit)
        self.repo.refs[branch] = commits[-1].id
        self.repo.refs.set_symbolic_ref(b"HEAD", branch)
        return commits

    def test_basic(self) -> None:
        commits = self._linear_history(4)
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            commits[-1].id,
            outstream=out,
        )
        lines = out.getvalue().splitlines()
        self.assertEqual(
            "The following changes since commit " + commits[0].id.decode("ascii") + ":",
            lines[0],
        )
        self.assertEqual("are available in the Git repository at:", lines[4])
        self.assertEqual(
            "  https://example.com/repo.git " + commits[-1].id.decode("ascii"),
            lines[6],
        )
        self.assertEqual(
            "for you to fetch changes up to " + commits[-1].id.decode("ascii") + ":",
            lines[8],
        )
        # Shortlog section.
        shortlog = out.getvalue().split("-" * 64 + "\n", 1)[1]
        self.assertEqual(
            "Test Author (3):\n      Commit 1\n      Commit 2\n      Commit 3\n",
            shortlog.split("\n\n", 1)[0] + "\n",
        )
        self.assertIn("3 files changed", lines[-1])

    def test_shortlog_order_oldest_first(self) -> None:
        commits = self._linear_history(4)
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            outstream=out,
        )
        # The shortlog (after the separator line) keeps commits oldest first.
        shortlog = out.getvalue().split("-" * 64 + "\n", 1)[1]
        self.assertEqual(
            [
                "Test Author (3):",
                "      Commit 1",
                "      Commit 2",
                "      Commit 3",
            ],
            shortlog.splitlines()[:4],
        )

    def test_defaults_to_head(self) -> None:
        commits = self._linear_history(3)
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            outstream=out,
        )
        lines = out.getvalue().splitlines()
        self.assertEqual(
            "for you to fetch changes up to " + commits[-1].id.decode("ascii") + ":",
            lines[8],
        )
        # The symbolic HEAD resolves to the branch name being advertised.
        self.assertEqual("  https://example.com/repo.git master", lines[6])

    def test_explicit_remote_ref(self) -> None:
        commits = self._linear_history(3)
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            b"HEAD:for-upstream",
            outstream=out,
        )
        self.assertEqual(
            "  https://example.com/repo.git for-upstream",
            out.getvalue().splitlines()[6],
        )

    def test_multiple_authors(self) -> None:
        c0 = make_commit(tree=Tree().id, message=b"root")
        self.repo.object_store.add_objects([(Tree(), None), (c0, None)])
        blob = Blob.from_string(b"a\n")
        tree = Tree()
        tree.add(b"a.txt", 0o100644, blob.id)
        c1 = make_commit(
            tree=tree.id,
            parents=[c0.id],
            author=b"Alice <alice@example.com>",
            committer=b"Alice <alice@example.com>",
            message=b"Alice change",
        )
        blob2 = Blob.from_string(b"b\n")
        tree2 = Tree()
        tree2.add(b"a.txt", 0o100644, blob.id)
        tree2.add(b"b.txt", 0o100644, blob2.id)
        c2 = make_commit(
            tree=tree2.id,
            parents=[c1.id],
            author=b"Bob <bob@example.com>",
            committer=b"Bob <bob@example.com>",
            message=b"Bob change",
        )
        self.repo.object_store.add_objects(
            [
                (blob, None),
                (tree, None),
                (c1, None),
                (blob2, None),
                (tree2, None),
                (c2, None),
            ]
        )
        self.repo.refs[b"refs/heads/master"] = c2.id
        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            c0.id,
            "https://example.com/repo.git",
            outstream=out,
        )
        # Authors are sorted case-insensitively by name, Alice before Bob.
        shortlog = out.getvalue().split("-" * 64 + "\n", 1)[1]
        self.assertEqual(
            [
                "Alice (1):",
                "      Alice change",
                "",
                "Bob (1):",
                "      Bob change",
            ],
            shortlog.splitlines()[:5],
        )

    def test_no_common_ancestor(self) -> None:
        c1 = make_commit(tree=Tree().id, message=b"first root")
        c2 = make_commit(tree=Tree().id, message=b"second root", commit_time=1262304100)
        self.repo.object_store.add_objects([(Tree(), None), (c1, None), (c2, None)])
        out = StringIO()
        self.assertRaises(
            porcelain.Error,
            porcelain.request_pull,
            self.repo.path,
            c1.id,
            "https://example.com/repo.git",
            c2.id,
            outstream=out,
        )

    def test_no_commits_in_range(self) -> None:
        commits = self._linear_history(2)
        out = StringIO()
        self.assertRaises(
            porcelain.Error,
            porcelain.request_pull,
            self.repo.path,
            commits[-1].id,
            "https://example.com/repo.git",
            commits[-1].id,
            outstream=out,
        )

    def test_patch_included(self) -> None:
        commits = self._linear_history(2)
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            commits[-1].id,
            patch=True,
            outstream=out,
        )
        output = out.getvalue()
        # The diffstat appears before the patch body.
        self.assertIn("file1.txt", output)
        self.assertLess(output.index("files changed"), output.index("diff --git"))
        self.assertIn("+content 1", output)

    def test_annotated_tag_message(self) -> None:
        commits = self._linear_history(2)
        tag = Tag()
        tag.name = b"v1.0"
        tag.object = (Commit, commits[-1].id)
        tag.tagger = b"Test Author <test@nodomain.com>"
        tag.tag_time = 1262304000
        tag.tag_timezone = 0
        tag.message = b"Release version 1.0\n"
        self.repo.object_store.add_object(tag)
        self.repo.refs[b"refs/tags/v1.0"] = tag.id
        out = StringIO()
        porcelain.request_pull(
            self.repo.path,
            commits[0].id,
            "https://example.com/repo.git",
            b"v1.0",
            outstream=out,
        )
        # The tag message appears between the first and second separators.
        body = out.getvalue().split("-" * 64 + "\n")
        self.assertEqual("Release version 1.0\n", body[1])
