ソースを参照

Fix porcelain.add() to stage modified files when no paths specified

When called without paths, porcelain.add() now stages both untracked and
modified files from the entire repository, behaving like 'git add -A'.
Previously, only untracked files were staged, which was inconsistent
with Git's behavior.

Fixes #746
Jelmer Vernooij 2 ヶ月 前
コミット
a7ba57e8da
3 ファイル変更107 行追加47 行削除
  1. 5 0
      NEWS
  2. 31 17
      dulwich/porcelain.py
  3. 71 30
      tests/test_porcelain.py

+ 5 - 0
NEWS

@@ -1,5 +1,10 @@
 0.22.9	UNRELEASED
 
+ * Fix ``porcelain.add()`` to stage both untracked and modified files when no
+   paths are specified. Previously, only untracked files were staged, inconsistent
+   with Git's behavior. Now behaves like ``git add -A`` when called without paths.
+   (Jelmer Vernooij, #746)
+
  * Fix ``porcelain.add()`` symlink handling to allow adding symlinks that point
    outside the repository. Previously, the function would fail when trying to
    add a symlink pointing outside the repo due to aggressive path resolution.

+ 31 - 17
dulwich/porcelain.py

@@ -587,7 +587,7 @@ def add(repo=".", paths=None):
 
     Args:
       repo: Repository for the files
-      paths: Paths to add. If None, stages all untracked files from the
+      paths: Paths to add. If None, stages all untracked and modified files from the
         current working directory (mimicking 'git add .' behavior).
     Returns: Tuple with set of added files and ignored files
 
@@ -595,27 +595,23 @@ def add(repo=".", paths=None):
     contain the path to an ignored directory (with trailing slash). Individual
     files within ignored directories will not be returned.
 
-    Note: When paths=None, this function respects the current working directory,
-    so if called from a subdirectory, it will only add files from that
-    subdirectory and below, matching Git's behavior.
+    Note: When paths=None, this function adds all untracked and modified files
+    from the entire repository, mimicking 'git add -A' behavior.
     """
     ignored = set()
     with open_repo_closing(repo) as r:
         repo_path = Path(r.path).resolve()
         ignore_manager = IgnoreFilterManager.from_repo(r)
+
+        # Get unstaged changes once for the entire operation
+        index = r.open_index()
+        normalizer = r.get_blob_normalizer()
+        filter_callback = normalizer.checkin_normalize
+        all_unstaged_paths = list(get_unstaged_changes(index, r.path, filter_callback))
+
         if not paths:
-            cwd = Path(os.getcwd()).resolve()
-            paths = list(
-                get_untracked_paths(
-                    str(cwd),
-                    str(repo_path),
-                    r.open_index(),
-                )
-            )
-            # If we're in a subdirectory, adjust paths to be relative to repo root
-            if cwd != repo_path:
-                cwd_relative_to_repo = cwd.relative_to(repo_path)
-                paths = [str(cwd_relative_to_repo / p) for p in paths]
+            # When no paths specified, add all untracked and modified files from repo root
+            paths = [str(repo_path)]
         relpaths = []
         if not isinstance(paths, list):
             paths = [paths]
@@ -654,7 +650,7 @@ def add(repo=".", paths=None):
                     get_untracked_paths(
                         str(resolved_path),
                         str(repo_path),
-                        r.open_index(),
+                        index,
                     )
                 )
                 for untracked_path in current_untracked:
@@ -666,6 +662,24 @@ def add(repo=".", paths=None):
                         relpaths.append(untracked_path)
                     else:
                         ignored.add(untracked_path)
+
+                # Also add unstaged (modified) files within this directory
+                for unstaged_path in all_unstaged_paths:
+                    if isinstance(unstaged_path, bytes):
+                        unstaged_path = unstaged_path.decode("utf-8")
+
+                    # Check if this unstaged file is within the directory we're processing
+                    unstaged_full_path = repo_path / unstaged_path
+                    try:
+                        unstaged_full_path.relative_to(resolved_path)
+                        # File is within this directory, add it
+                        if not ignore_manager.is_ignored(unstaged_path):
+                            relpaths.append(unstaged_path)
+                        else:
+                            ignored.add(unstaged_path)
+                    except ValueError:
+                        # File is not within this directory, skip it
+                        continue
                 continue
 
             # FIXME: Support patterns

+ 71 - 30
tests/test_porcelain.py

@@ -955,7 +955,8 @@ class AddTests(PorcelainTestCase):
             os.chdir(cwd)
 
         index = self.repo.open_index()
-        self.assertEqual(sorted(index), [b"foo/blie"])
+        # 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")
@@ -1053,19 +1054,21 @@ class AddTests(PorcelainTestCase):
         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])
-        
+        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)
@@ -1080,20 +1083,20 @@ class AddTests(PorcelainTestCase):
             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)
@@ -1108,24 +1111,26 @@ class AddTests(PorcelainTestCase):
         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])
-        
+        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)
@@ -1137,14 +1142,14 @@ class AddTests(PorcelainTestCase):
         # 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)
@@ -1155,18 +1160,18 @@ class AddTests(PorcelainTestCase):
         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)
@@ -1176,11 +1181,11 @@ class AddTests(PorcelainTestCase):
         # Create a symlink to a system directory
         symlink_path = os.path.join(self.repo.path, "link_to_tmp")
         os.symlink("/tmp", 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))
 
@@ -1192,21 +1197,21 @@ class AddTests(PorcelainTestCase):
         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)
@@ -1406,6 +1411,42 @@ class AddTests(PorcelainTestCase):
         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)
+
 
 class RemoveTests(PorcelainTestCase):
     def test_remove_file(self) -> None: