Repository: jelmer/dulwich

Issue:
Porcelain function for switching branch that updates working tree
update_head() lets you change the branch, but the working tree does not get updated. I don't see anyway to accomplish this with just porcelain. There should be some way to get the same behavior as 

`git checkout 'branch_name'`

Discussion:
@jelmer: This is blocked on #452@MattSable: Is there any path at all to update the working tree to that of a different branch?@jelmer: dulwich.index.build_index_from_tree() can construct a working tree with index from a Dulwich Tree object. It's what gets run at the end of Repo.clone to construct the working tree.

However, it doesn't do anything fancy like merge the old working tree changes.@MattSable: I see. Is there an easy way to generate the tree given a branch name?

```
git clone my_repo
git checkout branch2


r = porcelain.clone("my_repo")
dulwich.index.build_index_from_tree(r.path, r.index_path(), r.object_store, ???)
```@jelmer: r[b"refs/heads/branchname"].tree

You can also use:

     r.reset_index(r[b"refs/heads/branchname"].tree)

and then, if you also want to update the currently active branch:

     r.refs.set_symbolic_ref(b"HEAD", b"refs/heads/branchname")@Friz-zy: I'm feel that current `porcelain.clone` do the `checkout` instead of `clone` as I don't see any `merge` operation in it. I can be wrong, but then we should create a separate functions for `checkout` as this is also very useful operation:

`Checkout`: Fetches the latest changes. You should already have this repo downloaded. It does not merge those new changes but makes your working directory reflect them. You can merge them at your leisure later.

`Pull`: Fetches the changes and merges them into the local branch of the same name.@jelmer: I'm not sure if I understand your comment. Creating a working copy after clone is much simpler than doing a three way merge in the working copy after pull/merge or checkout operations. @Friz-zy: sorry, I mean
1) currently `checkout` implemented only in `porcelain.clone`, but if I have an existed repo I can't change it to another branch or tag like `git checkout <branch>`
2) current `porcelain.pull` looks like `checkout` instead of pure git pull operation if I right understand the code, it's okay but we should rename it then and add warning about unimplemented method imo@yakutovicha: I am trying to do `git checkout` following the suggestions described above.
It seems to me that those two steps are still not enough:
```
r.reset_index(r[b"refs/heads/branchname"].tree)
r.refs.set_symbolic_ref(b"HEAD", b"refs/heads/branchname")
```
This operations do NOT remove the files that are not present in the branch I am checking out to. In addition, they remain staged:
```
In [135]: r.reset_index(r['refs/tags/v0.0.1'].tree)

In [136]: r.refs.set_symbolic_ref(b"HEAD", 'refs/tags/v0.0.1')

In [138]: ! git status
On branch refs/tags/v0.0.1
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   __init__.py
        new file:   delete_node.ipynb
        new file:   rest.ipynb
        new file:   start.py
```
reset command does not help, unfortunately:
```
In [147]: from dulwich.porcelain import reset

In [148]: reset(r, 'hard')

In [149]: ! git status
On branch refs/tags/v0.0.1
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        new file:   __init__.py
        new file:   delete_node.ipynb
        new file:   rest.ipynb
        new file:   start.py
```
@jelmer what would be the best way to remove the staged files that remained after this dulwich-managed checkout? Do you have any plans to implement proper `git checkout` within dulwich?@jelmer: The proper way to handle this requires support for updating the working tree with the delta between trees. See #452

This is something I'm planning to work on, but it's somewhat nontrivial for working tree files that have changed.@laneb: @jelmer The approach that I described in #452 satisfies this, no?

> I.e. the WD+index reflected tree T0 and we want to update WD+index to reflect T1. I think this operation can be implemented naively as
> 
> 1. Assert index matches T0
> 2. Assert T1 contains no blob at the same path as an untracked file in WD
> 3. Remove all files tracked in T0 from WD
> 4. Write all files tracked in T1 to WD
> 5. Rebuild index from T1

What's the issue you're anticipating re: working tree that have changed?@jelmer: Yup.

On 22 February 2019 01:51:24 GMT-08:00, Lane Barlow <notifications@github.com> wrote:
>@jelmer The approach that I described in #452 satisfies this, no?
>
>> I.e. the WD+index reflected tree T0 and we want to update WD+index to
>reflect T1. I think this operation can be implemented naively as
>> 
>> 1. Assert index matches T0
>> 2. Assert T1 contains no blob at the same path as an untracked file
>in WD
>> 3. Remove all files tracked in T0 from WD
>> 4. Write all files tracked in T1 to WD
>> 5. Rebuild index from T1
>
>-- 
>You are receiving this because you were mentioned.
>Reply to this email directly or view it on GitHub:
>https://github.com/dulwich/dulwich/issues/576#issuecomment-466339719


Solution patch:
diff --git a/dulwich/cli.py b/dulwich/cli.py
index 80909a54..d9d10aa5 100755
--- a/dulwich/cli.py
+++ b/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)
diff --git a/dulwich/porcelain.py b/dulwich/porcelain.py
index f7a6e044..d5885973 100644
--- a/dulwich/porcelain.py
+++ b/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,10 +1183,10 @@ 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:
 
@@ -2065,6 +2062,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.
 
@@ -2083,117 +2209,26 @@ 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)
+    import warnings
 
-    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)
+    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(


Test patch:
diff --git a/tests/test_porcelain.py b/tests/test_porcelain.py
index 07eec968..984ed1bb 100644
--- a/tests/test_porcelain.py
+++ b/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(