diff --git a/ff_undo/.gitmastery-exercise.json b/ff_undo/.gitmastery-exercise.json
new file mode 100644
index 0000000..53fddb6
--- /dev/null
+++ b/ff_undo/.gitmastery-exercise.json
@@ -0,0 +1,18 @@
+{
+ "exercise_name": "ff-undo",
+ "tags": [
+ "git-branch",
+ "git-merge",
+ "git-reset"
+ ],
+ "requires_git": true,
+ "requires_github": false,
+ "base_files": {},
+ "exercise_repo": {
+ "repo_type": "local",
+ "repo_name": "play-characters",
+ "repo_title": null,
+ "create_fork": null,
+ "init": true
+ }
+}
\ No newline at end of file
diff --git a/ff_undo/README.md b/ff_undo/README.md
new file mode 100644
index 0000000..289ff6e
--- /dev/null
+++ b/ff_undo/README.md
@@ -0,0 +1,40 @@
+# ff-undo
+
+This exercise focuses on **undoing a merge in Git**. You will practice how to revert unwanted merge commits while keeping branches and commits intact.
+
+## Task
+
+You have a repository with two branches:
+
+- `main` branch, which initially contains commits:
+ - `Add Rick`
+ - `Add Morty`
+- `others` branch, which contains commits:
+ - `Add Birdperson`
+ - `Add Cyborg to birdperson.txt`
+ - `Add Tammy`
+
+A merge with fast forward from `others` into `main` has been done incorrectly. Your task is:
+
+1. **Undo the merge on `main`**, so that only `Add Rick` and `Add Morty` remain on `main`.
+2. Ensure the `others` branch still exists with all its commits intact.
+3. Do not delete any commits; only undo the merge on `main`.
+
+## Hints
+
+
+Hint 1: Check your branches
+
+Use `git branch` to see the current branches and verify `main` and `others` exist.
+
+
+
+Hint 2: View commit history
+
+Use `git log --oneline` on `main` to identify the merge commit that needs to be undone.
+
+
+
+Hint 3: Undo the merge
+
+You can undo a merge using: `git reset --hard `
diff --git a/ff_undo/__init__.py b/ff_undo/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ff_undo/download.py b/ff_undo/download.py
new file mode 100644
index 0000000..0f4db87
--- /dev/null
+++ b/ff_undo/download.py
@@ -0,0 +1,36 @@
+from exercise_utils.cli import run_command
+from exercise_utils.gitmastery import create_start_tag
+
+__resources__ = {}
+
+
+def setup(verbose: bool = False):
+ # Marks the start of setup (Git-Mastery internal logging)
+ create_start_tag(verbose)
+
+ # Create initial files and commits
+ run_command('echo "Scientist" > rick.txt', verbose)
+ run_command('git add .', verbose)
+ run_command('git commit -m "Add Rick"', verbose)
+
+ run_command('echo "Boy" > morty.txt', verbose)
+ run_command('git add .', verbose)
+ run_command('git commit -m "Add Morty"', verbose)
+
+ # Create and switch to branch 'others'
+ run_command('git switch -c others', verbose)
+ run_command('echo "No job" > birdperson.txt', verbose)
+ run_command('git add .', verbose)
+ run_command('git commit -m "Add Birdperson"', verbose)
+
+ run_command('echo "Cyborg" >> birdperson.txt', verbose)
+ run_command('git add .', verbose)
+ run_command('git commit -m "Add Cyborg to birdperson.txt"', verbose)
+
+ run_command('echo "Spy" > tammy.txt', verbose)
+ run_command('git add .', verbose)
+ run_command('git commit -m "Add Tammy"', verbose)
+
+ # Merge back into main
+ run_command('git switch main', verbose)
+ run_command('git merge others -m "Introduce others"', verbose)
\ No newline at end of file
diff --git a/ff_undo/tests/__init__.py b/ff_undo/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/ff_undo/tests/specs/base.yml b/ff_undo/tests/specs/base.yml
new file mode 100644
index 0000000..b77c9fe
--- /dev/null
+++ b/ff_undo/tests/specs/base.yml
@@ -0,0 +1,23 @@
+initialization:
+ steps:
+ - type: commit
+ id: start
+ empty: true
+ message: Empty commit
+ - type: commit
+ message: "Add Rick"
+ - type: commit
+ message: "Add Morty"
+ - type: branch
+ branch-name: others
+ - type: checkout
+ branch-name: others
+ - type: commit
+ message: "Add Birdperson"
+ - type: commit
+ message: "Add Cyborg to birdperson.txt"
+ - type: commit
+ message: "Add Tammy"
+ - type: checkout
+ branch-name: main
+
diff --git a/ff_undo/tests/specs/branch_missing.yml b/ff_undo/tests/specs/branch_missing.yml
new file mode 100644
index 0000000..8fe1a43
--- /dev/null
+++ b/ff_undo/tests/specs/branch_missing.yml
@@ -0,0 +1,10 @@
+initialization:
+ steps:
+ - type: commit
+ id: start
+ empty: true
+ message: Empty commit
+ - type: commit
+ message: "Add Rick"
+ - type: commit
+ message: "Add Morty"
diff --git a/ff_undo/tests/specs/main_commits_incorrect.yml b/ff_undo/tests/specs/main_commits_incorrect.yml
new file mode 100644
index 0000000..510c4ab
--- /dev/null
+++ b/ff_undo/tests/specs/main_commits_incorrect.yml
@@ -0,0 +1,18 @@
+initialization:
+ steps:
+ - type: commit
+ id: start
+ empty: true
+ message: Empty commit
+ - type: commit
+ message: "Add Morty"
+ - type: branch
+ branch-name: others
+ - type: checkout
+ branch-name: others
+ - type: commit
+ message: "Add Birdperson"
+ - type: commit
+ message: "Add Cyborg to birdperson.txt"
+ - type: commit
+ message: "Add Tammy"
diff --git a/ff_undo/tests/specs/merge_not_undone.yml b/ff_undo/tests/specs/merge_not_undone.yml
new file mode 100644
index 0000000..0a77d8a
--- /dev/null
+++ b/ff_undo/tests/specs/merge_not_undone.yml
@@ -0,0 +1,26 @@
+initialization:
+ steps:
+ - type: commit
+ id: start
+ empty: true
+ message: Empty commit
+ - type: commit
+ message: "Add Rick"
+ - type: commit
+ message: "Add Morty"
+ - type: branch
+ branch-name: others
+ - type: checkout
+ branch-name: others
+ - type: commit
+ message: "Add Birdperson"
+ - type: commit
+ message: "Add Cyborg to birdperson.txt"
+ - type: commit
+ message: "Add Tammy"
+ - type: checkout
+ branch-name: main
+ - type: merge
+ branch-name: others
+ no-ff: false
+ message: "Introduce others"
diff --git a/ff_undo/tests/specs/others_commits_incorrect.yml b/ff_undo/tests/specs/others_commits_incorrect.yml
new file mode 100644
index 0000000..b0acc2a
--- /dev/null
+++ b/ff_undo/tests/specs/others_commits_incorrect.yml
@@ -0,0 +1,19 @@
+initialization:
+ steps:
+ - type: commit
+ id: start
+ empty: true
+ message: Empty commit
+ - type: commit
+ message: "Add Rick"
+ - type: commit
+ message: "Add Morty"
+ - type: branch
+ branch-name: others
+ - type: checkout
+ branch-name: others
+ - type: commit
+ message: "Add Birdperson"
+ - type: commit
+ message: "Add Tammy"
+
diff --git a/ff_undo/tests/test_verify.py b/ff_undo/tests/test_verify.py
new file mode 100644
index 0000000..097bd76
--- /dev/null
+++ b/ff_undo/tests/test_verify.py
@@ -0,0 +1,37 @@
+from git_autograder import GitAutograderTestLoader, assert_output
+from git_autograder.status import GitAutograderStatus
+from ..verify import (
+ MERGE_NOT_UNDONE,
+ MAIN_COMMITS_INCORRECT,
+ OTHERS_COMMITS_INCORRECT,
+ OTHERS_BRANCH_MISSING,
+ verify
+)
+
+REPOSITORY_NAME = "ff-undo"
+
+loader = GitAutograderTestLoader(__file__, REPOSITORY_NAME, verify)
+
+def test_correct_solution():
+ with loader.load("specs/base.yml") as output:
+ assert_output(output, GitAutograderStatus.SUCCESSFUL)
+
+
+def test_merge_not_undone():
+ with loader.load("specs/merge_not_undone.yml") as output:
+ assert_output(output, GitAutograderStatus.UNSUCCESSFUL, [MERGE_NOT_UNDONE])
+
+
+def test_branch_missing():
+ with loader.load("specs/branch_missing.yml") as output:
+ assert_output(output, GitAutograderStatus.UNSUCCESSFUL, [OTHERS_BRANCH_MISSING])
+
+
+def test_main_commits_incorrect():
+ with loader.load("specs/main_commits_incorrect.yml") as output:
+ assert_output(output, GitAutograderStatus.UNSUCCESSFUL, [MAIN_COMMITS_INCORRECT])
+
+
+def test_others_commits_incorrect():
+ with loader.load("specs/others_commits_incorrect.yml") as output:
+ assert_output(output, GitAutograderStatus.UNSUCCESSFUL, [OTHERS_COMMITS_INCORRECT])
diff --git a/ff_undo/verify.py b/ff_undo/verify.py
new file mode 100644
index 0000000..42faac8
--- /dev/null
+++ b/ff_undo/verify.py
@@ -0,0 +1,57 @@
+from git_autograder import (
+ GitAutograderOutput,
+ GitAutograderExercise,
+ GitAutograderStatus,
+)
+
+MERGE_NOT_UNDONE = (
+ "You need to undo the merge."
+)
+MAIN_COMMITS_INCORRECT = (
+ "The main branch does not contain the expected commits "
+ "The main branch does not contain both commits 'Add Rick' and 'Add Morty'."
+)
+OTHERS_COMMITS_INCORRECT = (
+ "The others branch does not contain the expected commits "
+ "'Add Birdperson', 'Add Cyborg to birdperson.txt', and 'Add Tammy'."
+)
+OTHERS_BRANCH_MISSING = (
+ "The branch 'others' no longer exists. You should not delete it, only undo the merge on main."
+)
+
+def verify(exercise: GitAutograderExercise) -> GitAutograderOutput:
+
+ # Check if branch others exists
+ if not exercise.repo.branches.has_branch("others"):
+ raise exercise.wrong_answer([OTHERS_BRANCH_MISSING])
+
+ # Take all commit messages on main
+ commit_messages_in_main = [c.message.strip() for c in exercise.repo.repo.iter_commits("main")]
+
+ # Take all commit messages on others
+ commit_messages_in_others = [c.message.strip() for c in exercise.repo.repo.iter_commits("others")]
+
+ # Check that the merge commit is not present on main
+ has_birdperson_in_main = any("Add Birdperson" in msg for msg in commit_messages_in_main)
+ has_cyborg_in_main = any("Add Cyborg to birdperson.txt" in msg for msg in commit_messages_in_main)
+ has_tammy_in_main = any("Add Tammy" in msg for msg in commit_messages_in_main)
+ if has_birdperson_in_main or has_birdperson_in_main or has_tammy_in_main:
+ raise exercise.wrong_answer([MERGE_NOT_UNDONE])
+
+ # Check that commits in main are only the initial 2 commits
+ has_rick = any("Add Rick" in msg for msg in commit_messages_in_main)
+ has_morty = any("Add Morty" in msg for msg in commit_messages_in_main)
+ if len(commit_messages_in_main) != 3 or not (has_rick and has_morty):
+ raise exercise.wrong_answer([MAIN_COMMITS_INCORRECT])
+
+ # Check that commits in others are only the initial 3 commits
+ has_birdperson = any("Add Birdperson" in msg for msg in commit_messages_in_others)
+ has_cyborg = any("Add Cyborg to birdperson.txt" in msg for msg in commit_messages_in_others)
+ has_tammy = any("Add Tammy" in msg for msg in commit_messages_in_others)
+ if len(commit_messages_in_others) != 6 or not (has_birdperson and has_cyborg and has_tammy):
+ raise exercise.wrong_answer([OTHERS_COMMITS_INCORRECT])
+
+ return exercise.to_output(
+ ["You have successfully undone the merge of branch 'others'."],
+ GitAutograderStatus.SUCCESSFUL,
+ )