Skip to content

Commit 40f72fa

Browse files
MilyMiloMiłosz Skaza
and
Miłosz Skaza
authored
add support for git subrepo (#150)
Co-authored-by: Miłosz Skaza <[email protected]>
1 parent 31ec803 commit 40f72fa

File tree

3 files changed

+124
-80
lines changed

3 files changed

+124
-80
lines changed

README.md

+11
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,14 @@ The specification format has already been tested and used with CTFd in productio
205205
`ctfcli` plugins are essentially additions to the command line interface via dynamic class modifications. See the [plugin documentation page](docs/plugins.md) for a simple example.
206206

207207
*`ctfcli` is an alpha project! The plugin interface is likely to change!*
208+
209+
# Sub-Repos as alternative to Sub-Trees
210+
211+
`ctfcli` manages git-based challenges by using the built-in git `subtree` mechanism. While it works most of the time, it's been proven to have disadvantages and tends to create problems and merge conflicts.
212+
213+
As an alternative, we're currently experimenting with the git [`git subrepo`](https://github.com/ingydotnet/git-subrepo) extension.
214+
This functionality can be enabled by adding a `use_subrepo = True` property to the `[config]` section inside a ctfcli project config.
215+
216+
Subrepo has to be installed separately, and is not backwards compatible with the default `subtree`.
217+
Once challenges have been added by using either method, they will not work properly if you change it, and you will have to add the challenges again.
218+

ctfcli/cli/challenges.py

+106-80
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
LintException,
2121
RemoteChallengeNotFound,
2222
)
23-
from ctfcli.utils.git import get_git_repo_head_branch
23+
from ctfcli.utils.git import check_if_git_subrepo_is_installed, get_git_repo_head_branch
2424

2525
log = logging.getLogger("ctfcli.cli.challenges")
2626

@@ -119,17 +119,24 @@ def templates(self) -> int:
119119

120120
return TemplatesCommand.list()
121121

122-
def add(self, repo: str, directory: str = None, yaml_path: str = None) -> int:
123-
log.debug(f"add: {repo} (directory={directory}, yaml_path={yaml_path})")
122+
def add(
123+
self, repo: str, directory: str = None, branch: str = None, force: bool = False, yaml_path: str = None
124+
) -> int:
125+
log.debug(f"add: {repo} (directory={directory}, branch={branch}, force={force}, yaml_path={yaml_path})")
124126
config = Config()
125127

126-
# check if we're working with a remote challenge which has to be pulled first
128+
# Check if we're working with a remote challenge which has to be pulled first
127129
if repo.endswith(".git"):
130+
use_subrepo = config["config"].getboolean("use_subrepo", fallback=False)
131+
if use_subrepo and not check_if_git_subrepo_is_installed():
132+
click.secho("This project is configured to use git subrepo, but it's not installed.")
133+
return 1
134+
128135
# Get a relative path from project root to current directory
129136
project_path = config.project_path
130137
project_relative_cwd = Path.cwd().relative_to(project_path)
131138

132-
# Get a new directory that will add the git subtree
139+
# Get a new directory that will add the git subtree / git subrepo
133140
repository_basename = Path(repo).stem
134141

135142
# Use the custom subdirectory for the challenge if one was provided
@@ -148,29 +155,25 @@ def add(self, repo: str, directory: str = None, yaml_path: str = None) -> int:
148155

149156
# Add a new challenge to the config
150157
config["challenges"][str(challenge_key)] = repo
151-
head_branch = get_git_repo_head_branch(repo)
152158

153-
log.debug(
154-
f"call(['git', 'subtree', 'add', '--prefix', '{challenge_path}', "
155-
f"'{repo}', '{head_branch}', '--squash'], cwd='{project_path}')"
156-
)
157-
git_subtree_add = subprocess.call(
158-
[
159-
"git",
160-
"subtree",
161-
"add",
162-
"--prefix",
163-
challenge_path,
164-
repo,
165-
head_branch,
166-
"--squash",
167-
],
168-
cwd=project_path,
169-
)
159+
if use_subrepo:
160+
# Clone with subrepo if configured
161+
cmd = ["git", "subrepo", "clone", repo, challenge_path]
170162

