Skip to content

Commit 86faac9

Browse files
committed
Add format-patch command to dfetch.
Fixes #943
1 parent a79ee22 commit 86faac9

14 files changed

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

dfetch/project/superproject.py

Lines changed: 23 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,25 @@ 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+
return username or getpass.getuser() or os.getlogin()
125+
126+
def get_useremail(self) -> str:
127+
"""Get the user email of the superproject VCS."""
128+
email = ""
129+
if GitLocalRepo(self.root_directory).is_git():
130+
email = GitLocalRepo(self.root_directory).get_useremail()
131+
132+
elif SvnRepo(self.root_directory).is_svn():
133+
email = SvnRepo(self.root_directory).get_useremail()
134+
135+
return email or f"{self.get_username()}@example.com"

dfetch/vcs/git.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,3 +589,25 @@ 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+
result = run_on_cmdline(
597+
logger,
598+
["git", "config", "user.name"],
599+
)
600+
return str(result.stdout.decode().strip())
601+
except SubprocessCommandError:
602+
return ""
603+
604+
def get_useremail(self) -> str:
605+
"""Get the user email of the local git repo."""
606+
try:
607+
result = run_on_cmdline(
608+
logger,
609+
["git", "config", "user.email"],
610+
)
611+
return str(result.stdout.decode().strip())
612+
except SubprocessCommandError:
613+
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_files: int = 1
212+
current_idx: int = 1
213+
revision: str = ""
214+
date: datetime.datetime = datetime.datetime.utcnow()
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_idx}/{self.total_files}] {self.subject}"
221+
if self.total_files > 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)