Skip to content

Commit 84ee652

Browse files
committed
Add gitea_api.GitDiffGenerator class for creating submodule diffs without a git checkout
1 parent 44af44e commit 84ee652

File tree

3 files changed

+351
-0
lines changed

3 files changed

+351
-0
lines changed

osc/gitea_api/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .conf import Login
1515
from .fork import Fork
1616
from .git import Git
17+
from .git_diff_generator import GitDiffGenerator
1718
from .issue_timeline_entry import IssueTimelineEntry
1819
from .json import json_dumps
1920
from .pr import PullRequest
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""
2+
Changing a submodule commit in a git repo requires cloning the submodule and changing it's HEAD.
3+
This is relatively slow, especially when working with many submodules.
4+
You can create 2 instances of ``GitDiffGenerator`` and set their files and submodules and produce a diff,
5+
that can be applied and used for submiting a pull request.
6+
This is primarily made for making submodule changes while grouping project pull requests during staging.
7+
8+
Limitations:
9+
- works with text files only
10+
- renames are not supported
11+
"""
12+
13+
14+
import configparser
15+
from typing import List
16+
from typing import Optional
17+
18+
19+
class GitmodulesEntry:
20+
def __init__(self, gitmodules_parser: "GitmodulesParser", path: str):
21+
self._parser = gitmodules_parser
22+
self._path = path
23+
assert self.path == path
24+
25+
@property
26+
def section(self) -> str:
27+
return f'submodule "{self._path}"'
28+
29+
def _get_value(self, name: str) -> str:
30+
return self._parser.get(self.section, name, raw=True, fallback=None)
31+
32+
def _set_value(self, name: str, value: Optional[str]):
33+
if value is None:
34+
self._parser.remove_option(self.section, name)
35+
else:
36+
self._parser.set(self.section, name, value)
37+
38+
@property
39+
def path(self) -> str:
40+
return self._get_value("path")
41+
42+
@property
43+
def url(self) -> str:
44+
return self._get_value("url")
45+
46+
@url.setter
47+
def url(self, value: str):
48+
return self._set_value("url", value)
49+
50+
@property
51+
def branch(self) -> str:
52+
return self._get_value("branch")
53+
54+
@branch.setter
55+
def branch(self, value: str):
56+
return self._set_value("branch", value)
57+
58+
59+
class GitmodulesParser(configparser.ConfigParser):
60+
def _write_section(self, fp, section_name, section_items, delimiter, *args, **kwargs):
61+
import io
62+
63+
# prefix keys with spaces to be compatible with standard .gitmodules format created by git
64+
section_items = [(f"\t{k}", v) for k, v in section_items]
65+
66+
buffer = io.StringIO()
67+
super()._write_section(
68+
fp=buffer,
69+
section_name=section_name,
70+
section_items=section_items,
71+
delimiter=delimiter,
72+
*args,
73+
**kwargs,
74+
)
75+
76+
# remove the trailing newline
77+
gitmodules_str = buffer.getvalue()
78+
if gitmodules_str[-1] == "\n":
79+
gitmodules_str = gitmodules_str[:-1]
80+
81+
fp.write(gitmodules_str)
82+
83+
def to_string(self) -> str:
84+
import io
85+
86+
buffer = io.StringIO()
87+
self.write(buffer)
88+
return buffer.getvalue()
89+
90+
91+
class GitDiffGenerator:
92+
def __init__(self, gitmodules_str: str = None):
93+
self._files = {}
94+
self._submodule_commits = {}
95+
self._gitmodules = GitmodulesParser()
96+
if gitmodules_str:
97+
self._gitmodules.read_string(gitmodules_str)
98+
99+
def _check_path(self, path: str):
100+
if path.startswith("/"):
101+
raise ValueError(f"A path in a git repo must not be absolute: {path}")
102+
103+
def set_file(self, path: str, contents: Optional[str]):
104+
self._check_path(path)
105+
if contents is None:
106+
self._files.pop(path, None)
107+
else:
108+
self._files[path] = contents
109+
110+
def _get_file_lines(self, path) -> Optional[List[str]]:
111+
self._check_path(path)
112+
if not path in self._files:
113+
return None
114+
return self._files[path].splitlines()
115+
116+
def set_submodule_commit(self, path: str, commit: Optional[str]):
117+
self._check_path(path)
118+
if commit is None:
119+
if self.has_gitmodules_entry(path):
120+
raise ValueError(f"Need to delete a corresponding .gitmodules entry first: {path}")
121+
self._submodule_commits.pop(path, None)
122+
else:
123+
if not self.has_gitmodules_entry(path):
124+
raise ValueError(f"Path has no corresponding .gitmodules entry: {path}")
125+
self._submodule_commits[path] = commit
126+
127+
def _get_submodule_lines(self, path) -> Optional[List[str]]:
128+
self._check_path(path)
129+
if not path in self._submodule_commits:
130+
return None
131+
commit = self._submodule_commits[path]
132+
return [f"Subproject commit {commit}"]
133+
134+
def _get_gitmodules_section_name(self, path: str) -> str:
135+
return f'submodule "{path}"'
136+
137+
def has_gitmodules_entry(self, path: str) -> bool:
138+
return self._gitmodules.has_section(self._get_gitmodules_section_name(path))
139+
140+
def get_gitmodules_entry(self, path) -> Optional[GitmodulesEntry]:
141+
if not self.has_gitmodules_entry(path):
142+
return None
143+
return GitmodulesEntry(self._gitmodules, path)
144+
145+
def create_gitmodules_entry(self, *, path: str, url: str, branch: str):
146+
try:
147+
section_name = self._get_gitmodules_section_name(path)
148+
self._gitmodules.add_section(section_name)
149+
self._gitmodules.set(section_name, "path", path)
150+
self._gitmodules.set(section_name, "url", url)
151+
self._gitmodules.set(section_name, "branch", branch)
152+
except configparser.DuplicateSectionError:
153+
raise ValueError(f"A .gitmodules entry '{path}' already exists.")
154+
return self.get_gitmodules_entry(path)
155+
156+
def update_gitmodules_entry(self, *, path: str, url: str, branch: str):
157+
try:
158+
result = self.create_gitmodules_entry(path=path, url=url, branch=branch)
159+
except ValueError:
160+
result = self.get_gitmodules_entry(path)
161+
result.url = url
162+
result.branch = branch
163+
return result
164+
165+
def delete_gitmodules_entry(self, path: str):
166+
self._gitmodules.remove_section(self._get_gitmodules_section_name(path))
167+
168+
def diff(self, other: "GitDiffGenerator"):
169+
"""
170+
How the current state
171+
"""
172+
import difflib
173+
174+
# generate .gitmodules diff
175+
self_gitmodules = self._gitmodules.to_string()
176+
other_gitmodules = other._gitmodules.to_string()
177+
if self_gitmodules or other_gitmodules:
178+
path = ".gitmodules"
179+
old_lines = self_gitmodules.splitlines()
180+
new_lines = other_gitmodules.splitlines()
181+
yield from difflib.unified_diff(
182+
old_lines or [],
183+
new_lines or [],
184+
fromfile=f"a/{path}" if old_lines is not None else "/dev/null",
185+
tofile=f"b/{path}" if new_lines is not None else "/dev/null",
186+
lineterm="",
187+
)
188+
189+
# generate submodules diff
190+
all_submodules = sorted(
191+
set(self._submodule_commits) | set(other._submodule_commits)
192+
)
193+
for path in all_submodules:
194+
old_lines = self._get_submodule_lines(path)
195+
new_lines = other._get_submodule_lines(path)
196+
yield from difflib.unified_diff(
197+
old_lines or [],
198+
new_lines or [],
199+
fromfile=f"a/{path}" if old_lines is not None else "/dev/null",
200+
tofile=f"b/{path}" if new_lines is not None else "/dev/null",
201+
lineterm="",
202+
)
203+
204+
# generate files diff
205+
all_files = sorted(set(self._files) | set(other._files))
206+
for path in all_files:
207+
old_lines = self._get_file_lines(path)
208+
new_lines = other._get_file_lines(path)
209+
yield from difflib.unified_diff(
210+
old_lines or [],
211+
new_lines or [],
212+
fromfile=f"a/{path}" if old_lines is not None else "/dev/null",
213+
tofile=f"b/{path}" if new_lines is not None else "/dev/null",
214+
lineterm="",
215+
)
216+
217+
yield "\n"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import unittest
2+
3+
from osc.gitea_api import GitDiffGenerator
4+
5+
6+
class GitDiffGeneratorTest(unittest.TestCase):
7+
8+
def test_add_file(self):
9+
base = GitDiffGenerator()
10+
head = GitDiffGenerator()
11+
12+
head.set_file("new-file", "text")
13+
14+
expected = """
15+
--- /dev/null
16+
+++ b/new-file
17+
@@ -0,0 +1 @@
18+
+text
19+
20+
""".lstrip()
21+
actual = "\n".join(base.diff(head))
22+
self.assertEqual(expected, actual)
23+
24+
def test_remove_file(self):
25+
base = GitDiffGenerator()
26+
head = GitDiffGenerator()
27+
28+
base.set_file("removed-file", "text")
29+
30+
expected = """
31+
--- a/removed-file
32+
+++ /dev/null
33+
@@ -1 +0,0 @@
34+
-text
35+
36+
""".lstrip()
37+
actual = "\n".join(base.diff(head))
38+
self.assertEqual(expected, actual)
39+
40+
def test_modify_file(self):
41+
base = GitDiffGenerator()
42+
head = GitDiffGenerator()
43+
44+
base.set_file("modify-file", "old-value")
45+
head.set_file("modify-file", "new-value")
46+
47+
expected = """
48+
--- a/modify-file
49+
+++ b/modify-file
50+
@@ -1 +1 @@
51+
-old-value
52+
+new-value
53+
54+
""".lstrip()
55+
actual = "\n".join(base.diff(head))
56+
self.assertEqual(expected, actual)
57+
58+
def test_add_submodule(self):
59+
base = GitDiffGenerator()
60+
head = GitDiffGenerator()
61+
62+
head.create_gitmodules_entry(
63+
path="package", url="../../pool/package", branch="factory"
64+
)
65+
head.set_submodule_commit("package", "aabbcc")
66+
67+
expected = """
68+
--- a/.gitmodules
69+
+++ b/.gitmodules
70+
@@ -0,0 +1,4 @@
71+
+[submodule "package"]
72+
+ path = package
73+
+ url = ../../pool/package
74+
+ branch = factory
75+
--- /dev/null
76+
+++ b/package
77+
@@ -0,0 +1 @@
78+
+Subproject commit aabbcc
79+
80+
""".lstrip()
81+
actual = "\n".join(base.diff(head))
82+
self.assertEqual(expected, actual)
83+
84+
def test_remove_submodule(self):
85+
base = GitDiffGenerator()
86+
head = GitDiffGenerator()
87+
88+
base.create_gitmodules_entry(
89+
path="package", url="../../pool/package", branch="factory"
90+
)
91+
base.set_submodule_commit("package", "aabbcc")
92+
93+
expected = """
94+
--- a/.gitmodules
95+
+++ b/.gitmodules
96+
@@ -1,4 +0,0 @@
97+
-[submodule "package"]
98+
- path = package
99+
- url = ../../pool/package
100+
- branch = factory
101+
--- a/package
102+
+++ /dev/null
103+
@@ -1 +0,0 @@
104+
-Subproject commit aabbcc
105+
106+
""".lstrip()
107+
actual = "\n".join(base.diff(head))
108+
self.assertEqual(expected, actual)
109+
110+
def test_modify_submodule(self):
111+
gitmodules_str = """
112+
[submodule "package"]
113+
path = package
114+
url = ../../pool/package
115+
branch = factory
116+
"""
117+
118+
base = GitDiffGenerator(gitmodules_str)
119+
head = GitDiffGenerator(gitmodules_str)
120+
121+
base.set_submodule_commit("package", "aabbcc")
122+
head.set_submodule_commit("package", "dddeeff")
123+
124+
expected = """
125+
--- a/package
126+
+++ b/package
127+
@@ -1 +1 @@
128+
-Subproject commit aabbcc
129+
+Subproject commit dddeeff
130+
131+
""".lstrip()
132+
actual = "\n".join(base.diff(head))
133+
self.assertEqual(expected, actual)

0 commit comments

Comments
 (0)