171-
if git_subtree_add != 0:
163+
if branch is not None:
164+
cmd += ["-b", branch]
165+
166+
if force:
167+
cmd += ["-f"]
168+
else:
169+
# Otherwise default to the built-in subtree
170+
head_branch = get_git_repo_head_branch(repo)
171+
cmd = ["git", "subtree", "add", "--prefix", challenge_path, repo, head_branch, "--squash"]
172+
173+
log.debug(f"call({cmd}, cwd='{project_path}')")
174+
if subprocess.call(cmd, cwd=project_path) != 0:
172175
click.secho(
173-
"Could not add the challenge subtree. " "Please check git error messages above.",
176+
"Could not add the challenge repository. Please check git error messages above.",
174177
fg="red",
175178
)
176179
return 1
@@ -186,7 +189,7 @@ def add(self, repo: str, directory: str = None, yaml_path: str = None) -> int:
186189

187190
if any(r != 0 for r in [git_add, git_commit]):
188191
click.secho(
189-
"Could not commit the challenge subtree. " "Please check git error messages above.",
192+
"Could not commit the challenge repository. Please check git error messages above.",
190193
fg="red",
191194
)
192195
return 1
@@ -205,7 +208,7 @@ def add(self, repo: str, directory: str = None, yaml_path: str = None) -> int:
205208
return 1
206209

207210
def push(self, challenge: str = None, no_auto_pull: bool = False, quiet=False) -> int:
208-
log.debug(f"push: (challenge={challenge})")
211+
log.debug(f"push: (challenge={challenge}, no_auto_pull={no_auto_pull}, quiet={quiet})")
209212
config = Config()
210213

211214
if challenge:
@@ -224,6 +227,11 @@ def push(self, challenge: str = None, no_auto_pull: bool = False, quiet=False) -
224227
else:
225228
context = click.progressbar(challenges, label="Pushing challenges")
226229

230+
use_subrepo = config["config"].getboolean("use_subrepo", fallback=False)
231+
if use_subrepo and not check_if_git_subrepo_is_installed():
232+
click.secho("This project is configured to use git subrepo, but it's not installed.")
233+
return 1
234+
227235
with context as context_challenges:
228236
for challenge_instance in context_challenges:
229237
click.echo()
@@ -256,7 +264,6 @@ def push(self, challenge: str = None, no_auto_pull: bool = False, quiet=False) -
256264
continue
257265

258266
click.secho(f"Pushing '{challenge_path}' to '{challenge_repo}'", fg="blue")
259-
head_branch = get_git_repo_head_branch(challenge_repo)
260267

