Explorar o código

Add dulwich.porcelain.checkout

Fixes #576
Jelmer Vernooij hai 2 meses
pai
achega
26ac0d1d76
Modificáronse 4 ficheiros con 456 adicións e 147 borrados
  1. 3 0
      NEWS
  2. 13 5
      dulwich/cli.py
  3. 145 111
      dulwich/porcelain.py
  4. 295 31
      tests/test_porcelain.py

+ 3 - 0
NEWS

@@ -26,6 +26,9 @@
 
  * Update working tree in pull. (Jelmer Vernooij, #452)
 
+ * Support switching branch in a way that updates
+   working tree. (Jelmer Vernooij, #576)
+
 0.22.8	2025-03-02
 
  * Allow passing in plain strings to ``dulwich.porcelain.tag_create``

+ 13 - 5
dulwich/cli.py

@@ -766,9 +766,9 @@ class cmd_checkout(Command):
     def run(self, args) -> None:
         parser = argparse.ArgumentParser()
         parser.add_argument(
-            "branch",
+            "target",
             type=str,
-            help="Name of the branch",
+            help="Name of the branch, tag, or commit to checkout",
         )
         parser.add_argument(
             "-f",
@@ -776,13 +776,21 @@ class cmd_checkout(Command):
             action="store_true",
             help="Force checkout",
         )
+        parser.add_argument(
+            "-b",
+            "--new-branch",
+            type=str,
+            help="Create a new branch at the target and switch to it",
+        )
         args = parser.parse_args(args)
-        if not args.branch:
-            print("Usage: dulwich checkout BRANCH_NAME [--force]")
+        if not args.target:
+            print("Usage: dulwich checkout TARGET [--force] [-b NEW_BRANCH]")
             sys.exit(1)
 
         try:
-            porcelain.checkout_branch(".", target=args.branch, force=args.force)
+            porcelain.checkout(
+                ".", target=args.target, force=args.force, new_branch=args.new_branch
+            )
         except porcelain.CheckoutError as e:
             sys.stderr.write(f"{e}\n")
             sys.exit(1)

+ 145 - 111
dulwich/porcelain.py

@@ -26,6 +26,7 @@ Currently implemented:
  * add
  * branch{_create,_delete,_list}
  * check_ignore
+ * checkout
  * checkout_branch
  * clone
  * cone mode{_init, _set, _add}
@@ -93,7 +94,6 @@ from .diff_tree import (
     RENAME_CHANGE_TYPES,
 )
 from .errors import SendPackError
-from .file import ensure_dir_exists
 from .graph import can_fast_forward
 from .ignore import IgnoreFilterManager
 from .index import (
@@ -101,10 +101,9 @@ from .index import (
     blob_from_path_and_stat,
     build_file_from_blob,
     get_unstaged_changes,
-    index_entry_from_stat,
     update_working_tree,
 )
-from .object_store import iter_tree_contents, tree_lookup_path
+from .object_store import tree_lookup_path
 from .objects import (
     Commit,
     Tag,
@@ -118,14 +117,12 @@ from .objectspec import (
     parse_ref,
     parse_reftuples,
     parse_tree,
-    to_bytes,
 )
 from .pack import write_pack_from_container, write_pack_index
 from .patch import write_tree_diff
 from .protocol import ZERO_SHA, Protocol
 from .refs import (
     LOCAL_BRANCH_PREFIX,
-    LOCAL_REMOTE_PREFIX,
     LOCAL_TAG_PREFIX,
     Ref,
     _import_remote_refs,
@@ -1186,14 +1183,14 @@ def reset(repo, mode, treeish="HEAD") -> None:
         else:
             validate_path_element = validate_path_element_default
 
-        # Import symlink function
-        from .index import symlink
-
         if config.get_boolean(b"core", b"symlinks", True):
+            # Import symlink function
+            from .index import symlink
+
             symlink_fn = symlink
         else:
 
-            def symlink_fn(source, target) -> None:
+            def symlink_fn(source, target) -> None:  # type: ignore
                 mode = "w" + ("b" if isinstance(source, bytes) else "")
                 with open(target, mode) as f:
                     f.write(source)
@@ -2063,6 +2060,135 @@ def update_head(repo, target, detached=False, new_branch=None) -> None:
             r.refs.set_symbolic_ref(b"HEAD", to_set)
 
 
+def checkout(
+    repo,
+    target: Union[bytes, str],
+    force: bool = False,
+    new_branch: Optional[Union[bytes, str]] = None,
+) -> None:
+    """Switch to a branch or commit, updating both HEAD and the working tree.
+
+    This is similar to 'git checkout', allowing you to switch to a branch,
+    tag, or specific commit. Unlike update_head, this function also updates
+    the working tree to match the target.
+
+    Args:
+      repo: Path to repository or repository object
+      target: Branch name, tag, or commit SHA to checkout
+      force: Force checkout even if there are local changes
+      new_branch: Create a new branch at target (like git checkout -b)
+
+    Raises:
+      CheckoutError: If checkout cannot be performed due to conflicts
+      KeyError: If the target reference cannot be found
+    """
+    with open_repo_closing(repo) as r:
+        if isinstance(target, str):
+            target = target.encode(DEFAULT_ENCODING)
+        if isinstance(new_branch, str):
+            new_branch = new_branch.encode(DEFAULT_ENCODING)
+
+        # Parse the target to get the commit
+        target_commit = parse_commit(r, target)
+        target_tree_id = target_commit.tree
+
+        # Get current HEAD tree for comparison
+        try:
+            current_head = r.refs[b"HEAD"]
+            current_tree_id = r[current_head].tree
+        except KeyError:
+            # No HEAD yet (empty repo)
+            current_tree_id = None
+
+        # Check for uncommitted changes if not forcing
+        if not force and current_tree_id is not None:
+            status_report = status(r)
+            changes = []
+            # staged is a dict with 'add', 'delete', 'modify' keys
+            if isinstance(status_report.staged, dict):
+                changes.extend(status_report.staged.get("add", []))
+                changes.extend(status_report.staged.get("delete", []))
+                changes.extend(status_report.staged.get("modify", []))
+            # unstaged is a list
+            changes.extend(status_report.unstaged)
+            if changes:
+                # Check if any changes would conflict with checkout
+                target_tree = r[target_tree_id]
+                for change in changes:
+                    if isinstance(change, str):
+                        change = change.encode(DEFAULT_ENCODING)
+
+                    try:
+                        target_tree.lookup_path(r.object_store.__getitem__, change)
+                        # File exists in target tree - would overwrite local changes
+                        raise CheckoutError(
+                            f"Your local changes to '{change.decode()}' would be "
+                            "overwritten by checkout. Please commit or stash before switching."
+                        )
+                    except KeyError:
+                        # File doesn't exist in target tree - change can be preserved
+                        pass
+
+        # Get configuration for working directory update
+        config = r.get_config()
+        honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
+
+        # Import validation functions
+        from .index import validate_path_element_default, validate_path_element_ntfs
+
+        if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
+            validate_path_element = validate_path_element_ntfs
+        else:
+            validate_path_element = validate_path_element_default
+
+        if config.get_boolean(b"core", b"symlinks", True):
+            # Import symlink function
+            from .index import symlink
+
+            symlink_fn = symlink
+        else:
+
+            def symlink_fn(source, target) -> None:  # type: ignore
+                mode = "w" + ("b" if isinstance(source, bytes) else "")
+                with open(target, mode) as f:
+                    f.write(source)
+
+        # Update working tree
+        update_working_tree(
+            r,
+            current_tree_id,
+            target_tree_id,
+            honor_filemode=honor_filemode,
+            validate_path_element=validate_path_element,
+            symlink_fn=symlink_fn,
+            force_remove_untracked=force,
+        )
+
+        # Update HEAD
+        if new_branch:
+            # Create new branch and switch to it
+            branch_create(r, new_branch, objectish=target_commit.id.decode("ascii"))
+            update_head(r, new_branch)
+        else:
+            # Check if target is a branch name (with or without refs/heads/ prefix)
+            branch_ref = None
+            if target in r.refs.keys():
+                if target.startswith(LOCAL_BRANCH_PREFIX):
+                    branch_ref = target
+            else:
+                # Try adding refs/heads/ prefix
+                potential_branch = _make_branch_ref(target)
+                if potential_branch in r.refs.keys():
+                    branch_ref = potential_branch
+
+            if branch_ref:
+                # It's a branch - update HEAD symbolically
+                update_head(r, branch_ref)
+            else:
+                # It's a tag, other ref, or commit SHA - detached HEAD
+                update_head(r, target_commit.id.decode("ascii"), detached=True)
+
+
 def reset_file(repo, file_path: str, target: bytes = b"HEAD", symlink_fn=None) -> None:
     """Reset the file to specific commit or branch.
 
@@ -2081,117 +2207,25 @@ def reset_file(repo, file_path: str, target: bytes = b"HEAD", symlink_fn=None) -
     build_file_from_blob(blob, mode, full_path, symlink_fn=symlink_fn)
 
 
-def _update_head_during_checkout_branch(repo, target):
-    checkout_target = None
-    if target == b"HEAD":  # Do not update head while trying to checkout to HEAD.
-        pass
-    elif target in repo.refs.keys(base=LOCAL_BRANCH_PREFIX):
-        update_head(repo, target)
-    else:
-        # If checking out a remote branch, create a local one without the remote name prefix.
-        config = repo.get_config()
-        name = target.split(b"/")[0]
-        section = (b"remote", name)
-        if config.has_section(section):
-            checkout_target = target.replace(name + b"/", b"")
-            try:
-                branch_create(
-                    repo, checkout_target, (LOCAL_REMOTE_PREFIX + target).decode()
-                )
-            except Error:
-                pass
-            update_head(repo, LOCAL_BRANCH_PREFIX + checkout_target)
-        else:
-            update_head(repo, target, detached=True)
-
-    return checkout_target
-
-
 def checkout_branch(repo, target: Union[bytes, str], force: bool = False) -> None:
     """Switch branches or restore working tree files.
 
-    The implementation of this function will probably not scale well
-    for branches with lots of local changes.
-    This is due to the analysis of a diff between branches before any
-    changes are applied.
+    This is now a wrapper around the general checkout() function.
+    Preserved for backward compatibility.
 
     Args:
       repo: dulwich Repo object
       target: branch name or commit sha to checkout
       force: true or not to force checkout
     """
-    target = to_bytes(target)
-
-    current_tree = parse_tree(repo, repo.head())
-    target_tree = parse_tree(repo, target)
-
-    if force:
-        repo.reset_index(target_tree.id)
-        _update_head_during_checkout_branch(repo, target)
-    else:
-        status_report = status(repo)
-        changes = list(
-            set(
-                status_report[0]["add"]
-                + status_report[0]["delete"]
-                + status_report[0]["modify"]
-                + status_report[1]
-            )
-        )
-        index = 0
-        while index < len(changes):
-            change = changes[index]
-            try:
-                current_tree.lookup_path(repo.object_store.__getitem__, change)
-                try:
-                    target_tree.lookup_path(repo.object_store.__getitem__, change)
-                    index += 1
-                except KeyError:
-                    raise CheckoutError(
-                        "Your local changes to the following files would be overwritten by checkout: "
-                        + change.decode()
-                    )
-            except KeyError:
-                changes.pop(index)
-
-        # Update head.
-        checkout_target = _update_head_during_checkout_branch(repo, target)
-        if checkout_target is not None:
-            target_tree = parse_tree(repo, checkout_target)
-
-        dealt_with = set()
-        repo_index = repo.open_index()
-        for entry in iter_tree_contents(repo.object_store, target_tree.id):
-            dealt_with.add(entry.path)
-            if entry.path in changes:
-                continue
-            full_path = os.path.join(os.fsencode(repo.path), entry.path)
-            blob = repo.object_store[entry.sha]
-            ensure_dir_exists(os.path.dirname(full_path))
-            st = build_file_from_blob(blob, entry.mode, full_path)
-            repo_index[entry.path] = index_entry_from_stat(st, entry.sha)
-
-        repo_index.write()
-
-        for entry in iter_tree_contents(repo.object_store, current_tree.id):
-            if entry.path not in dealt_with:
-                repo.unstage([entry.path])
-
-    # Remove the untracked files which are in the current_file_set.
-    repo_index = repo.open_index()
-    for change in repo_index.changes_from_tree(repo.object_store, current_tree.id):
-        path_change = change[0]
-        if path_change[1] is None:
-            file_name = path_change[0]
-            full_path = os.path.join(repo.path, file_name.decode())
-            if os.path.isfile(full_path):
-                os.remove(full_path)
-            dir_path = os.path.dirname(full_path)
-            while dir_path != repo.path:
-                is_empty = len(os.listdir(dir_path)) == 0
-                if is_empty:
-                    os.rmdir(dir_path)
-                dir_path = os.path.dirname(dir_path)
+    import warnings
+    warnings.warn(
+        "checkout_branch is deprecated, use checkout instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
+    # Simply delegate to the new checkout function
+    checkout(repo, target, force=force)
 
 
 def sparse_checkout(

+ 295 - 31
tests/test_porcelain.py

@@ -1760,14 +1760,16 @@ class CheckoutTests(PorcelainTestCase):
             [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
         )
 
-        # Both branches have file 'foo' checkout should be fine.
-        porcelain.checkout_branch(self.repo, b"uni")
-        self.assertEqual(b"uni", porcelain.active_branch(self.repo))
+        # The new checkout behavior prevents switching with staged changes
+        with self.assertRaises(porcelain.CheckoutError):
+            porcelain.checkout_branch(self.repo, b"uni")
 
-        status = list(porcelain.status(self.repo))
-        self.assertEqual(
-            [{"add": [], "delete": [], "modify": [b"foo"]}, [], []], status
-        )
+        # Should still be on master
+        self.assertEqual(b"master", porcelain.active_branch(self.repo))
+
+        # Force checkout should work
+        porcelain.checkout_branch(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")])
@@ -1776,14 +1778,16 @@ class CheckoutTests(PorcelainTestCase):
             [{"add": [], "delete": [b"foo"], "modify": []}, [], []], status
         )
 
-        # Both branches have file 'foo' checkout should be fine.
-        porcelain.checkout_branch(self.repo, b"uni")
-        self.assertEqual(b"uni", porcelain.active_branch(self.repo))
+        # The new checkout behavior prevents switching with staged deletions
+        with self.assertRaises(porcelain.CheckoutError):
+            porcelain.checkout_branch(self.repo, b"uni")
 
-        status = list(porcelain.status(self.repo))
-        self.assertEqual(
-            [{"add": [], "delete": [b"foo"], "modify": []}, [], []], status
-        )
+        # Should still be on master
+        self.assertEqual(b"master", porcelain.active_branch(self.repo))
+
+        # Force checkout should work
+        porcelain.checkout_branch(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")
@@ -1818,16 +1822,17 @@ class CheckoutTests(PorcelainTestCase):
             [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
         )
 
-        # 'uni' branch doesn't have 'nee' and it has been modified, should result in the checkout being aborted.
-        with self.assertRaises(CheckoutError):
-            porcelain.checkout_branch(self.repo, b"uni")
-
-        self.assertEqual(b"master", porcelain.active_branch(self.repo))
+        # The new checkout behavior allows switching if the file doesn't exist in target branch
+        # (changes can be preserved)
+        porcelain.checkout_branch(self.repo, b"uni")
+        self.assertEqual(b"uni", porcelain.active_branch(self.repo))
 
+        # The staged changes are lost and the file is removed from working tree
+        # because it doesn't exist in the target branch
         status = list(porcelain.status(self.repo))
-        self.assertEqual(
-            [{"add": [], "delete": [], "modify": [b"nee"]}, [], []], status
-        )
+        # File 'nee' is gone completely
+        self.assertEqual([{"add": [], "delete": [], "modify": []}, [], []], status)
+        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.
@@ -1860,12 +1865,16 @@ class CheckoutTests(PorcelainTestCase):
             [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
         )
 
-        porcelain.checkout_branch(self.repo, b"uni")
+        # The new checkout behavior prevents switching with unstaged changes
+        with self.assertRaises(porcelain.CheckoutError):
+            porcelain.checkout_branch(self.repo, b"uni")
 
-        status = list(porcelain.status(self.repo))
-        self.assertEqual(
-            [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
-        )
+        # Should still be on master
+        self.assertEqual(b"master", porcelain.active_branch(self.repo))
+
+        # Force checkout should work
+        porcelain.checkout_branch(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:
@@ -2050,14 +2059,19 @@ class CheckoutTests(PorcelainTestCase):
             target_repo.refs[b"HEAD"],
         )
 
+        # The new checkout behavior treats origin/foo as a ref and creates detached HEAD
         porcelain.checkout_branch(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,
-            b"refs/heads/foo": 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,
@@ -2073,8 +2087,14 @@ class CheckoutTests(PorcelainTestCase):
 
     def test_checkout_remote_branch_then_master_then_remote_branch_again(self) -> None:
         target_repo = self._checkout_remote_branch()
-        self.assertEqual(b"foo", porcelain.active_branch(target_repo))
-        _commit_file_with_content(target_repo, "bar", "something\n")
+        # 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_branch(target_repo, b"master")
@@ -2082,14 +2102,258 @@ class CheckoutTests(PorcelainTestCase):
         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_branch(target_repo, b"origin/foo")
 
-        self.assertEqual(b"foo", porcelain.active_branch(target_repo))
+        # 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_branch(target_repo, detached_commit_sha.decode())
         self.assertTrue(os.path.isfile(os.path.join(target_repo.path, "bar")))
 
         target_repo.close()
 
 
+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."""
+        # 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))
+
+        # Local changes should be discarded
+        with open(self._foo_path) as f:
+            content = f.read()
+        self.assertEqual("initial 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(