diff --git a/branch_forward/.gitmastery-exercise.json b/branch_forward/.gitmastery-exercise.json new file mode 100644 index 0000000..a68c6c0 --- /dev/null +++ b/branch_forward/.gitmastery-exercise.json @@ -0,0 +1,17 @@ +{ + "exercise_name": "branch-forward", + "tags": [ + "git-branch", + "git-merge" + ], + "requires_git": true, + "requires_github": false, + "base_files": {}, + "exercise_repo": { + "repo_type": "local", + "repo_name": "love-story", + "repo_title": null, + "create_fork": null, + "init": true + } +} diff --git a/branch_forward/README.md b/branch_forward/README.md new file mode 100644 index 0000000..55567b6 --- /dev/null +++ b/branch_forward/README.md @@ -0,0 +1,19 @@ +# branch-forward + +You are writing the outline for a story. You now have two parallel storylines in two branches. + +## Task + +1. Review the `with-sally` and `with-ginny` branches. +2. Merge only the branch(es) that can be fast-forwarded into `main`. +3. Leave other branches (if any) unmerged. + +## Hints + +
+ +Hint 1 + +Ensure you have switched to the destination branch before initiating the merge. + +
diff --git a/branch_forward/__init__.py b/branch_forward/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/branch_forward/download.py b/branch_forward/download.py new file mode 100644 index 0000000..e75ed57 --- /dev/null +++ b/branch_forward/download.py @@ -0,0 +1,63 @@ +from exercise_utils.file import append_to_file, create_or_update_file +from exercise_utils.git import add, checkout, commit + + +def setup(verbose: bool = False): + create_or_update_file( + "story.txt", + """ + Harry was single. + """, + ) + add(["story.txt"], verbose) + commit("Introduce Harry", verbose) + + append_to_file( + "story.txt", + """ + Harry did not have a family. + """, + ) + add(["story.txt"], verbose) + commit("Add about family", verbose) + + checkout("with-ginny", True, verbose) + append_to_file( + "story.txt", + """ + Then he met Ginny. + """, + ) + add(["story.txt"], verbose) + commit("Add about Ginny", verbose) + + checkout("main", False, verbose) + create_or_update_file( + "cast.txt", + """ + Harry + """, + ) + add(["cast.txt"], verbose) + commit("Add cast.txt", verbose) + + checkout("with-sally", True, verbose) + append_to_file( + "story.txt", + """ + Then he met Sally + """, + ) + add(["story.txt"], verbose) + commit("Mention Sally", verbose) + + checkout("with-ginny", False, verbose) + append_to_file( + "story.txt", + """ + Ginny was single too + """, + ) + add(["story.txt"], verbose) + commit("Mention Ginny is single", verbose) + diff --git a/branch_forward/tests/__init__.py b/branch_forward/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/branch_forward/tests/specs/base.yml b/branch_forward/tests/specs/base.yml new file mode 100644 index 0000000..6ddea2c --- /dev/null +++ b/branch_forward/tests/specs/base.yml @@ -0,0 +1,43 @@ +initialization: + steps: + - type: commit + empty: true + message: Introduce Harry + id: start + - type: commit + empty: true + message: Add about family + + - type: branch + branch-name: with-ginny + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Add about Ginny + + - type: checkout + branch-name: main + - type: commit + empty: true + message: Add cast.txt + + - type: branch + branch-name: with-sally + - type: checkout + branch-name: with-sally + - type: commit + empty: true + message: Mention Sally + + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Mention Ginny is single + + - type: checkout + branch-name: main + - type: merge + branch-name: with-sally + diff --git a/branch_forward/tests/specs/merge_with_sally_no_ff.yml b/branch_forward/tests/specs/merge_with_sally_no_ff.yml new file mode 100644 index 0000000..75169f8 --- /dev/null +++ b/branch_forward/tests/specs/merge_with_sally_no_ff.yml @@ -0,0 +1,44 @@ +initialization: + steps: + - type: commit + empty: true + message: Introduce Harry + id: start + - type: commit + empty: true + message: Add about family + + - type: branch + branch-name: with-ginny + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Add about Ginny + + - type: checkout + branch-name: main + - type: commit + empty: true + message: Add cast.txt + + - type: branch + branch-name: with-sally + - type: checkout + branch-name: with-sally + - type: commit + empty: true + message: Mention Sally + + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Mention Ginny is single + + - type: checkout + branch-name: main + - type: merge + branch-name: with-sally + no-ff: true + diff --git a/branch_forward/tests/specs/no_merges.yml b/branch_forward/tests/specs/no_merges.yml new file mode 100644 index 0000000..2d0ece0 --- /dev/null +++ b/branch_forward/tests/specs/no_merges.yml @@ -0,0 +1,41 @@ +initialization: + steps: + - type: commit + empty: true + message: Introduce Harry + id: start + - type: commit + empty: true + message: Add about family + + - type: branch + branch-name: with-ginny + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Add about Ginny + + - type: checkout + branch-name: main + - type: commit + empty: true + message: Add cast.txt + + - type: branch + branch-name: with-sally + - type: checkout + branch-name: with-sally + - type: commit + empty: true + message: Mention Sally + + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Mention Ginny is single + + - type: checkout + branch-name: main + diff --git a/branch_forward/tests/specs/other_branch_ff.yml b/branch_forward/tests/specs/other_branch_ff.yml new file mode 100644 index 0000000..e7f6377 --- /dev/null +++ b/branch_forward/tests/specs/other_branch_ff.yml @@ -0,0 +1,53 @@ +initialization: + steps: + - type: commit + empty: true + message: Introduce Harry + id: start + - type: commit + empty: true + message: Add about family + + - type: branch + branch-name: with-ginny + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Add about Ginny + + - type: checkout + branch-name: main + - type: commit + empty: true + message: Add cast.txt + + - type: branch + branch-name: with-sally + - type: checkout + branch-name: with-sally + - type: commit + empty: true + message: Mention Sally + + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Mention Ginny is single + + - type: checkout + branch-name: main + - type: branch + branch-name: with-ron + - type: checkout + branch-name: with-ron + - type: commit + empty: true + message: Mention Ron + + - type: checkout + branch-name: main + - type: merge + branch-name: with-ron + diff --git a/branch_forward/tests/specs/other_branch_non_ff.yml b/branch_forward/tests/specs/other_branch_non_ff.yml new file mode 100644 index 0000000..0bb536f --- /dev/null +++ b/branch_forward/tests/specs/other_branch_non_ff.yml @@ -0,0 +1,43 @@ +initialization: + steps: + - type: commit + empty: true + message: Introduce Harry + id: start + - type: commit + empty: true + message: Add about family + + - type: branch + branch-name: with-ginny + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Add about Ginny + + - type: checkout + branch-name: main + - type: commit + empty: true + message: Add cast.txt + + - type: branch + branch-name: with-sally + - type: checkout + branch-name: with-sally + - type: commit + empty: true + message: Mention Sally + + - type: checkout + branch-name: with-ginny + - type: commit + empty: true + message: Mention Ginny is single + + - type: checkout + branch-name: main + - type: merge + branch-name: with-ginny + diff --git a/branch_forward/tests/test_verify.py b/branch_forward/tests/test_verify.py new file mode 100644 index 0000000..433727d --- /dev/null +++ b/branch_forward/tests/test_verify.py @@ -0,0 +1,52 @@ +from git_autograder import GitAutograderStatus, GitAutograderTestLoader, assert_output + +from ..verify import ( + FAST_FORWARD_REQUIRED, + ONLY_WITH_SALLY_MERGED, + verify, +) + +REPOSITORY_NAME = "branch-forward" + +loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify) + + +def test_success(): + with loader.load("specs/base.yml", "start") as output: + assert_output(output, GitAutograderStatus.SUCCESSFUL) + + +def test_no_merges(): + with loader.load("specs/no_merges.yml", "start") as output: + assert_output( + output, + GitAutograderStatus.UNSUCCESSFUL, + [ONLY_WITH_SALLY_MERGED], + ) + + +def test_other_branch_non_ff(): + with loader.load("specs/other_branch_non_ff.yml", "start") as output: + assert_output( + output, + GitAutograderStatus.UNSUCCESSFUL, + [ONLY_WITH_SALLY_MERGED], + ) + + +def test_other_branch_ff(): + with loader.load("specs/other_branch_ff.yml", "start") as output: + assert_output( + output, + GitAutograderStatus.UNSUCCESSFUL, + [ONLY_WITH_SALLY_MERGED], + ) + + +def test_merge_with_sally_no_ff(): + with loader.load("specs/merge_with_sally_no_ff.yml", "start") as output: + assert_output( + output, + GitAutograderStatus.UNSUCCESSFUL, + [FAST_FORWARD_REQUIRED], + ) diff --git a/branch_forward/verify.py b/branch_forward/verify.py new file mode 100644 index 0000000..4874f07 --- /dev/null +++ b/branch_forward/verify.py @@ -0,0 +1,65 @@ +from typing import List, Optional +from git_autograder import ( + GitAutograderCommit, + GitAutograderExercise, + GitAutograderOutput, + GitAutograderStatus, +) + +FAST_FORWARD_REQUIRED = ( + "You must use a fast-forward merge to bring a branch into 'main'." +) + +ONLY_WITH_SALLY_MERGED = "Only one of the two starting branches can be fast-forward merged into 'main'. Do not create new branches." + +EXPECTED_MAIN_COMMIT_MESSAGES = { + "Introduce Harry", + "Add about family", + "Add cast.txt", + "Mention Sally", +} + + +def get_commit_from_message( + commits: List[GitAutograderCommit], message: str +) -> Optional[GitAutograderCommit]: + """Find a commit with the given message from a list of commits.""" + for commit in commits: + if message.strip() == commit.commit.message.strip(): + return commit + return None + + +def verify(exercise: GitAutograderExercise) -> GitAutograderOutput: + main_commits = exercise.repo.branches.branch("main").commits + head_commit = exercise.repo.branches.branch("main").latest_commit + + sally_commit = get_commit_from_message(main_commits, "Mention Sally") + if sally_commit is None: + raise exercise.wrong_answer([ONLY_WITH_SALLY_MERGED]) + + # Confirm that the fast-forward landed exactly on the expected commit and did not + # introduce a merge commit. + if len(head_commit.parents) != 1: + raise exercise.wrong_answer([FAST_FORWARD_REQUIRED]) + + if head_commit.commit.message.strip() != "Mention Sally": + raise exercise.wrong_answer([ONLY_WITH_SALLY_MERGED]) + + if any(len(commit.parents) > 1 for commit in main_commits): + raise exercise.wrong_answer([FAST_FORWARD_REQUIRED]) + + if len(main_commits) != len(EXPECTED_MAIN_COMMIT_MESSAGES): + raise exercise.wrong_answer([ONLY_WITH_SALLY_MERGED]) + + commit_messages = {commit.commit.message.strip() for commit in main_commits} + if not commit_messages.issubset(EXPECTED_MAIN_COMMIT_MESSAGES): + raise exercise.wrong_answer([ONLY_WITH_SALLY_MERGED]) + + if "Mention Ginny is single" in commit_messages: + raise exercise.wrong_answer([ONLY_WITH_SALLY_MERGED]) + + return exercise.to_output( + ["Great job fast-forward merging only 'with-sally'!"], + GitAutograderStatus.SUCCESSFUL, + )