Kaynağa Gözat

Add support for reset --mixed and reset --soft modes (#1635)

Implement mixed and soft reset modes in porcelain.reset() and the CLI:
- Mixed reset: Updates HEAD and index but leaves working tree unchanged
- Soft reset: Only updates HEAD, preserves index and working tree
- Refactor CLI to use porcelain.reset() for all modes
Jelmer Vernooij 1 ay önce
ebeveyn
işleme
7c5e791780
4 değiştirilmiş dosya ile 187 ekleme ve 50 silme
  1. 5 0
      NEWS
  2. 8 11
      dulwich/cli.py
  3. 81 39
      dulwich/porcelain.py
  4. 93 0
      tests/test_porcelain.py

+ 5 - 0
NEWS

@@ -48,6 +48,11 @@
 
  * Support timeouts for HTTP client operations.  (Jelmer Vernooij)
 
+ * Add support for ``reset --mixed`` and ``reset --soft`` modes in
+   ``porcelain.reset()`` and the CLI. Mixed reset updates HEAD and index
+   but leaves working tree unchanged. Soft reset only updates HEAD.
+   (Jelmer Vernooij)
+
  * Add ``prune`` method to object stores for cleaning up orphaned temporary
    pack files. This is now called by ``garbage_collect()`` to match Git's
    behavior. Also added ``prune`` command to ``dulwich.porcelain``.

+ 8 - 11
dulwich/cli.py

@@ -542,20 +542,17 @@ class cmd_reset(Command):
         args = parser.parse_args(args)
 
         if args.hard:
-            porcelain.reset(".", mode="hard", treeish=args.treeish)
+            mode = "hard"
         elif args.soft:
-            # Soft reset: only change HEAD
-            if args.treeish:
-                from .repo import Repo
-
-                with Repo(".") as repo:
-                    repo.refs[b"HEAD"] = args.treeish.encode()
+            mode = "soft"
         elif args.mixed:
-            # Mixed reset is not implemented yet
-            raise NotImplementedError("Mixed reset not yet implemented")
+            mode = "mixed"
         else:
-            # Default to mixed behavior (not implemented)
-            raise NotImplementedError("Mixed reset not yet implemented")
+            # Default to mixed behavior
+            mode = "mixed"
+
+        # Use the porcelain.reset function for all modes
+        porcelain.reset(".", mode=mode, treeish=args.treeish)
 
 
 class cmd_revert(Command):

+ 81 - 39
dulwich/porcelain.py

@@ -1495,55 +1495,97 @@ def reset(repo, mode, treeish="HEAD") -> None:
       mode: Mode ("hard", "soft", "mixed")
       treeish: Treeish to reset to
     """
-    if mode != "hard":
-        raise Error("hard is the only mode currently supported")
-
     with open_repo_closing(repo) as r:
+        # Parse the target tree
         tree = parse_tree(r, treeish)
+        target_commit = parse_commit(r, treeish)
+
+        # Update HEAD to point to the target commit
+        r.refs[b"HEAD"] = target_commit.id
+
+        if mode == "soft":
+            # Soft reset: only update HEAD, leave index and working tree unchanged
+            return
+
+        elif mode == "mixed":
+            # Mixed reset: update HEAD and index, but leave working tree unchanged
+            from .index import IndexEntry
+            from .object_store import iter_tree_contents
+
+            # Open the index
+            index = r.open_index()
+
+            # Clear the current index
+            index.clear()
+
+            # Populate index from the target tree
+            for entry in iter_tree_contents(r.object_store, tree.id):
+                # Create an IndexEntry from the tree entry
+                # Use zeros for filesystem-specific fields since we're not touching the working tree
+                index_entry = IndexEntry(
+                    ctime=(0, 0),
+                    mtime=(0, 0),
+                    dev=0,
+                    ino=0,
+                    mode=entry.mode,
+                    uid=0,
+                    gid=0,
+                    size=0,  # Size will be 0 since we're not reading from disk
+                    sha=entry.sha,
+                    flags=0,
+                )
+                index[entry.path] = index_entry
 
-        # Get current HEAD tree for comparison
-        try:
-            current_head = r.refs[b"HEAD"]
-            current_tree = r[current_head].tree
-        except KeyError:
-            current_tree = None
+            # Write the updated index
+            index.write()
 
-        # Get configuration for working directory update
-        config = r.get_config()
-        honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
+        elif mode == "hard":
+            # Hard reset: update HEAD, index, and working tree
+            # Get current HEAD tree for comparison
+            try:
+                current_head = r.refs[b"HEAD"]
+                current_tree = r[current_head].tree
+            except KeyError:
+                current_tree = None
 
-        # Import validation functions
-        from .index import validate_path_element_default, validate_path_element_ntfs
+            # Get configuration for working directory update
+            config = r.get_config()
+            honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
 
-        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
+            # Import validation functions
+            from .index import validate_path_element_default, validate_path_element_ntfs
 
-        if config.get_boolean(b"core", b"symlinks", True):
-            # Import symlink function
-            from .index import symlink
+            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
 
-            symlink_fn = symlink
-        else:
+            if config.get_boolean(b"core", b"symlinks", True):
+                # Import symlink function
+                from .index import symlink
 
-            def symlink_fn(  # type: ignore
-                source, target, target_is_directory=False, *, dir_fd=None
-            ) -> None:
-                mode = "w" + ("b" if isinstance(source, bytes) else "")
-                with open(target, mode) as f:
-                    f.write(source)
+                symlink_fn = symlink
+            else:
 
-        # Update working tree and index
-        update_working_tree(
-            r,
-            current_tree,
-            tree.id,
-            honor_filemode=honor_filemode,
-            validate_path_element=validate_path_element,
-            symlink_fn=symlink_fn,
-            force_remove_untracked=True,
-        )
+                def symlink_fn(  # type: ignore
+                    source, target, target_is_directory=False, *, dir_fd=None
+                ) -> None:
+                    mode = "w" + ("b" if isinstance(source, bytes) else "")
+                    with open(target, mode) as f:
+                        f.write(source)
+
+            # Update working tree and index
+            update_working_tree(
+                r,
+                current_tree,
+                tree.id,
+                honor_filemode=honor_filemode,
+                validate_path_element=validate_path_element,
+                symlink_fn=symlink_fn,
+                force_remove_untracked=True,
+            )
+        else:
+            raise Error(f"Invalid reset mode: {mode}")
 
 
 def get_remote_repo(

+ 93 - 0
tests/test_porcelain.py

@@ -2495,6 +2495,99 @@ class ResetTests(PorcelainTestCase):
         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,
+                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: