Bläddra i källkod

Add format-patch command

Jelmer Vernooij 1 månad sedan
förälder
incheckning
af20d7c4e1
7 ändrade filer med 606 tillägg och 5 borttagningar
  1. 3 3
      Cargo.lock
  2. 2 0
      NEWS
  3. 54 1
      dulwich/cli.py
  4. 33 0
      dulwich/objectspec.py
  5. 120 1
      dulwich/porcelain.py
  6. 260 0
      tests/test_cli.py
  7. 134 0
      tests/test_porcelain.py

+ 3 - 3
Cargo.lock

@@ -10,7 +10,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
 
 [[package]]
 name = "diff-tree-py"
-version = "0.23.1"
+version = "0.23.3"
 dependencies = [
  "pyo3",
 ]
@@ -50,7 +50,7 @@ dependencies = [
 
 [[package]]
 name = "objects-py"
-version = "0.23.1"
+version = "0.23.3"
 dependencies = [
  "memchr",
  "pyo3",
@@ -64,7 +64,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
 
 [[package]]
 name = "pack-py"
-version = "0.23.1"
+version = "0.23.3"
 dependencies = [
  "memchr",
  "pyo3",

+ 2 - 0
NEWS

@@ -1,5 +1,7 @@
 0.23.3	UNRELEASED
 
+ * Add ``format-patch`` command in porcelain. (Jelmer Vernooij)
+
  * Add support for ``core.commitGraph`` configuration setting to control
    whether commit-graph files are used for performance optimization.
    (Jelmer Vernooij)

+ 54 - 1
dulwich/cli.py

@@ -41,7 +41,7 @@ from .client import GitProtocolError, get_transport_and_path
 from .errors import ApplyDeltaError
 from .index import Index
 from .objects import valid_hexsha
-from .objectspec import parse_commit
+from .objectspec import parse_commit, parse_committish_range
 from .pack import Pack, sha_to_hex
 from .repo import Repo
 
@@ -1955,6 +1955,58 @@ For a list of supported commands, see 'dulwich help -a'.
             )
 
 
+class cmd_format_patch(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "committish",
+            nargs="?",
+            help="Commit or commit range (e.g., HEAD~3..HEAD or origin/master..HEAD)",
+        )
+        parser.add_argument(
+            "-n",
+            "--numbered",
+            type=int,
+            default=1,
+            help="Number of commits to format (default: 1)",
+        )
+        parser.add_argument(
+            "-o",
+            "--output-directory",
+            dest="outdir",
+            help="Output directory for patches",
+        )
+        parser.add_argument(
+            "--stdout",
+            action="store_true",
+            help="Output patches to stdout",
+        )
+        args = parser.parse_args(args)
+
+        # Parse committish using the new function
+        committish = None
+        if args.committish:
+            with Repo(".") as r:
+                range_result = parse_committish_range(r, args.committish)
+                if range_result:
+                    committish = range_result
+                else:
+                    committish = args.committish
+
+        filenames = porcelain.format_patch(
+            ".",
+            committish=committish,
+            outstream=sys.stdout,
+            outdir=args.outdir,
+            n=args.numbered,
+            stdout=args.stdout,
+        )
+
+        if not args.stdout:
+            for filename in filenames:
+                print(filename)
+
+
 commands = {
     "add": cmd_add,
     "annotate": cmd_annotate,
@@ -1980,6 +2032,7 @@ commands = {
     "fetch": cmd_fetch,
     "filter-branch": cmd_filter_branch,
     "for-each-ref": cmd_for_each_ref,
+    "format-patch": cmd_format_patch,
     "fsck": cmd_fsck,
     "gc": cmd_gc,
     "help": cmd_help,

+ 33 - 0
dulwich/objectspec.py

@@ -210,6 +210,39 @@ def parse_refs(container, refspecs):
     return ret
 
 
+def parse_committish_range(
+    repo: "Repo", committish: Union[str, bytes]
+) -> Optional[tuple[bytes, bytes]]:
+    """Parse a string referring to a commit range.
+
+    Args:
+      repo: A `Repo` object
+      committish: A string referring to a commit or range (e.g., "HEAD~3..HEAD")
+
+    Returns:
+      None if committish is a single commit reference
+      A tuple of (start_commit_id, end_commit_id) if it's a range
+    Raises:
+      KeyError: When the commits can not be found
+      ValueError: If the range can not be parsed
+    """
+    committish = to_bytes(committish)
+    if b".." not in committish:
+        return None
+
+    parts = committish.split(b"..", 1)
+    if len(parts) != 2:
+        raise ValueError(f"Invalid commit range: {committish.decode('utf-8')}")
+
+    start_ref = parts[0]
+    end_ref = parts[1] if parts[1] else b"HEAD"
+
+    start_commit = parse_commit(repo, start_ref)
+    end_commit = parse_commit(repo, end_ref)
+
+    return (start_commit.id, end_commit.id)
+
+
 def parse_commit_range(
     repo: "Repo", committishs: Union[str, bytes]
 ) -> Iterator["Commit"]:

+ 120 - 1
dulwich/porcelain.py

@@ -130,7 +130,7 @@ from .objectspec import (
     parse_tree,
 )
 from .pack import write_pack_from_container, write_pack_index
-from .patch import write_tree_diff
+from .patch import write_commit_patch, write_tree_diff
 from .protocol import ZERO_SHA, Protocol
 from .refs import (
     LOCAL_BRANCH_PREFIX,
@@ -4418,6 +4418,125 @@ def filter_branch(
             raise Error(str(e)) from e
 
 
+def format_patch(
+    repo=".",
+    committish=None,
+    outstream=sys.stdout,
+    outdir=None,
+    n=1,
+    stdout=False,
+    version=None,
+) -> list[str]:
+    """Generate patches suitable for git am.
+
+    Args:
+      repo: Path to repository
+      committish: Commit-ish or commit range to generate patches for.
+        Can be a single commit id, or a tuple of (start, end) commit ids
+        for a range. If None, formats the last n commits from HEAD.
+      outstream: Stream to write to if stdout=True
+      outdir: Directory to write patch files to (default: current directory)
+      n: Number of patches to generate if committish is None
+      stdout: Write patches to stdout instead of files
+      version: Version string to include in patches (default: Dulwich version)
+
+    Returns:
+      List of patch filenames that were created (empty if stdout=True)
+    """
+    if outdir is None:
+        outdir = "."
+
+    filenames = []
+
+    with open_repo_closing(repo) as r:
+        # Determine which commits to format
+        commits_to_format = []
+
+        if committish is None:
+            # Get the last n commits from HEAD
+            try:
+                walker = r.get_walker()
+                for entry in walker:
+                    commits_to_format.append(entry.commit)
+                    if len(commits_to_format) >= n:
+                        break
+                commits_to_format.reverse()
+            except KeyError:
+                # No HEAD or empty repository
+                pass
+        elif isinstance(committish, tuple):
+            # Handle commit range (start, end)
+            start_id, end_id = committish
+
+            # Walk from end back to start
+            walker = r.get_walker(include=[end_id], exclude=[start_id])
+            for entry in walker:
+                commits_to_format.append(entry.commit)
+            commits_to_format.reverse()
+        else:
+            # Single commit
+            commit = r.object_store[committish]
+            commits_to_format.append(commit)
+
+        # Generate patches
+        total = len(commits_to_format)
+        for i, commit in enumerate(commits_to_format, 1):
+            # Get the parent
+            if commit.parents:
+                parent_id = commit.parents[0]
+                parent = r.object_store[parent_id]
+            else:
+                parent = None
+
+            # Generate the diff
+            from io import BytesIO
+
+            diff_content = BytesIO()
+            if parent:
+                write_tree_diff(
+                    diff_content,
+                    r.object_store,
+                    parent.tree,
+                    commit.tree,
+                )
+            else:
+                # Initial commit - diff against empty tree
+                write_tree_diff(
+                    diff_content,
+                    r.object_store,
+                    None,
+                    commit.tree,
+                )
+
+            # Generate patch with commit metadata
+            if stdout:
+                write_commit_patch(
+                    outstream.buffer if hasattr(outstream, "buffer") else outstream,
+                    commit,
+                    diff_content.getvalue(),
+                    (i, total),
+                    version=version,
+                )
+            else:
+                # Generate filename
+                from .patch import get_summary
+
+                summary = get_summary(commit)
+                filename = os.path.join(outdir, f"{i:04d}-{summary}.patch")
+
+                with open(filename, "wb") as f:
+                    write_commit_patch(
+                        f,
+                        commit,
+                        diff_content.getvalue(),
+                        (i, total),
+                        version=version,
+                    )
+                filenames.append(filename)
+
+    return filenames
+
+
 def bisect_start(
     repo=".",
     bad: Optional[Union[str, bytes, Commit, Tag]] = None,

+ 260 - 0
tests/test_cli.py

@@ -588,6 +588,266 @@ class ShowCommandTest(DulwichCliTestCase):
         self.assertIn("Test commit", stdout)
 
 
+class FormatPatchCommandTest(DulwichCliTestCase):
+    """Tests for format-patch command."""
+
+    def test_format_patch_single_commit(self):
+        # Create a commit with actual content
+        from dulwich.objects import Blob, Tree
+
+        # Initial commit
+        tree1 = Tree()
+        self.repo.object_store.add_object(tree1)
+        self.repo.do_commit(
+            message=b"Initial commit",
+            tree=tree1.id,
+        )
+
+        # Second commit with a file
+        blob = Blob.from_string(b"Hello, World!\n")
+        self.repo.object_store.add_object(blob)
+        tree2 = Tree()
+        tree2.add(b"hello.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree2)
+        self.repo.do_commit(
+            message=b"Add hello.txt",
+            tree=tree2.id,
+        )
+
+        # Test format-patch for last commit
+        result, stdout, stderr = self._run_cli("format-patch", "-n", "1")
+        self.assertEqual(result, None)
+        self.assertIn("0001-Add-hello.txt.patch", stdout)
+
+        # Check patch contents
+        patch_file = os.path.join(self.repo_path, "0001-Add-hello.txt.patch")
+        with open(patch_file, "rb") as f:
+            content = f.read()
+            # Check header
+            self.assertIn(b"Subject: [PATCH 1/1] Add hello.txt", content)
+            self.assertIn(b"From:", content)
+            self.assertIn(b"Date:", content)
+            # Check diff content
+            self.assertIn(b"diff --git a/hello.txt b/hello.txt", content)
+            self.assertIn(b"new file mode", content)
+            self.assertIn(b"+Hello, World!", content)
+            # Check footer
+            self.assertIn(b"-- \nDulwich", content)
+
+        # Clean up
+        os.remove(patch_file)
+
+    def test_format_patch_multiple_commits(self):
+        from dulwich.objects import Blob, Tree
+
+        # Initial commit
+        tree1 = Tree()
+        self.repo.object_store.add_object(tree1)
+        self.repo.do_commit(
+            message=b"Initial commit",
+            tree=tree1.id,
+        )
+
+        # Second commit
+        blob1 = Blob.from_string(b"File 1 content\n")
+        self.repo.object_store.add_object(blob1)
+        tree2 = Tree()
+        tree2.add(b"file1.txt", 0o100644, blob1.id)
+        self.repo.object_store.add_object(tree2)
+        self.repo.do_commit(
+            message=b"Add file1.txt",
+            tree=tree2.id,
+        )
+
+        # Third commit
+        blob2 = Blob.from_string(b"File 2 content\n")
+        self.repo.object_store.add_object(blob2)
+        tree3 = Tree()
+        tree3.add(b"file1.txt", 0o100644, blob1.id)
+        tree3.add(b"file2.txt", 0o100644, blob2.id)
+        self.repo.object_store.add_object(tree3)
+        self.repo.do_commit(
+            message=b"Add file2.txt",
+            tree=tree3.id,
+        )
+
+        # Test format-patch for last 2 commits
+        result, stdout, stderr = self._run_cli("format-patch", "-n", "2")
+        self.assertEqual(result, None)
+        self.assertIn("0001-Add-file1.txt.patch", stdout)
+        self.assertIn("0002-Add-file2.txt.patch", stdout)
+
+        # Check first patch
+        with open(os.path.join(self.repo_path, "0001-Add-file1.txt.patch"), "rb") as f:
+            content = f.read()
+            self.assertIn(b"Subject: [PATCH 1/2] Add file1.txt", content)
+            self.assertIn(b"+File 1 content", content)
+
+        # Check second patch
+        with open(os.path.join(self.repo_path, "0002-Add-file2.txt.patch"), "rb") as f:
+            content = f.read()
+            self.assertIn(b"Subject: [PATCH 2/2] Add file2.txt", content)
+            self.assertIn(b"+File 2 content", content)
+
+        # Clean up
+        os.remove(os.path.join(self.repo_path, "0001-Add-file1.txt.patch"))
+        os.remove(os.path.join(self.repo_path, "0002-Add-file2.txt.patch"))
+
+    def test_format_patch_output_directory(self):
+        from dulwich.objects import Blob, Tree
+
+        # Create a commit
+        blob = Blob.from_string(b"Test content\n")
+        self.repo.object_store.add_object(blob)
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+        self.repo.do_commit(
+            message=b"Test commit",
+            tree=tree.id,
+        )
+
+        # Create output directory
+        output_dir = os.path.join(self.test_dir, "patches")
+        os.makedirs(output_dir)
+
+        # Test format-patch with output directory
+        result, stdout, stderr = self._run_cli(
+            "format-patch", "-o", output_dir, "-n", "1"
+        )
+        self.assertEqual(result, None)
+
+        # Check that file was created in output directory with correct content
+        patch_file = os.path.join(output_dir, "0001-Test-commit.patch")
+        self.assertTrue(os.path.exists(patch_file))
+        with open(patch_file, "rb") as f:
+            content = f.read()
+            self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
+            self.assertIn(b"+Test content", content)
+
+    def test_format_patch_commit_range(self):
+        from dulwich.objects import Blob, Tree
+
+        # Create commits with actual file changes
+        commits = []
+        trees = []
+
+        # Initial empty commit
+        tree0 = Tree()
+        self.repo.object_store.add_object(tree0)
+        trees.append(tree0)
+        c0 = self.repo.do_commit(
+            message=b"Initial commit",
+            tree=tree0.id,
+        )
+        commits.append(c0)
+
+        # Add three files in separate commits
+        for i in range(1, 4):
+            blob = Blob.from_string(f"Content {i}\n".encode())
+            self.repo.object_store.add_object(blob)
+            tree = Tree()
+            # Copy previous files
+            for j in range(1, i):
+                prev_blob_id = trees[j][f"file{j}.txt".encode()][1]
+                tree.add(f"file{j}.txt".encode(), 0o100644, prev_blob_id)
+            # Add new file
+            tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
+            self.repo.object_store.add_object(tree)
+            trees.append(tree)
+
+            c = self.repo.do_commit(
+                message=f"Add file{i}.txt".encode(),
+                tree=tree.id,
+            )
+            commits.append(c)
+
+        # Test format-patch with commit range (should get commits 2 and 3)
+        result, stdout, stderr = self._run_cli(
+            "format-patch", f"{commits[1].decode()}..{commits[3].decode()}"
+        )
+        self.assertEqual(result, None)
+
+        # Should create patches for commits 2 and 3
+        self.assertIn("0001-Add-file2.txt.patch", stdout)
+        self.assertIn("0002-Add-file3.txt.patch", stdout)
+
+        # Verify patch contents
+        with open(os.path.join(self.repo_path, "0001-Add-file2.txt.patch"), "rb") as f:
+            content = f.read()
+            self.assertIn(b"Subject: [PATCH 1/2] Add file2.txt", content)
+            self.assertIn(b"+Content 2", content)
+            self.assertNotIn(b"file3.txt", content)  # Should not include file3
+
+        with open(os.path.join(self.repo_path, "0002-Add-file3.txt.patch"), "rb") as f:
+            content = f.read()
+            self.assertIn(b"Subject: [PATCH 2/2] Add file3.txt", content)
+            self.assertIn(b"+Content 3", content)
+            self.assertNotIn(b"file2.txt", content)  # Should not modify file2
+
+        # Clean up
+        os.remove(os.path.join(self.repo_path, "0001-Add-file2.txt.patch"))
+        os.remove(os.path.join(self.repo_path, "0002-Add-file3.txt.patch"))
+
+    def test_format_patch_stdout(self):
+        from dulwich.objects import Blob, Tree
+
+        # Create a commit with modified file
+        tree1 = Tree()
+        blob1 = Blob.from_string(b"Original content\n")
+        self.repo.object_store.add_object(blob1)
+        tree1.add(b"file.txt", 0o100644, blob1.id)
+        self.repo.object_store.add_object(tree1)
+        self.repo.do_commit(
+            message=b"Initial commit",
+            tree=tree1.id,
+        )
+
+        tree2 = Tree()
+        blob2 = Blob.from_string(b"Modified content\n")
+        self.repo.object_store.add_object(blob2)
+        tree2.add(b"file.txt", 0o100644, blob2.id)
+        self.repo.object_store.add_object(tree2)
+        self.repo.do_commit(
+            message=b"Modify file.txt",
+            tree=tree2.id,
+        )
+
+        # Mock stdout as a BytesIO for binary output
+        stdout_stream = io.BytesIO()
+        stdout_stream.buffer = stdout_stream
+
+        # Run command with --stdout
+        old_stdout = sys.stdout
+        old_stderr = sys.stderr
+        old_cwd = os.getcwd()
+        try:
+            sys.stdout = stdout_stream
+            sys.stderr = io.StringIO()
+            os.chdir(self.repo_path)
+            cli.main(["format-patch", "--stdout", "-n", "1"])
+        finally:
+            sys.stdout = old_stdout
+            sys.stderr = old_stderr
+            os.chdir(old_cwd)
+
+        # Check output
+        stdout_stream.seek(0)
+        output = stdout_stream.read()
+        self.assertIn(b"Subject: [PATCH 1/1] Modify file.txt", output)
+        self.assertIn(b"diff --git a/file.txt b/file.txt", output)
+        self.assertIn(b"-Original content", output)
+        self.assertIn(b"+Modified content", output)
+        self.assertIn(b"-- \nDulwich", output)
+
+    def test_format_patch_empty_repo(self):
+        # Test with empty repository
+        result, stdout, stderr = self._run_cli("format-patch", "-n", "5")
+        self.assertEqual(result, None)
+        # Should produce no output for empty repo
+        self.assertEqual(stdout.strip(), "")
+
+
 class FetchPackCommandTest(DulwichCliTestCase):
     """Tests for fetch-pack command."""
 

+ 134 - 0
tests/test_porcelain.py

@@ -2374,6 +2374,140 @@ index ea5c7bf..fd38bcb 100644
         )
 
 
+class FormatPatchTests(PorcelainTestCase):
+    def test_format_patch_single_commit(self) -> None:
+        # Create initial commit
+        tree1 = Tree()
+        c1 = make_commit(
+            tree=tree1,
+            message=b"Initial commit",
+        )
+        self.repo.object_store.add_objects([(tree1, None), (c1, None)])
+
+        # Create second commit
+        b = Blob.from_string(b"modified")
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, b.id)
+        c2 = make_commit(
+            tree=tree,
+            parents=[c1.id],
+            message=b"Add test.txt",
+        )
+        self.repo.object_store.add_objects([(b, None), (tree, None), (c2, None)])
+        self.repo[b"HEAD"] = c2.id
+
+        # Generate patch for single commit
+        with tempfile.TemporaryDirectory() as tmpdir:
+            patches = porcelain.format_patch(
+                self.repo.path,
+                committish=c2.id,
+                outdir=tmpdir,
+            )
+
+            self.assertEqual(len(patches), 1)
+            self.assertTrue(patches[0].endswith("-Add-test.txt.patch"))
+
+            # Verify patch content
+            with open(patches[0], "rb") as f:
+                content = f.read()
+                self.assertIn(b"Subject: [PATCH 1/1] Add test.txt", content)
+                self.assertIn(b"+modified", content)
+
+    def test_format_patch_multiple_commits(self) -> None:
+        # Create commit chain
+        commits = []
+        for i in range(3):
+            blob = Blob.from_string(f"content {i}".encode())
+            tree = Tree()
+            tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
+
+            parents = [commits[-1].id] if commits else []
+            commit = make_commit(
+                tree=tree,
+                parents=parents,
+                message=f"Commit {i}".encode(),
+            )
+            self.repo.object_store.add_objects(
+                [(blob, None), (tree, None), (commit, None)]
+            )
+            commits.append(commit)
+
+        self.repo[b"HEAD"] = commits[-1].id
+
+        # Test generating last 2 commits
+        with tempfile.TemporaryDirectory() as tmpdir:
+            patches = porcelain.format_patch(
+                self.repo.path,
+                n=2,
+                outdir=tmpdir,
+            )
+
+            self.assertEqual(len(patches), 2)
+            self.assertTrue(patches[0].endswith("-Commit-1.patch"))
+            self.assertTrue(patches[1].endswith("-Commit-2.patch"))
+
+            # Check patch numbering
+            with open(patches[0], "rb") as f:
+                self.assertIn(b"Subject: [PATCH 1/2] Commit 1", f.read())
+            with open(patches[1], "rb") as f:
+                self.assertIn(b"Subject: [PATCH 2/2] Commit 2", f.read())
+
+    def test_format_patch_range(self) -> None:
+        # Create commit chain
+        c1, c2, c3 = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
+        self.repo[b"HEAD"] = c3.id
+
+        # Test commit range
+        with tempfile.TemporaryDirectory() as tmpdir:
+            patches = porcelain.format_patch(
+                self.repo.path,
+                committish=(c1.id, c3.id),
+                outdir=tmpdir,
+            )
+
+            # Should include c2 and c3
+            self.assertEqual(len(patches), 2)
+
+    def test_format_patch_stdout(self) -> None:
+        # Create a commit
+        blob = Blob.from_string(b"test content")
+        tree = Tree()
+        tree.add(b"test.txt", 0o100644, blob.id)
+        commit = make_commit(
+            tree=tree,
+            message=b"Test commit",
+        )
+        self.repo.object_store.add_objects([(blob, None), (tree, None), (commit, None)])
+        self.repo[b"HEAD"] = commit.id
+
+        # Test stdout output
+        outstream = BytesIO()
+        patches = porcelain.format_patch(
+            self.repo.path,
+            committish=commit.id,
+            stdout=True,
+            outstream=outstream,
+        )
+
+        # Should return empty list when writing to stdout
+        self.assertEqual(patches, [])
+
+        # Check stdout content
+        outstream.seek(0)
+        content = outstream.read()
+        self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
+        self.assertIn(b"diff --git", content)
+
+    def test_format_patch_no_commits(self) -> None:
+        # Test with a new repository with no commits
+        # Just remove HEAD to simulate empty repo
+        patches = porcelain.format_patch(
+            self.repo.path,
+            n=5,
+        )
+        self.assertEqual(patches, [])
+
+
 class SymbolicRefTests(PorcelainTestCase):
     def test_set_wrong_symbolic_ref(self) -> None:
         c1, c2, c3 = build_commit_graph(