Skip to content

Commit bfba7b3

Browse files
committed
Add format-patch command to dfetch.
Fixes #943
1 parent 659c98f commit bfba7b3

14 files changed

Lines changed: 568 additions & 28 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Release 0.12.0 (unreleased)
1515
* Respect `NO_COLOR <https://no-color.org/>`_ (#960)
1616
* Group logging under a project name header (#953)
1717
* Introduce new ``update-patch`` command (#614)
18+
* Introduce new ``format-patch`` command (#943)
1819

1920
Release 0.11.0 (released 2026-01-03)
2021
====================================

dfetch/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import dfetch.commands.check
1414
import dfetch.commands.diff
1515
import dfetch.commands.environment
16+
import dfetch.commands.format_patch
1617
import dfetch.commands.freeze
1718
import dfetch.commands.import_
1819
import dfetch.commands.init
@@ -46,6 +47,7 @@ def create_parser() -> argparse.ArgumentParser:
4647
dfetch.commands.check.Check.create_menu(subparsers)
4748
dfetch.commands.diff.Diff.create_menu(subparsers)
4849
dfetch.commands.environment.Environment.create_menu(subparsers)
50+
dfetch.commands.format_patch.FormatPatch.create_menu(subparsers)
4951
dfetch.commands.freeze.Freeze.create_menu(subparsers)
5052
dfetch.commands.import_.Import.create_menu(subparsers)
5153
dfetch.commands.init.Init.create_menu(subparsers)

dfetch/commands/diff.py

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -32,35 +32,9 @@
3232
Using the generated patch
3333
=========================
3434
The patch can be used in the manifest; see the :ref:`patch` attribute for more information.
35-
It can also be sent to the upstream maintainer in case of bug fixes.
3635
37-
The generated patch is a relative patch and should be by applied specifying the base directory of the *git repo*.
38-
See below for the version control specifics. The patch will also contain the content of binary files.
39-
40-
.. tabs::
41-
42-
.. tab:: Git
43-
44-
.. code-block:: sh
45-
46-
git apply --verbose --directory='some-project' some-project.patch
47-
48-
.. tab:: SVN
49-
50-
.. code-block:: sh
51-
52-
svn patch some-project.patch
53-
54-
.. warning::
55-
56-
The path given to ``--directory`` when applying the patch in a git repo, *must* be relative to the base
57-
directory of the repo, i.e. the folder where the ``.git`` folder is located.
58-
59-
For example if you have the patch ``Core/MyModule/MySubmodule.patch``
60-
for files in the directory ``Core/MyModule/MySubmodule/`` and your current working directory is ``Core/MyModule/``.
61-
The correct command would be:
62-
63-
``git apply --verbose --directory='Core/MyModule/MySubmodule' MySubmodule.patch``
36+
Because the patch is generated relative to the project's directory, you should use the :ref:`format-patch`
37+
command to reformat the patch for upstream use.
6438
6539
"""
6640

dfetch/commands/format_patch.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Formatting patches.
2+
3+
*Dfetch* allows you to keep local changes to external projects in the form of
4+
patch files. These patch files should be created with the `dfetch diff` command.
5+
However, these patch files are relative the :ref:`source directory <source-dir>`
6+
of the project inside the superproject. This makes it hard to apply these patches
7+
upstream, as upstream projects usually expect patches to be relative to their
8+
root directory. The ``format-patch`` command reformats all patches of a project
9+
to make them usable for the upstream project.
10+
11+
.. code-block:: sh
12+
13+
dfetch format-patch some-project
14+
15+
.. scenario-include:: ../features/format-patch-in-git.feature
16+
17+
"""
18+
19+
import argparse
20+
import pathlib
21+
import re
22+
23+
import dfetch.commands.command
24+
import dfetch.manifest.project
25+
import dfetch.project
26+
from dfetch.log import get_logger
27+
from dfetch.project.superproject import SuperProject
28+
from dfetch.util.util import catch_runtime_exceptions, in_directory
29+
from dfetch.vcs.patch import PatchAuthor, PatchInfo, format_patch_with_prefix
30+
31+
logger = get_logger(__name__)
32+
33+
34+
class FormatPatch(dfetch.commands.command.Command):
35+
"""Format a patch to reflect the last changes.
36+
37+
The ``format-patch`` reformats all patches of a project to make
38+
them usable for the upstream project.
39+
"""
40+
41+
@staticmethod
42+
def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None:
43+
"""Add the menu for the format-patch action."""
44+
parser = dfetch.commands.command.Command.parser(subparsers, FormatPatch)
45+
parser.add_argument(
46+
"projects",
47+
metavar="<project>",
48+
type=str,
49+
nargs="*",
50+
help="Specific project(s) to format patches of",
51+
)
52+
parser.add_argument(
53+
"-o",
54+
"--output-directory",
55+
metavar="<output_directory>",
56+
type=str,
57+
default=".",
58+
help="Output directory for formatted patches",
59+
)
60+
61+
def __call__(self, args: argparse.Namespace) -> None:
62+
"""Perform the format patch."""
63+
superproject = SuperProject()
64+
65+
exceptions: list[str] = []
66+
67+
output_dir_path = pathlib.Path(args.output_directory).resolve()
68+
69+
if not output_dir_path.is_relative_to(superproject.root_directory):
70+
raise RuntimeError(
71+
f"Output directory '{output_dir_path}' must be inside"
72+
f" the superproject root '{superproject.root_directory}'"
73+
)
74+
75+
output_dir_path.mkdir(parents=True, exist_ok=True)
76+
77+
with in_directory(superproject.root_directory):
78+
for project in superproject.manifest.selected_projects(args.projects):
79+
with catch_runtime_exceptions(exceptions) as exceptions:
80+
subproject = dfetch.project.make(project)
81+
82+
# Check if the project has a patch, maybe suggest creating one?
83+
if not subproject.patch:
84+
logger.print_warning_line(
85+
project.name,
86+
f'skipped - there is no patch file, use "dfetch diff {project.name}"'
87+
" to generate one instead",
88+
)
89+
continue
90+
91+
for idx, patch in enumerate(subproject.patch, start=1):
92+
93+
version = subproject.on_disk_version()
94+
95+
patch_text = format_patch_with_prefix(
96+
patch_text=pathlib.Path(patch).read_bytes(),
97+
patch_info=PatchInfo(
98+
author=PatchAuthor(
99+
name=superproject.get_username(),
100+
email=superproject.get_useremail(),
101+
),
102+
subject=f"Patch for {project.name}",
103+
total_patches=len(subproject.patch),
104+
current_patch_idx=idx,
105+
revision="" if not version else version.revision,
106+
),
107+
path_prefix=re.split(r"\*", subproject.source, 1)[0].rstrip(
108+
"/"
109+
),
110+
)
111+
112+
output_patch_file = output_dir_path / pathlib.Path(patch).name
113+
output_patch_file.write_text(patch_text)
114+
115+
logger.print_info_line(
116+
project.name,
117+
f"formatted patch written to {output_patch_file}",
118+
)
119+
120+
if exceptions:
121+
raise RuntimeError("\n".join(exceptions))

dfetch/project/superproject.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import getpass
1112
import os
1213
import pathlib
1314
from collections.abc import Sequence
@@ -110,3 +111,32 @@ def current_revision(self) -> str:
110111
return SvnRepo.get_last_changed_revision(self.root_directory)
111112

112113
return ""
114+
115+
def get_username(self) -> str:
116+
"""Get the username of the superproject VCS."""
117+
username = ""
118+
if GitLocalRepo(self.root_directory).is_git():
119+
username = GitLocalRepo(self.root_directory).get_username()
120+
121+
elif SvnRepo(self.root_directory).is_svn():
122+
username = SvnRepo(self.root_directory).get_username()
123+
124+
username = username or getpass.getuser()
125+
if not username:
126+
try:
127+
username = os.getlogin()
128+
except OSError:
129+
username = "unknown"
130+
return username
131+
132+
def get_useremail(self) -> str:
133+
"""Get the user email of the superproject VCS."""
134+
email = ""
135+
if GitLocalRepo(self.root_directory).is_git():
136+
email = GitLocalRepo(self.root_directory).get_useremail()
137+
138+
elif SvnRepo(self.root_directory).is_svn():
139+
email = SvnRepo(self.root_directory).get_useremail()
140+
141+
username = self.get_username() or "unknown"
142+
return email or f"{username}@example.com"

dfetch/vcs/git.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,3 +589,27 @@ def find_branch_containing_sha(self, sha: str) -> str:
589589
]
590590

591591
return "" if not branches else branches[0]
592+
593+
def get_username(self) -> str:
594+
"""Get the username of the local git repo."""
595+
try:
596+
with in_directory(self._path):
597+
result = run_on_cmdline(
598+
logger,
599+
["git", "config", "user.name"],
600+
)
601+
return str(result.stdout.decode().strip())
602+
except SubprocessCommandError:
603+
return ""
604+
605+
def get_useremail(self) -> str:
606+
"""Get the user email of the local git repo."""
607+
try:
608+
with in_directory(self._path):
609+
result = run_on_cmdline(
610+
logger,
611+
["git", "config", "user.email"],
612+
)
613+
return str(result.stdout.decode().strip())
614+
except SubprocessCommandError:
615+
return ""

dfetch/vcs/patch.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Various patch utilities for VCS systems."""
22

3+
import datetime
34
import difflib
45
import hashlib
56
import stat
@@ -191,3 +192,87 @@ def reverse_patch(patch_text: bytes) -> str:
191192
reverse_patch_lines.append(b"") # blank line between files
192193

193194
return (b"\n".join(reverse_patch_lines)).decode(encoding="UTF-8")
195+
196+
197+
@dataclass
198+
class PatchAuthor:
199+
"""Information about a patch author."""
200+
201+
name: str
202+
email: str
203+
204+
205+
@dataclass
206+
class PatchInfo:
207+
"""Information about a patch file."""
208+
209+
author: PatchAuthor
210+
subject: str
211+
total_patches: int = 1
212+
current_patch_idx: int = 1
213+
revision: str = ""
214+
date: datetime.datetime = datetime.datetime.now(tz=datetime.timezone.utc)
215+
description: str = ""
216+
217+
def to_string(self) -> str:
218+
"""Convert patch info to a string."""
219+
subject_line = (
220+
f"[PATCH {self.current_patch_idx}/{self.total_patches}] {self.subject}"
221+
if self.total_patches > 1
222+
else f"[PATCH] {self.subject}"
223+
)
224+
return (
225+
f"From {self.revision or '0000000000000000000000000000000000000000'} Mon Sep 17 00:00:00 2001\n"
226+
f"From: {self.author.name} <{self.author.email}>\n"
227+
f"Date: {self.date:%a, %d %b %Y %H:%M:%S +0000}\n"
228+
f"Subject: {subject_line}\n"
229+
"\n"
230+
f"{self.description if self.description else self.subject}\n"
231+
)
232+
233+
234+
def format_patch_with_prefix(
235+
patch_text: bytes, patch_info: PatchInfo, path_prefix: str
236+
) -> str:
237+
"""Rewrite a patch to prefix file paths and add a mail-style header."""
238+
patch = patch_ng.fromstring(patch_text)
239+
240+
if not patch:
241+
return ""
242+
243+
out: list[bytes] = patch_info.to_string().encode("utf-8").splitlines()
244+
245+
for file in patch.items:
246+
# normalize prefix (no leading/trailing slash surprises)
247+
prefix = path_prefix.strip("/").encode()
248+
prefix = prefix + b"/" if prefix else b""
249+
250+
src = file.source
251+
tgt = file.target
252+
253+
# strip a/ b/ if present
254+
if src.startswith(b"a/"):
255+
src = src[2:]
256+
if tgt.startswith(b"b/"):
257+
tgt = tgt[2:]
258+
259+
new_src = b"a/" + prefix + src
260+
new_tgt = b"b/" + prefix + tgt
261+
262+
# diff header
263+
out.append(b"")
264+
out.append(b"diff --git " + new_src + b" " + new_tgt)
265+
out.append(b"--- " + new_src)
266+
out.append(b"+++ " + new_tgt)
267+
268+
for hunk in file.hunks:
269+
out.append(
270+
f"@@ -{hunk.startsrc},{hunk.linessrc} "
271+
f"+{hunk.starttgt},{hunk.linestgt} @@".encode()
272+
)
273+
for line in hunk.text:
274+
out.append(line.rstrip(b"\n"))
275+
276+
out.append(b"") # blank line between files
277+
278+
return b"\n".join(out).decode("utf-8")

dfetch/vcs/svn.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,3 +334,26 @@ def create_diff(
334334
patch_text = run_on_cmdline(logger, cmd).stdout
335335

336336
return filter_patch(patch_text, ignore)
337+
338+
def get_username(self) -> str:
339+
"""Get the username of the local svn repo."""
340+
try:
341+
result = run_on_cmdline(
342+
logger,
343+
[
344+
"svn",
345+
"info",
346+
"--non-interactive",
347+
"--show-item",
348+
"author",
349+
self._path,
350+
],
351+
)
352+
return str(result.stdout.decode().strip())
353+
except SubprocessCommandError:
354+
return ""
355+
356+
def get_useremail(self) -> str:
357+
"""Get the user email of the local svn repo."""
358+
# SVN does not have user email concept
359+
return ""

0 commit comments

Comments
 (0)