261268
log.debug(
262269
f"call(['git', 'status', '--porcelain'], cwd='{config.project_path / challenge_path}',"
@@ -287,32 +294,22 @@ def push(self, challenge: str = None, no_auto_pull: bool = False, quiet=False) -
287294

288295
if any(r != 0 for r in [git_add, git_commit]):
289296
click.secho(
290-
"Could not commit the challenge changes. " "Please check git error messages above.",
297+
"Could not commit the challenge changes. Please check git error messages above.",
291298
fg="red",
292299
)
293300
failed_pushes.append(challenge_instance)
294301
continue
295302

296-
log.debug(
297-
f"call(['git', 'subtree', 'push', '--prefix', '{challenge_path}', '{challenge_repo}', "
298-
f"'{head_branch}'], cwd='{config.project_path / challenge_path}')"
299-
)
300-
git_subtree_push = subprocess.call(
301-
[
302-
"git",
303-
"subtree",
304-
"push",
305-
"--prefix",
306-
challenge_path,
307-
challenge_repo,
308-
head_branch,
309-
],
310-
cwd=config.project_path,
311-
)
303+
if use_subrepo:
304+
cmd = ["git", "subrepo", "push", challenge_path]
305+
else:
306+
head_branch = get_git_repo_head_branch(challenge_repo)
307+
cmd = ["git", "subtree", "push", "--prefix", challenge_path, challenge_repo, head_branch]
312308

313-
if git_subtree_push != 0:
309+
log.debug(f"call({cmd}, cwd='{config.project_path / challenge_path}')")
310+
if subprocess.call(cmd, cwd=config.project_path) != 0:
314311
click.secho(
315-
"Could not push the challenge subtree. " "Please check git error messages above.",
312+
"Could not push the challenge repository. Please check git error messages above.",
316313
fg="red",
317314
)
318315
failed_pushes.append(challenge_instance)
@@ -335,8 +332,8 @@ def push(self, challenge: str = None, no_auto_pull: bool = False, quiet=False) -
335332

336333
return 1
337334

338-
def pull(self, challenge: str = None, quiet=False) -> int:
339-
log.debug(f"pull: (challenge={challenge})")
335+
def pull(self, challenge: str = None, strategy: str = "fast-forward", quiet: bool = False) -> int:
336+
log.debug(f"pull: (challenge={challenge}, quiet={quiet})")
340337
config = Config()
341338

342339
if challenge:
@@ -353,6 +350,11 @@ def pull(self, challenge: str = None, quiet=False) -> int:
353350
else:
354351
context = click.progressbar(challenges, label="Pulling challenges")
355352

353+
use_subrepo = config["config"].getboolean("use_subrepo", fallback=False)
354+
if use_subrepo and not check_if_git_subrepo_is_installed():
355+
click.secho("This project is configured to use git subrepo, but it's not installed.")
356+
return 1
357+
356358
failed_pulls = []
357359
with context as context_challenges:
358360
for challenge_instance in context_challenges:
@@ -386,18 +388,25 @@ def pull(self, challenge: str = None, quiet=False) -> int:
386388
continue
387389

388390
click.secho(f"Pulling latest '{challenge_repo}' to '{challenge_path}'", fg="blue")
389-
head_branch = get_git_repo_head_branch(challenge_repo)
390-
391-
log.debug(
392-
f"call(['git', 'subtree', 'pull', '--prefix', '{challenge_path}', "
393-
f"'{challenge_repo}', '{head_branch}', '--squash'], cwd='{config.project_path}')"
394-
)
395391

396392
pull_env = os.environ.copy()
397-
pull_env["GIT_MERGE_AUTOEDIT"] = "no"
398-
399-
git_subtree_pull = subprocess.call(
400-
[
393+
if use_subrepo:
394+
cmd = ["git", "subrepo", "pull", challenge_path]
395+
396+
if strategy == "rebase":
397+
cmd += ["--rebase"]
398+
elif strategy == "merge":
399+
cmd += ["--merge"]
400+
elif strategy == "force":
401+
cmd += ["--force"]
402+
elif strategy == "fast-forward":
403+
pass # fast-forward is the default strategy
404+
else:
405+
click.secho(f"Cannot pull challenge - '{strategy}' is not a valid pull strategy", fg="red")
406+
else:
407+
head_branch = get_git_repo_head_branch(challenge_repo)
408+
pull_env["GIT_MERGE_AUTOEDIT"] = "no"
409+
cmd = [
401410
"git",
402411
"subtree",
403412
"pull",
@@ -406,12 +415,10 @@ def pull(self, challenge: str = None, quiet=False) -> int:
406415
challenge_repo,
407416
head_branch,
408417
"--squash",
409-
],
410-
cwd=config.project_path,
411-
env=pull_env,
412-
)
418+
]
413419

414-
if git_subtree_pull != 0:
420+
log.debug(f"call({cmd}, cwd='{config.project_path})")
421+
if subprocess.call(cmd, cwd=config.project_path, env=pull_env) != 0:
415422
click.secho(
416423
f"Could not pull the subtree for challenge '{challenge_path}'. "
417424
"Please check git error messages above.",
@@ -420,25 +427,26 @@ def pull(self, challenge: str = None, quiet=False) -> int:
420427
failed_pulls.append(challenge_instance)
421428
continue
422429

423-
log.debug(f"call(['git', 'mergetool'], cwd='{config.project_path / challenge_path}')")
424-
git_mergetool = subprocess.call(["git", "mergetool"], cwd=config.project_path / challenge_path)
430+
if not use_subrepo:
431+
log.debug(f"call(['git', 'mergetool'], cwd='{config.project_path / challenge_path}')")
432+
git_mergetool = subprocess.call(["git", "mergetool"], cwd=config.project_path / challenge_path)
425433

426-
log.debug(f"call(['git', 'commit', '--no-edit'], cwd='{config.project_path / challenge_path}')")
427-
subprocess.call(["git", "commit", "--no-edit"], cwd=config.project_path / challenge_path)
434+
log.debug(f"call(['git', 'commit', '--no-edit'], cwd='{config.project_path / challenge_path}')")
435+
subprocess.call(["git", "commit", "--no-edit"], cwd=config.project_path / challenge_path)
428436

429-
log.debug(f"call(['git', 'clean', '-f'], cwd='{config.project_path / challenge_path}')")
430-
git_clean = subprocess.call(["git", "clean", "-f"], cwd=config.project_path / challenge_path)
437+
log.debug(f"call(['git', 'clean', '-f'], cwd='{config.project_path / challenge_path}')")
438+
git_clean = subprocess.call(["git", "clean", "-f"], cwd=config.project_path / challenge_path)
431439

432-
# git commit is allowed to return a non-zero code
433-
# because it would also mean that there's nothing to commit
434-
if any(r != 0 for r in [git_mergetool, git_clean]):
435-
click.secho(
436-
f"Could not commit the subtree for challenge '{challenge_path}'. "
437-
"Please check git error messages above.",
438-
fg="red",
439-
)
440-
failed_pulls.append(challenge_instance)
441-
continue
440+
# git commit is allowed to return a non-zero code
441+
# because it would also mean that there's nothing to commit
442+
if any(r != 0 for r in [git_mergetool, git_clean]):
443+
click.secho(
444+
f"Could not commit the changes for challenge '{challenge_path}'. "
445+
"Please check git error messages above.",
446+
fg="red",
447+
)
448+
failed_pulls.append(challenge_instance)
449+
continue
442450

443451
if len(failed_pulls) == 0:
444452
if not quiet:
@@ -460,6 +468,11 @@ def restore(self, challenge: str = None) -> int:
460468
click.secho("Could not find any added challenges to restore", fg="yellow")
461469
return 1
462470

471+
use_subrepo = config["config"].getboolean("use_subrepo", fallback=False)
472+
if use_subrepo and not check_if_git_subrepo_is_installed():
473+
click.secho("This project is configured to use git subrepo, but it's not installed.")
474+
return 1
475+
463476
failed_restores = []
464477
for challenge_key, challenge_source in config.challenges.items():
465478
if challenge is not None and challenge_key != challenge:
@@ -483,6 +496,19 @@ def restore(self, challenge: str = None) -> int:
483496
failed_restores.append(challenge_key)
484497
continue
485498

499+
# If we're using subrepo - the restore can be achieved by performing a force pull
500+
if use_subrepo:
501+
if self.pull(challenge, strategy="force") != 0:
502+
click.secho(
503+
f"Failed to restore challenge '{challenge_key}' via subrepo force pull. "
504+
"Please check git error messages above.",
505+
fg="red",
506+
)
507+
failed_restores.append(challenge_key)
508+
509+
continue
510+
511+
# Otherwise - default to restoring the repository via re-adding the subtree
486512
# Check if target directory exits
487513
if (config.project_path / challenge_key).exists():
488514
click.secho(

ctfcli/utils/git.py

+7
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
from typing import Optional, Union
44

55

6+
def check_if_git_subrepo_is_installed() -> bool:
7+
output = subprocess.run(["git", "subrepo"], capture_output=True, text=True)
8+
if "git: 'subrepo' is not a git command" in output.stderr:
9+
return False
10+
return True
11+
12+
613
def get_git_repo_head_branch(repo: str) -> Optional[str]:
714
"""
815
A helper method to get the reference of the HEAD branch of a git remote repo.

0 commit comments

Comments
 (0)