Skip to content

Commit 03ad7c1

Browse files
authored
Merge pull request #390 from C0DE-X/feature/patches
Add patches functionality
2 parents d2cb289 + 129550d commit 03ad7c1

File tree

10 files changed

+337
-10
lines changed

10 files changed

+337
-10
lines changed

gitman/cli.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ def main(args=None, function=None):
125125
action="store_true",
126126
dest="no_scripts",
127127
)
128+
sub.add_argument(
129+
"-P",
130+
"--no-patches",
131+
help="skip patches after installation",
132+
action="store_true",
133+
dest="no_patches",
134+
)
128135

129136
# Update parser
130137
info = "update dependencies to the latest versions"
@@ -158,6 +165,13 @@ def main(args=None, function=None):
158165
action="store_true",
159166
dest="no_scripts",
160167
)
168+
sub.add_argument(
169+
"-P",
170+
"--no-patches",
171+
help="skip patches after installation",
172+
action="store_true",
173+
dest="no_patches",
174+
)
161175

162176
# List parser
163177
info = "display the current version of each dependency"
@@ -277,6 +291,7 @@ def _get_command(function, namespace):
277291
clean=namespace.clean,
278292
skip_changes=namespace.skip_changes,
279293
skip_scripts=namespace.no_scripts,
294+
skip_patches=namespace.no_patches,
280295
)
281296
if namespace.command == "install":
282297
kwargs.update(
@@ -350,6 +365,8 @@ def _run_command(function, args, kwargs):
350365
except exceptions.ScriptFailure as exception:
351366
_show_error(exception)
352367
exit_message = "Run again with '--force' to ignore script errors"
368+
except exceptions.PatchFailure as exception:
369+
_show_error(exception)
353370
except exceptions.InvalidConfig as exception:
354371
_show_error(exception)
355372
exit_message = "Adapt config and run again"

gitman/commands.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def install(
6262
skip_changes=False,
6363
skip_default_group=False,
6464
skip_scripts=False,
65+
skip_patches=False,
6566
):
6667
"""Install dependencies for a project.
6768
@@ -70,17 +71,18 @@ def install(
7071
- `*names`: optional list of dependency directory names to filter on
7172
- `root`: specifies the path to the root working tree
7273
- `depth`: number of levels of dependencies to traverse
73-
- `force`: indicates uncommitted changes can be overwritten and
74-
script errors can be ignored
74+
- `force`: indicates uncommitted changes can be overwritten,
75+
script errors can be ignored and failed patches can be ignored
7576
- `force_interactive`: indicates uncommitted changes can be interactively
76-
overwritten and script errors can be ignored
77+
overwritten, script errors can be ignored and failed patches can be ignored
7778
- `fetch`: indicates the latest branches should always be fetched
7879
- `clean`: indicates untracked files should be deleted from dependencies
7980
- `skip_changes`: indicates dependencies with uncommitted changes
8081
should be skipped
8182
- `skip_default_group`: indicates default_group should be skipped if
8283
`*names` is empty
8384
- `skip_scripts`: indicates scripts should be skipped
85+
- `skip_patches`: indicates patches should be skipped
8486
"""
8587
log.info(
8688
"%sInstalling dependencies: %s",
@@ -115,6 +117,10 @@ def install(
115117
)
116118
count += _count # type: ignore
117119

120+
if _count and not skip_patches:
121+
common.show("Applying patches...", color="message", log=False)
122+
common.newline()
123+
config.apply_patches(*names, depth=depth, force=force)
118124
if _count and not skip_scripts:
119125
label = "nested scripts" if index else "scripts"
120126
common.show(f"Running {label}...", color="message", log=False)
@@ -137,6 +143,7 @@ def update(
137143
skip_changes=False,
138144
skip_default_group=False,
139145
skip_scripts=False,
146+
skip_patches=False,
140147
):
141148
"""Update dependencies for a project.
142149
@@ -157,6 +164,7 @@ def update(
157164
- `skip_default_group`: indicates default_group should be skipped if
158165
`*names` is empty
159166
- `skip_scripts`: indicates scripts should be skipped
167+
- `skip_patches`: indicates patches should be skipped
160168
"""
161169
log.info(
162170
"%s dependencies%s: %s",
@@ -201,6 +209,10 @@ def update(
201209
skip_changes=skip_changes or force_interactive,
202210
)
203211

212+
if _count and not skip_patches:
213+
common.show("Applying patches...", color="message", log=False)
214+
common.newline()
215+
config.apply_patches(*names, depth=depth, force=force)
204216
if _count and not skip_scripts:
205217
label = "nested scripts" if index else "scripts"
206218
common.show(f"Running {label}...", color="message", log=False)

gitman/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,7 @@ class UncommittedChanges(RuntimeError):
2424

2525
class ScriptFailure(ShellError):
2626
"""Raised when post-install script has a non-zero exit code."""
27+
28+
29+
class PatchFailure(ShellError):
30+
"""Raised when applying a patch has a non-zero exit code."""

gitman/git.py

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def clone(
3737
user_params = []
3838

3939
if type == "git-svn":
40-
# just the preperation for the svn deep clone / checkout here
40+
# just the preparation for the svn deep clone / checkout here
4141
# clone will be made in update function to simplify source.py).
4242
os.makedirs(path)
4343
return
@@ -78,6 +78,38 @@ def clone(
7878
git("clone", "--reference-if-able", reference, repo, normpath, *user_params)
7979

8080

81+
def create_branch_local(type, name: str, base_ref: str = "HEAD", recreate: bool = True):
82+
"""Create a local branch.
83+
84+
:param recreate: delete and create if already exists
85+
"""
86+
if type == "git-svn":
87+
return # ignore creating branch in case of git-svn
88+
89+
assert type == "git"
90+
91+
# Check if branch exists and delete
92+
if recreate and _local_branch_exists(name):
93+
git("branch", "-D", name, _show=False)
94+
95+
# Create branch
96+
git("branch", name, base_ref, _show=False)
97+
98+
99+
def checkout(type, branch_name: str):
100+
"""Checkout a specific branch.
101+
102+
:param branch_name: branch name for the checkout
103+
"""
104+
if type == "git-svn":
105+
return # ignore checkout in the case of git-svn
106+
107+
assert type == "git"
108+
109+
# Checkout branch
110+
git("checkout", branch_name)
111+
112+
81113
def is_sha(rev):
82114
"""Heuristically determine whether a revision corresponds to a commit SHA.
83115
@@ -152,7 +184,7 @@ def rebuild(type, repo): # pylint: disable=unused-argument
152184

153185
assert type == "git"
154186

155-
common.show("Rebuilding mising git repo...", color="message")
187+
common.show("Rebuilding missing git repo...", color="message")
156188
git("init", _show=True)
157189
git("remote", "add", "origin", repo, _show=True)
158190
common.show("Rebuilt git repo...", color="message")
@@ -163,8 +195,7 @@ def changes(type, include_untracked=False, display_status=True, _show=False):
163195
status = False
164196

165197
if type == "git-svn":
166-
# ignore changes in case of git-svn
167-
return status
198+
return status # ignore changes in case of git-svn
168199

169200
assert type == "git"
170201

@@ -194,6 +225,26 @@ def changes(type, include_untracked=False, display_status=True, _show=False):
194225
return status
195226

196227

228+
def am(patch, _skip=False):
229+
"""Apply a patch.
230+
231+
:param patch: the patch to be applied
232+
:param _skip: skip patch if applying fails
233+
"""
234+
try:
235+
git("am", "--3way", patch, _show=True)
236+
except ShellError as e:
237+
if _skip:
238+
# Check if current git am is stuck
239+
rebase_dir = git("rev-parse", "--git-path", "rebase-apply", _show=False)
240+
if rebase_dir and os.path.isdir(rebase_dir[0]):
241+
try:
242+
git("am", "--skip", _show=True, _ignore=True)
243+
except ShellError:
244+
pass
245+
raise ShellError from e
246+
247+
197248
def update(
198249
type, repo, path, *, clean=True, fetch=False, rev=None
199250
): # pylint: disable=redefined-outer-name,unused-argument
@@ -241,14 +292,17 @@ def get_url(type):
241292
return git("config", "--get", "remote.origin.url", _show=False)[0]
242293

243294

244-
def get_hash(type, _show=False):
295+
def get_hash(type, short=False, _show=False):
245296
"""Get the current working tree's hash."""
246297
if type == "git-svn":
247298
return "".join(filter(str.isdigit, gitsvn("info", _show=_show)[4]))
248299

249300
assert type == "git"
250-
251-
return git("rev-parse", "HEAD", _show=_show, _stream=False)[0]
301+
args = ["rev-parse"]
302+
if short:
303+
args.append("--short")
304+
args.append("HEAD")
305+
return git(*args, _show=_show, _stream=False)[0]
252306

253307

254308
def get_tag():
@@ -283,6 +337,15 @@ def get_object_rev(object_name):
283337
return commit_sha
284338

285339

340+
def _local_branch_exists(name) -> bool:
341+
"""Check if a local branch exists."""
342+
try:
343+
git("rev-parse", "--verify", name, _show=False)
344+
return True
345+
except ShellError:
346+
return False
347+
348+
286349
def _get_sha_from_rev(rev):
287350
"""Get a rev-parse string's hash."""
288351
if "@{" in rev:

gitman/models/config.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,52 @@ def run_scripts(
187187

188188
return count
189189

190+
@preserve_cwd
191+
def apply_patches(
192+
self,
193+
*names: str,
194+
depth: Optional[int] = None,
195+
force: bool = False,
196+
) -> int:
197+
"""Apply patches for the specified dependencies."""
198+
if depth == 0:
199+
log.info("Skipped directory: %s", self.location_path)
200+
return 0
201+
202+
sources = self._get_sources()
203+
sources_filter = self._get_sources_filter(
204+
*names, sources=sources, skip_default_group=False
205+
)
206+
207+
shell.cd(self.location_path)
208+
common.newline()
209+
common.indent()
210+
211+
count = 0
212+
for source in sources:
213+
if source.name in sources_filter:
214+
shell.cd(source.name)
215+
216+
config = load_config(search=False)
217+
if config:
218+
common.indent()
219+
remaining_depth = None if depth is None else max(0, depth - 1)
220+
if remaining_depth:
221+
common.newline()
222+
count += config.apply_patches(depth=remaining_depth, force=force)
223+
common.dedent()
224+
225+
source.apply_patches(
226+
topdir=os.path.normpath(self.location_path), skip=force
227+
)
228+
count += 1
229+
230+
shell.cd(self.location_path, _show=False)
231+
232+
common.dedent()
233+
234+
return count
235+
190236
@classmethod
191237
def _split_name_and_rev(cls, name_rev):
192238
true_name = name_rev

gitman/models/source.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class Source:
3030
| `sparse_paths` | Controls partial checkout | No | `[]` |
3131
| `links` | Creates symlinks within a project | No | `[]` |
3232
| `scripts` | Shell commands to run after checkout | No | `[]` |
33+
| `patches` | patches to be applied after checkout | No | `[]` |
3334
3435
### Params
3536
@@ -62,6 +63,17 @@ class Source:
6263
- cabal install
6364
```
6465
66+
### Patches
67+
68+
Patches that are applied after checkout. For example:
69+
70+
```
71+
repo: "https://github.com/koalaman/shellcheck"
72+
patches:
73+
- patchdir/0001-add-something.patch
74+
- patchdir/0002-add-more.patch
75+
```
76+
6577
"""
6678

6779
repo: str = ""
@@ -74,6 +86,7 @@ class Source:
7486
links: List[Link] = field(default_factory=list)
7587

7688
scripts: List[str] = field(default_factory=list)
89+
patches: List[str] = field(default_factory=list)
7790

7891
DIRTY = "<dirty>"
7992
UNKNOWN = "<unknown>"
@@ -235,6 +248,46 @@ def run_scripts(self, force: bool = False, show_shell_stdout: bool = False):
235248
raise exceptions.ScriptFailure(msg)
236249
common.newline()
237250

251+
def apply_patches(self, topdir: str, skip: bool = False):
252+
log.info("Applying patches...")
253+
254+
# Enter the working tree
255+
if not git.valid():
256+
raise self._invalid_repository
257+
258+
# Check for patches
259+
if not self.patches or not self.patches[0]:
260+
common.show("(no patches to apply)", color="shell_info")
261+
common.newline()
262+
return
263+
264+
# Create a branch from the current hash to apply patches
265+
try:
266+
hash = git.get_hash(self.type, short=True, _show=False)
267+
patched_branch = hash + "-patched"
268+
git.create_branch_local(self.type, patched_branch)
269+
git.checkout(self.type, patched_branch)
270+
log.info("Working on local branch{} for patching.".format(patched_branch))
271+
except exceptions.ShellError as exc:
272+
msg = "Patch preparation failed! Could not create branch {}".format(
273+
patched_branch
274+
)
275+
raise exceptions.PatchFailure(msg) from exc
276+
277+
# Apply all patches
278+
for patch in self.patches:
279+
try:
280+
# Convert patch to absolute path
281+
patch_path = os.path.abspath(os.path.join(topdir, patch))
282+
git.am(patch_path, skip)
283+
except exceptions.ShellError as exc:
284+
if skip:
285+
log.debug("Ignored error from patch '%s'", patch)
286+
else:
287+
msg = "Failed to apply patch '{}' in {}".format(patch, os.getcwd())
288+
raise exceptions.PatchFailure(msg) from exc
289+
common.newline()
290+
238291
def identify(
239292
self,
240293
allow_dirty: bool = True,
@@ -318,6 +371,7 @@ def lock(
318371
rev=rev,
319372
links=self.links,
320373
scripts=self.scripts,
374+
patches=self.patches,
321375
sparse_paths=self.sparse_paths,
322376
)
323377
return source

0 commit comments

Comments
 (0)