Compare commits
6 Commits
2fda134686
...
d3b4dc11fb
Author | SHA1 | Date | |
---|---|---|---|
d3b4dc11fb
|
|||
f34c5e7bcb
|
|||
45da0aba81
|
|||
c90fa6e39f
|
|||
1ff3091ef1
|
|||
312f09a2d7
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
22
TODO.md
22
TODO.md
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- This checklist outlines the rough plan for the "patchman" tool. It focuses on core functionality and will evolve over time. -->
|
||||||
|
|
||||||
|
# Patchman Feature Checklist (Initial Planning)
|
||||||
|
|
||||||
|
- [ ] Apply patches in order (like `quilt push`)
|
||||||
|
- [ ] Revert applied patches (like `quilt pop`)
|
||||||
|
- [ ] Track current patch level
|
||||||
|
- [ ] Edit the currently active patch
|
||||||
|
- [ ] Generate a new patch from:
|
||||||
|
- [ ] specific files
|
||||||
|
- [ ] all staged changes
|
||||||
|
- [ ] all unstaged changes
|
||||||
|
- [ ] Configure upstream source (branch or remote)
|
||||||
|
- [ ] Fetch and reset to upstream (optionally via config)
|
||||||
|
- [ ] Use a project-local config file (e.g. `patchman.yaml`)
|
||||||
|
- [ ] Derive patch series order from numbered filenames (no `series` file required)
|
||||||
|
- [ ] Optional reverse-apply (`git revert` or patch reverse)
|
||||||
|
- [ ] Integrate with Git (using CLI directly, not a lib)
|
||||||
|
- [ ] Provide CLI via `typer` with autocompletion
|
||||||
|
- [ ] Build a Textual-based TUI frontend (after CLI is stable)
|
||||||
|
- [ ] Handle malformed or outdated patch files gracefully
|
||||||
|
- [ ] Support dry-run mode (for testing without modification)
|
||||||
|
1307
poetry.lock
generated
Normal file
1307
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ pyyaml = "^6.0.2"
|
|||||||
pydantic = "^2.11.4"
|
pydantic = "^2.11.4"
|
||||||
zstandard = "^0.23.0"
|
zstandard = "^0.23.0"
|
||||||
textual = "^4.0.0"
|
textual = "^4.0.0"
|
||||||
|
gitpython = "^3.1.44"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
patchman = "patchman.__main__:main"
|
patchman = "patchman.__main__:main"
|
||||||
@@ -23,3 +24,4 @@ build-backend = "poetry.core.masonry.api"
|
|||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
twine = "^6.1.0"
|
twine = "^6.1.0"
|
||||||
|
pytest = "^8.4.1"
|
||||||
|
2
pytest.ini
Normal file
2
pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = src
|
@@ -1,22 +0,0 @@
|
|||||||
<!-- This checklist outlines the rough plan for the "patchman" tool. It focuses on core functionality and will evolve over time. -->
|
|
||||||
|
|
||||||
# Patchman Feature Checklist (Initial Planning)
|
|
||||||
|
|
||||||
- [ ] Apply patches in order (like `quilt push`)
|
|
||||||
- [ ] Revert applied patches (like `quilt pop`)
|
|
||||||
- [ ] Track current patch level
|
|
||||||
- [ ] Edit the currently active patch
|
|
||||||
- [ ] Generate a new patch from:
|
|
||||||
- [ ] specific files
|
|
||||||
- [ ] all staged changes
|
|
||||||
- [ ] all unstaged changes
|
|
||||||
- [ ] Configure upstream source (branch or remote)
|
|
||||||
- [ ] Fetch and reset to upstream (optionally via config)
|
|
||||||
- [ ] Use a project-local config file (e.g. `patchman.yaml`)
|
|
||||||
- [ ] Derive patch series order from numbered filenames (no `series` file required)
|
|
||||||
- [ ] Optional reverse-apply (`git revert` or patch reverse)
|
|
||||||
- [ ] Integrate with Git (using CLI directly, not a lib)
|
|
||||||
- [ ] Provide CLI via `typer` with autocompletion
|
|
||||||
- [ ] Build a Textual-based TUI frontend (after CLI is stable)
|
|
||||||
- [ ] Handle malformed or outdated patch files gracefully
|
|
||||||
- [ ] Support dry-run mode (for testing without modification)
|
|
||||||
|
33
src/patchman/models/config.py
Normal file
33
src/patchman/models/config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel, Field, HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamConfig(BaseModel):
|
||||||
|
"""Configuration for the upstream repository.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
url (HttpUrl): Git URL of the upstream repository.
|
||||||
|
branch (str): Upstream branch to track (default: 'main').
|
||||||
|
"""
|
||||||
|
url: HttpUrl = Field(..., description="Git URL of the upstream repository")
|
||||||
|
branch: str = Field(default="main", description="Upstream branch to track (default: 'main')")
|
||||||
|
|
||||||
|
|
||||||
|
class PatchesConfig(BaseModel):
|
||||||
|
"""Configuration for the patch directory.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
directory (str): Path to the patch directory.
|
||||||
|
"""
|
||||||
|
directory: str = Field(default="./patches", description="Path to patch directory")
|
||||||
|
|
||||||
|
|
||||||
|
class PatchmanConfig(BaseModel):
|
||||||
|
"""Main configuration for the Patchman application.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
upstream (UpstreamConfig): Configuration for the upstream repository.
|
||||||
|
patches (PatchesConfig): Configuration for the patch directory.
|
||||||
|
"""
|
||||||
|
upstream: UpstreamConfig
|
||||||
|
patches: PatchesConfig
|
21
src/patchman/models/lock.py
Normal file
21
src/patchman/models/lock.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamLock(BaseModel):
|
||||||
|
"""Represents the lock state of the upstream repository.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
last_commit (str): Commit hash of the last processed upstream repository.
|
||||||
|
"""
|
||||||
|
last_commit: str = Field(..., min_length=40, max_length=40, pattern="^[0-9a-f]{40}$",
|
||||||
|
description="Commit hash of the last processed upstream repository")
|
||||||
|
|
||||||
|
|
||||||
|
class PatchmanLock(BaseModel):
|
||||||
|
"""Represents the lock state of the Patchman application.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
upstream (UpstreamLock): Lock state of the upstream repository.
|
||||||
|
"""
|
||||||
|
upstream: UpstreamLock
|
42
src/patchman/utils/git_patch_parser/models.py
Normal file
42
src/patchman/utils/git_patch_parser/models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class GitPatch(BaseModel):
|
||||||
|
"""Represents a parsed Git patch with structured fields.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
commit_hash (str): 40-character SHA-1 commit hash from the 'From' header.
|
||||||
|
author (str): Author line from the patch header (e.g., 'Max <mail@example.com>').
|
||||||
|
date (str): Commit date in RFC-2822 format (e.g., 'Wed, 23 Jul 2025 12:34:56 +0200').
|
||||||
|
subject (str): Commit subject line from the Subject header.
|
||||||
|
description (Optional[str]): Optional description text from the commit body (between header and diff).
|
||||||
|
diff (str): Complete patch diff (starting with 'diff --git' or '--- a/...').
|
||||||
|
"""
|
||||||
|
commit_hash: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=40,
|
||||||
|
max_length=40,
|
||||||
|
pattern="^[0-9a-f]{40}$",
|
||||||
|
description="40-character SHA-1 commit hash from the 'From' header"
|
||||||
|
)
|
||||||
|
author: str = Field(
|
||||||
|
...,
|
||||||
|
description="Author line from the patch header (e.g. 'Max <mail@example.com>')"
|
||||||
|
)
|
||||||
|
date: str = Field(
|
||||||
|
...,
|
||||||
|
description="Commit date in RFC-2822 format, e.g. 'Wed, 23 Jul 2025 12:34:56 +0200'"
|
||||||
|
)
|
||||||
|
subject: str = Field(
|
||||||
|
...,
|
||||||
|
description="Commit subject line from the Subject header"
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional description text from the commit body (between header and diff)"
|
||||||
|
)
|
||||||
|
diff: str = Field(
|
||||||
|
...,
|
||||||
|
description="Complete patch diff (starting with 'diff --git' or '--- a/...')"
|
||||||
|
)
|
81
src/patchman/utils/git_patch_parser/parser.py
Normal file
81
src/patchman/utils/git_patch_parser/parser.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import re
|
||||||
|
from .models import GitPatch
|
||||||
|
|
||||||
|
# Match the commit hash from the 'From' line at the beginning of the patch
|
||||||
|
RE_COMMIT_HASH = re.compile(
|
||||||
|
r'^From\s+(?P<hash>[0-9a-f]{40})\s',
|
||||||
|
flags=re.MULTILINE
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract author information from the 'From:' header line
|
||||||
|
RE_AUTHOR = re.compile(r'^From:\s+(?P<author>.+)$', re.MULTILINE)
|
||||||
|
|
||||||
|
# Extract commit date from the 'Date:' header line
|
||||||
|
RE_DATE = re.compile(r'^Date:\s+(?P<date>.+)$', re.MULTILINE)
|
||||||
|
|
||||||
|
# Extract commit subject from the 'Subject:' header line
|
||||||
|
RE_SUBJECT = re.compile(r'^Subject:\s+(?P<subject>.+)$', re.MULTILINE)
|
||||||
|
|
||||||
|
# Extract commit description: everything between the first empty line after headers
|
||||||
|
# and the first occurrence of "diff --git" or "--- a/"
|
||||||
|
RE_DESCRIPTION = re.compile(
|
||||||
|
r'\n\n(?P<desc>.*?)(?=\n(?:diff --git|---\s[a-b]/))',
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract the diff content: from the first "diff --git" or "--- a/" to the end of file
|
||||||
|
RE_DIFF = re.compile(
|
||||||
|
r'\n(?P<diff>(?:diff --git|---\s[a-b]/).*?)\Z',
|
||||||
|
flags=re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_git_patch(patch_text: str) -> GitPatch:
|
||||||
|
"""Parse a git-format-patch into a GitPatch dataclass.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
patch_text (str): The raw text content of a git format-patch file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GitPatch: A structured representation of the patch.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If required fields (commit hash, author, date, subject) are missing.
|
||||||
|
"""
|
||||||
|
# Search for required fields with null checks
|
||||||
|
commit_match = RE_COMMIT_HASH.search(patch_text)
|
||||||
|
if not commit_match:
|
||||||
|
raise ValueError("Commit hash not found in patch")
|
||||||
|
commit_hash = commit_match.group('hash')
|
||||||
|
|
||||||
|
author_match = RE_AUTHOR.search(patch_text)
|
||||||
|
if not author_match:
|
||||||
|
raise ValueError("Author not found in patch")
|
||||||
|
author = author_match.group('author')
|
||||||
|
|
||||||
|
date_match = RE_DATE.search(patch_text)
|
||||||
|
if not date_match:
|
||||||
|
raise ValueError("Date not found in patch")
|
||||||
|
date = date_match.group('date')
|
||||||
|
|
||||||
|
subject_match = RE_SUBJECT.search(patch_text)
|
||||||
|
if not subject_match:
|
||||||
|
raise ValueError("Subject not found in patch")
|
||||||
|
subject = subject_match.group('subject')
|
||||||
|
|
||||||
|
# Extract optional fields
|
||||||
|
desc_match = RE_DESCRIPTION.search(patch_text)
|
||||||
|
diff_match = RE_DIFF.search(patch_text)
|
||||||
|
|
||||||
|
# Process optional fields with fallback to empty strings
|
||||||
|
description = desc_match.group('desc').rstrip() if desc_match else ''
|
||||||
|
diff = diff_match.group('diff') if diff_match else ''
|
||||||
|
|
||||||
|
return GitPatch(
|
||||||
|
commit_hash=commit_hash,
|
||||||
|
author=author,
|
||||||
|
date=date,
|
||||||
|
subject=subject,
|
||||||
|
description=description,
|
||||||
|
diff=diff,
|
||||||
|
)
|
78
tests/test_parser.py
Normal file
78
tests/test_parser.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import unittest
|
||||||
|
from patchman.utils.git_patch_parser.parser import parse_git_patch
|
||||||
|
from patchman.utils.git_patch_parser.models import GitPatch
|
||||||
|
|
||||||
|
class TestGitPatchParser(unittest.TestCase):
|
||||||
|
def test_parse_valid_patch(self):
|
||||||
|
patch_text = """From 0123456789abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Max Mustermann <max@example.com>
|
||||||
|
Date: Wed, 23 Jul 2025 12:34:56 +0200
|
||||||
|
Subject: [PATCH] Dein Patch-Titel
|
||||||
|
|
||||||
|
Dies ist eine Beschreibung des Patches.
|
||||||
|
|
||||||
|
diff --git a/file.txt b/file.txt
|
||||||
|
index 83db48f..f735c3b 100644
|
||||||
|
--- a/file.txt
|
||||||
|
+++ b/file.txt
|
||||||
|
@@ -1 +1 @@
|
||||||
|
-Hello World
|
||||||
|
+Hello Patchman
|
||||||
|
"""
|
||||||
|
expected_patch = GitPatch(
|
||||||
|
commit_hash="0123456789abcdef1234567890abcdef12345678",
|
||||||
|
author="Max Mustermann <max@example.com>",
|
||||||
|
date="Wed, 23 Jul 2025 12:34:56 +0200",
|
||||||
|
subject="[PATCH] Dein Patch-Titel",
|
||||||
|
description="Dies ist eine Beschreibung des Patches.",
|
||||||
|
diff="diff --git a/file.txt b/file.txt\nindex 83db48f..f735c3b 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-Hello World\n+Hello Patchman\n"
|
||||||
|
)
|
||||||
|
parsed_patch = parse_git_patch(patch_text)
|
||||||
|
self.assertEqual(parsed_patch, expected_patch)
|
||||||
|
|
||||||
|
def test_parse_invalid_patch(self):
|
||||||
|
patch_text = "Invalid patch content"
|
||||||
|
with self.assertRaises(ValueError) as context:
|
||||||
|
parse_git_patch(patch_text)
|
||||||
|
self.assertIn("Commit hash not found", str(context.exception))
|
||||||
|
|
||||||
|
def test_parse_patch_missing_author(self):
|
||||||
|
patch_text = """From 0123456789abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001
|
||||||
|
Date: Wed, 23 Jul 2025 12:34:56 +0200
|
||||||
|
Subject: [PATCH] Test"""
|
||||||
|
with self.assertRaises(ValueError) as context:
|
||||||
|
parse_git_patch(patch_text)
|
||||||
|
self.assertIn("Author not found", str(context.exception))
|
||||||
|
|
||||||
|
def test_parse_patch_missing_date(self):
|
||||||
|
patch_text = """From 0123456789abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Test Author <test@example.com>
|
||||||
|
Subject: [PATCH] Test"""
|
||||||
|
with self.assertRaises(ValueError) as context:
|
||||||
|
parse_git_patch(patch_text)
|
||||||
|
self.assertIn("Date not found", str(context.exception))
|
||||||
|
|
||||||
|
def test_parse_patch_missing_subject(self):
|
||||||
|
patch_text = """From 0123456789abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Test Author <test@example.com>
|
||||||
|
Date: Wed, 23 Jul 2025 12:34:56 +0200"""
|
||||||
|
with self.assertRaises(ValueError) as context:
|
||||||
|
parse_git_patch(patch_text)
|
||||||
|
self.assertIn("Subject not found", str(context.exception))
|
||||||
|
|
||||||
|
def test_parse_patch_without_description_and_diff(self):
|
||||||
|
patch_text = """From 0123456789abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Test Author <test@example.com>
|
||||||
|
Date: Wed, 23 Jul 2025 12:34:56 +0200
|
||||||
|
Subject: [PATCH] Minimal patch"""
|
||||||
|
|
||||||
|
parsed_patch = parse_git_patch(patch_text)
|
||||||
|
self.assertEqual(parsed_patch.commit_hash, "0123456789abcdef1234567890abcdef12345678")
|
||||||
|
self.assertEqual(parsed_patch.author, "Test Author <test@example.com>")
|
||||||
|
self.assertEqual(parsed_patch.date, "Wed, 23 Jul 2025 12:34:56 +0200")
|
||||||
|
self.assertEqual(parsed_patch.subject, "[PATCH] Minimal patch")
|
||||||
|
self.assertEqual(parsed_patch.description, "")
|
||||||
|
self.assertEqual(parsed_patch.diff, "")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
Reference in New Issue
Block a user