From 575c0714d09f46eca2e2fd526d5f82acb9a2b4b8 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 28 Oct 2025 18:42:07 +0800 Subject: [PATCH 1/8] Add tags push --- tags_push/.gitmastery-exercise.json | 16 ++++++++++++++++ tags_push/README.md | 18 ++++++++++++++++++ tags_push/__init__.py | 0 tags_push/download.py | 8 ++++++++ tags_push/tests/__init__.py | 0 tags_push/tests/specs/base.yml | 6 ++++++ tags_push/tests/test_verify.py | 12 ++++++++++++ tags_push/verify.py | 11 +++++++++++ 8 files changed, 71 insertions(+) create mode 100644 tags_push/.gitmastery-exercise.json create mode 100644 tags_push/README.md create mode 100644 tags_push/__init__.py create mode 100644 tags_push/download.py create mode 100644 tags_push/tests/__init__.py create mode 100644 tags_push/tests/specs/base.yml create mode 100644 tags_push/tests/test_verify.py create mode 100644 tags_push/verify.py diff --git a/tags_push/.gitmastery-exercise.json b/tags_push/.gitmastery-exercise.json new file mode 100644 index 0000000..e992217 --- /dev/null +++ b/tags_push/.gitmastery-exercise.json @@ -0,0 +1,16 @@ +{ + "exercise_name": "tags-push", + "tags": [ + "git-tag" + ], + "requires_git": true, + "requires_github": true, + "base_files": {}, + "exercise_repo": { + "repo_type": "remote", + "repo_name": "duty-roster", + "repo_title": "gm-duty-roster", + "create_fork": true, + "init": null + } +} \ No newline at end of file diff --git a/tags_push/README.md b/tags_push/README.md new file mode 100644 index 0000000..9861ea4 --- /dev/null +++ b/tags_push/README.md @@ -0,0 +1,18 @@ +# tags-push + + + +## Task + + + +## Hints + + + diff --git a/tags_push/__init__.py b/tags_push/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tags_push/download.py b/tags_push/download.py new file mode 100644 index 0000000..df0bbc2 --- /dev/null +++ b/tags_push/download.py @@ -0,0 +1,8 @@ +from exercise_utils.cli import run_command +from exercise_utils.gitmastery import create_start_tag + +__resources__ = {} + + +def setup(verbose: bool = False): + create_start_tag(verbose) diff --git a/tags_push/tests/__init__.py b/tags_push/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tags_push/tests/specs/base.yml b/tags_push/tests/specs/base.yml new file mode 100644 index 0000000..00c3a53 --- /dev/null +++ b/tags_push/tests/specs/base.yml @@ -0,0 +1,6 @@ +initialization: + steps: + - type: commit + empty: true + message: Empty commit + id: start diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py new file mode 100644 index 0000000..36e5d72 --- /dev/null +++ b/tags_push/tests/test_verify.py @@ -0,0 +1,12 @@ +from git_autograder import GitAutograderTestLoader + +from ..verify import verify + +REPOSITORY_NAME = "tags-push" + +loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify) + + +def test_base(): + with loader.load("specs/base.yml", "start"): + pass diff --git a/tags_push/verify.py b/tags_push/verify.py new file mode 100644 index 0000000..1288d3d --- /dev/null +++ b/tags_push/verify.py @@ -0,0 +1,11 @@ +from git_autograder import ( + GitAutograderOutput, + GitAutograderExercise, + GitAutograderStatus, +) + + +def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: + # INSERT YOUR GRADING CODE HERE + + return exercise.to_output([], GitAutograderStatus.SUCCESSFUL) From 4143affc96fceec819b3c6150867fe05bc56b342 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 28 Oct 2025 20:58:04 +0800 Subject: [PATCH 2/8] Add download.py --- tags_push/download.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tags_push/download.py b/tags_push/download.py index df0bbc2..a34b48d 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,8 +1,21 @@ from exercise_utils.cli import run_command +from exercise_utils.git import tag, push from exercise_utils.gitmastery import create_start_tag __resources__ = {} +REMOTE_NAME = "production" +TAG_1_NAME = "v1.0" +TAG_2_NAME = "v2.0" +TAG_DELETE_NAME = "beta" +TAG_2_MESSAGE = "First stable roster" def setup(verbose: bool = False): create_start_tag(verbose) + run_command(["git", "remote", "rename", "origin", REMOTE_NAME], verbose) + tag(TAG_DELETE_NAME, verbose) + push(REMOTE_NAME, "--tags", verbose) # somewhat hacky, maybe use run_command instead + run_command(["git", "tag", "-d", TAG_DELETE_NAME], verbose) + + run_command(["git", "tag", TAG_1_NAME, "HEAD~4"], verbose) + run_command(["git", "tag", "-a", TAG_2_NAME, "HEAD~1", "-m", f"\"{TAG_2_MESSAGE}\""], verbose) From 639fe6236f4ed1619a8b44cea3d3993f650abc1b Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Wed, 29 Oct 2025 02:02:24 +0800 Subject: [PATCH 3/8] Add verify.py --- tags_push/verify.py | 62 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/tags_push/verify.py b/tags_push/verify.py index 1288d3d..76370f4 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -1,11 +1,69 @@ +import os +import subprocess +from typing import List, Optional + from git_autograder import ( GitAutograderOutput, GitAutograderExercise, GitAutograderStatus, ) +IMPROPER_GH_CLI_SETUP = "Your Github CLI is not setup correctly" + +TAG_1_NAME = "v1.0" +TAG_2_NAME = "v2.0" +TAG_DELETE_NAME = "beta" + +TAG_1_MISSING = f"Tag {TAG_1_NAME} is missing, did you push it to the remote?" +TAG_2_MISSING = f"Tag {TAG_2_NAME} is missing, did you push it to the remote?" +TAG_DELETE_NOT_REMOVED = f"Tag {TAG_DELETE_NAME} is still on the remote!" + +def run_command(command: List[str]) -> Optional[str]: + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + check=True, + env=dict(os.environ, **{"GH_PAGER": "cat"}), + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return None + + +def get_username() -> Optional[str]: + return run_command(["gh", "api", "user", "-q", ".login"]) + +# git ls-remote --tags origin (i.e. production) + +def get_remote_tags(username: str) -> Optional[str]: + return run_command(["gh", "api", + f"repos/{username}/{username}-gitmastery-gm-duty-roster/tags", + "--paginate", "--jq", ".[].name"]) def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: - # INSERT YOUR GRADING CODE HERE + username = get_username() + if username is None: + raise exercise.wrong_answer([IMPROPER_GH_CLI_SETUP]) + + raw_tags = get_remote_tags(username) + tag_names = [line.strip() for line in raw_tags.strip().splitlines()] + + comments = [] + + if TAG_1_NAME not in tag_names: + comments.append(TAG_1_MISSING) + + if TAG_2_NAME not in tag_names: + comments.append(TAG_2_MISSING) + + if TAG_DELETE_NAME in tag_names: + comments.append(TAG_DELETE_NOT_REMOVED) + + if comments: + raise exercise.wrong_answer(comments) - return exercise.to_output([], GitAutograderStatus.SUCCESSFUL) + return exercise.to_output( + ["Wonderful! You have successfully synced the local tags with the remote tags!"], + GitAutograderStatus.SUCCESSFUL) From 437319aeccc36fe096774c06f038be39f09f7e4b Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Wed, 29 Oct 2025 02:58:21 +0800 Subject: [PATCH 4/8] Add unit tests and clean verify.py --- tags_push/tests/test_verify.py | 113 +++++++++++++++++++++++++++++++-- tags_push/verify.py | 10 ++- 2 files changed, 112 insertions(+), 11 deletions(-) diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py index 36e5d72..f42b424 100644 --- a/tags_push/tests/test_verify.py +++ b/tags_push/tests/test_verify.py @@ -1,12 +1,115 @@ -from git_autograder import GitAutograderTestLoader +import json +from pathlib import Path +from unittest.mock import patch -from ..verify import verify +import pytest +from git.repo import Repo + +from git_autograder import ( + GitAutograderExercise, + GitAutograderStatus, + GitAutograderTestLoader, + GitAutograderWrongAnswerException, + assert_output) + +from ..verify import ( + IMPROPER_GH_CLI_SETUP, + TAG_1_NAME, + TAG_2_NAME, + TAG_DELETE_NAME, + TAG_1_MISSING, + TAG_2_MISSING, + TAG_DELETE_NOT_REMOVED, + verify) REPOSITORY_NAME = "tags-push" loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify) -def test_base(): - with loader.load("specs/base.yml", "start"): - pass +# NOTE: This exercise is a special case where we do not require repo-smith. Instead, +# we directly mock function calls to verify that all branches are covered for us. + + +# TODO: The current tooling isn't mature enough to handle mock GitAutograderExercise in +# cases like these. We would ideally need some abstraction rather than creating our own. + + +@pytest.fixture +def exercise(tmp_path: Path) -> GitAutograderExercise: + repo_dir = tmp_path / "ignore-me" + repo_dir.mkdir() + + Repo.init(repo_dir) + with open(tmp_path / ".gitmastery-exercise.json", "a") as config_file: + config_file.write( + json.dumps( + { + "exercise_name": "tags-push", + "tags": [], + "requires_git": True, + "requires_github": True, + "base_files": {}, + "exercise_repo": { + "repo_type": "local", + "repo_name": "ignore-me", + "init": True, + "create_fork": None, + "repo_title": None, + }, + "downloaded_at": None, + } + ) + ) + + exercise = GitAutograderExercise(exercise_path=tmp_path) + return exercise + + +def test_pass(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME]), + ): + output = verify(exercise) + assert_output(output, GitAutograderStatus.SUCCESSFUL) + +def test_improper_gh_setup(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value=None), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [IMPROPER_GH_CLI_SETUP] + +def test_beta_present(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME, TAG_DELETE_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_DELETE_NOT_REMOVED] + +def test_tag_1_absent(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_2_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_1_MISSING] + +def test_tag_2_absent(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME]), + pytest.raises(GitAutograderWrongAnswerException) as exception, + ): + verify(exercise) + + assert exception.value.message == [TAG_2_MISSING] \ No newline at end of file diff --git a/tags_push/verify.py b/tags_push/verify.py index 76370f4..55a9b83 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -35,20 +35,18 @@ def run_command(command: List[str]) -> Optional[str]: def get_username() -> Optional[str]: return run_command(["gh", "api", "user", "-q", ".login"]) -# git ls-remote --tags origin (i.e. production) - -def get_remote_tags(username: str) -> Optional[str]: - return run_command(["gh", "api", +def get_remote_tags(username: str) -> List[str]: + raw_tags = run_command(["gh", "api", f"repos/{username}/{username}-gitmastery-gm-duty-roster/tags", "--paginate", "--jq", ".[].name"]) + return [line.strip() for line in raw_tags.strip().splitlines()] def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_username() if username is None: raise exercise.wrong_answer([IMPROPER_GH_CLI_SETUP]) - raw_tags = get_remote_tags(username) - tag_names = [line.strip() for line in raw_tags.strip().splitlines()] + tag_names = get_remote_tags(username) comments = [] From 951d019d88ed35b7e84772ba6d46beb826655c14 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Mon, 24 Nov 2025 18:47:06 +0800 Subject: [PATCH 5/8] Implement feedback - Added instructions to README - Removed hacks and cleaned up download.py - Removed GH API call - Cleaned up test_verify.py - Added new test --- tags_push/README.md | 16 +++------------- tags_push/download.py | 23 +++++++---------------- tags_push/tests/test_verify.py | 22 ++++++++++++---------- tags_push/verify.py | 6 ++---- 4 files changed, 24 insertions(+), 43 deletions(-) diff --git a/tags_push/README.md b/tags_push/README.md index 9861ea4..6c60c32 100644 --- a/tags_push/README.md +++ b/tags_push/README.md @@ -1,18 +1,8 @@ # tags-push - +The duty-roster repo contains text files that track which people are assigned for duties on which days of the week. This repo is backed up in a remote named production. Apparently, tags in the local repo are not in sync with the tags in your remote. ## Task - - -## Hints - - - +1. Push both tags in the local repo to the remote. +2. If any tags are present in the remote production but not in the local repo (i.e., likely result of you previously deleting them in the local repo but forgetting to delete them in the remote repo), delete them in the remote. diff --git a/tags_push/download.py b/tags_push/download.py index a34b48d..b3230dd 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,21 +1,12 @@ from exercise_utils.cli import run_command -from exercise_utils.git import tag, push +from exercise_utils.git import tag from exercise_utils.gitmastery import create_start_tag -__resources__ = {} - -REMOTE_NAME = "production" -TAG_1_NAME = "v1.0" -TAG_2_NAME = "v2.0" -TAG_DELETE_NAME = "beta" -TAG_2_MESSAGE = "First stable roster" - def setup(verbose: bool = False): - create_start_tag(verbose) - run_command(["git", "remote", "rename", "origin", REMOTE_NAME], verbose) - tag(TAG_DELETE_NAME, verbose) - push(REMOTE_NAME, "--tags", verbose) # somewhat hacky, maybe use run_command instead - run_command(["git", "tag", "-d", TAG_DELETE_NAME], verbose) + run_command(["git", "remote", "rename", "origin", "production"], verbose) + tag("beta", verbose) + run_command(["git", "push", "production", "--tags"], verbose) + run_command(["git", "tag", "-d", "beta"], verbose) - run_command(["git", "tag", TAG_1_NAME, "HEAD~4"], verbose) - run_command(["git", "tag", "-a", TAG_2_NAME, "HEAD~1", "-m", f"\"{TAG_2_MESSAGE}\""], verbose) + run_command(["git", "tag", "v1.0", "HEAD~4"], verbose) + run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", f"\"{"First stable roster"}\""], verbose) diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py index f42b424..9a5957b 100644 --- a/tags_push/tests/test_verify.py +++ b/tags_push/tests/test_verify.py @@ -78,38 +78,40 @@ def test_improper_gh_setup(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value=None), patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME]), - pytest.raises(GitAutograderWrongAnswerException) as exception, + pytest.raises(GitAutograderWrongAnswerException, match=IMPROPER_GH_CLI_SETUP), ): verify(exercise) - assert exception.value.message == [IMPROPER_GH_CLI_SETUP] - def test_beta_present(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME, TAG_2_NAME, TAG_DELETE_NAME]), - pytest.raises(GitAutograderWrongAnswerException) as exception, + pytest.raises(GitAutograderWrongAnswerException, match=TAG_DELETE_NOT_REMOVED), ): verify(exercise) - assert exception.value.message == [TAG_DELETE_NOT_REMOVED] - def test_tag_1_absent(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), patch("tags_push.verify.get_remote_tags", return_value=[TAG_2_NAME]), - pytest.raises(GitAutograderWrongAnswerException) as exception, + pytest.raises(GitAutograderWrongAnswerException, match=TAG_1_MISSING), ): verify(exercise) - assert exception.value.message == [TAG_1_MISSING] - def test_tag_2_absent(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), patch("tags_push.verify.get_remote_tags", return_value=[TAG_1_NAME]), + pytest.raises(GitAutograderWrongAnswerException, match=TAG_2_MISSING), + ): + verify(exercise) + +def test_all_wrong(exercise: GitAutograderExercise): + with ( + patch("tags_push.verify.get_username", return_value="dummy"), + patch("tags_push.verify.get_remote_tags", return_value=[TAG_DELETE_NAME]), pytest.raises(GitAutograderWrongAnswerException) as exception, ): verify(exercise) - assert exception.value.message == [TAG_2_MISSING] \ No newline at end of file + assert exception.value.message == [TAG_1_MISSING, TAG_2_MISSING, TAG_DELETE_NOT_REMOVED] \ No newline at end of file diff --git a/tags_push/verify.py b/tags_push/verify.py index 55a9b83..1a94111 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -36,10 +36,8 @@ def get_username() -> Optional[str]: return run_command(["gh", "api", "user", "-q", ".login"]) def get_remote_tags(username: str) -> List[str]: - raw_tags = run_command(["gh", "api", - f"repos/{username}/{username}-gitmastery-gm-duty-roster/tags", - "--paginate", "--jq", ".[].name"]) - return [line.strip() for line in raw_tags.strip().splitlines()] + raw_tags = run_command(["git", "ls-remote", "--tags"]) + return [line.split("/")[2] for line in raw_tags.strip().splitlines()] def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_username() From 0098d22d65546b5d77cd2b3140a30c2353187942 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Tue, 2 Dec 2025 13:30:20 +0800 Subject: [PATCH 6/8] Fix formatting --- tags_push/download.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tags_push/download.py b/tags_push/download.py index b3230dd..cd1105a 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,6 +1,5 @@ from exercise_utils.cli import run_command from exercise_utils.git import tag -from exercise_utils.gitmastery import create_start_tag def setup(verbose: bool = False): run_command(["git", "remote", "rename", "origin", "production"], verbose) @@ -9,4 +8,4 @@ def setup(verbose: bool = False): run_command(["git", "tag", "-d", "beta"], verbose) run_command(["git", "tag", "v1.0", "HEAD~4"], verbose) - run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", f"\"{"First stable roster"}\""], verbose) + run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", "First stable roster"], verbose) From 5041b1d808c09b11817689b8414b3dfb61e672e9 Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Thu, 4 Dec 2025 12:18:16 +0800 Subject: [PATCH 7/8] Fix whitespace --- tags_push/tests/test_verify.py | 4 ++++ tags_push/verify.py | 3 +++ 2 files changed, 7 insertions(+) diff --git a/tags_push/tests/test_verify.py b/tags_push/tests/test_verify.py index 9a5957b..14a3703 100644 --- a/tags_push/tests/test_verify.py +++ b/tags_push/tests/test_verify.py @@ -74,6 +74,7 @@ def test_pass(exercise: GitAutograderExercise): output = verify(exercise) assert_output(output, GitAutograderStatus.SUCCESSFUL) + def test_improper_gh_setup(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value=None), @@ -82,6 +83,7 @@ def test_improper_gh_setup(exercise: GitAutograderExercise): ): verify(exercise) + def test_beta_present(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), @@ -98,6 +100,7 @@ def test_tag_1_absent(exercise: GitAutograderExercise): ): verify(exercise) + def test_tag_2_absent(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), @@ -106,6 +109,7 @@ def test_tag_2_absent(exercise: GitAutograderExercise): ): verify(exercise) + def test_all_wrong(exercise: GitAutograderExercise): with ( patch("tags_push.verify.get_username", return_value="dummy"), diff --git a/tags_push/verify.py b/tags_push/verify.py index 1a94111..87357e9 100644 --- a/tags_push/verify.py +++ b/tags_push/verify.py @@ -18,6 +18,7 @@ TAG_2_MISSING = f"Tag {TAG_2_NAME} is missing, did you push it to the remote?" TAG_DELETE_NOT_REMOVED = f"Tag {TAG_DELETE_NAME} is still on the remote!" + def run_command(command: List[str]) -> Optional[str]: try: result = subprocess.run( @@ -35,10 +36,12 @@ def run_command(command: List[str]) -> Optional[str]: def get_username() -> Optional[str]: return run_command(["gh", "api", "user", "-q", ".login"]) + def get_remote_tags(username: str) -> List[str]: raw_tags = run_command(["git", "ls-remote", "--tags"]) return [line.split("/")[2] for line in raw_tags.strip().splitlines()] + def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: username = get_username() if username is None: From d9bf436f73846a78b94599a052c399c346a7a9dc Mon Sep 17 00:00:00 2001 From: Vikram Goyal Date: Mon, 15 Dec 2025 17:41:45 +0800 Subject: [PATCH 8/8] Refactor download.py to use tag_with_options --- tags_push/download.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tags_push/download.py b/tags_push/download.py index cd1105a..a617da7 100644 --- a/tags_push/download.py +++ b/tags_push/download.py @@ -1,5 +1,5 @@ from exercise_utils.cli import run_command -from exercise_utils.git import tag +from exercise_utils.git import tag, tag_with_options def setup(verbose: bool = False): run_command(["git", "remote", "rename", "origin", "production"], verbose) @@ -7,5 +7,5 @@ def setup(verbose: bool = False): run_command(["git", "push", "production", "--tags"], verbose) run_command(["git", "tag", "-d", "beta"], verbose) - run_command(["git", "tag", "v1.0", "HEAD~4"], verbose) - run_command(["git", "tag", "-a", "v2.0", "HEAD~1", "-m", "First stable roster"], verbose) + tag_with_options("v1.0", ["HEAD~4"], verbose) + tag_with_options("v2.0", ["-a", "HEAD~1", "-m", "First stable roster"], verbose)