From feb0f971839a3423f23be574856323f13f496382 Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Mon, 9 Mar 2026 11:20:11 +0800 Subject: [PATCH 1/7] refactor git-autograder to generalize bebavior and reduce indirections --- src/git_autograder/commit.py | 7 ++ src/git_autograder/exercise.py | 12 ++- src/git_autograder/helpers/commit_helper.py | 9 ++ src/git_autograder/helpers/tag_helper.py | 92 +++++++++++++++++++++ src/git_autograder/repo/null_repo.py | 7 ++ src/git_autograder/repo/repo.py | 6 ++ src/git_autograder/repo/repo_base.py | 5 ++ src/git_autograder/tag.py | 42 ++++++++++ 8 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 src/git_autograder/helpers/tag_helper.py create mode 100644 src/git_autograder/tag.py diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index d67cf71..dfbc941 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -34,6 +34,13 @@ def branches(self) -> List[str]: containing_branches = self.commit.repo.git.branch("--contains", self.hexsha) return [line[2:] for line in containing_branches.split("\n")] + @property + def message(self) -> str: + msg = self.commit.message + if isinstance(msg, bytes): + return msg.decode("utf-8", errors="replace") + return msg + def checkout(self) -> None: self.commit.repo.git.checkout(self.commit) diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 58a3d4c..a6b40ec 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Literal, Optional import pytz -from git import InvalidGitRepositoryError +from git import InvalidGitRepositoryError, Repo from git_autograder.answers.answers import GitAutograderAnswers from git_autograder.answers.answers_parser import GitAutograderAnswersParser @@ -78,6 +78,16 @@ def __init__( self.__answers_parser: Optional[GitAutograderAnswersParser] = None self.__answers: Optional[GitAutograderAnswers] = None + @property + def git_repo(self) -> Repo: + ignored_repo_types = {"ignore", "local-ignore"} + if self.config.exercise_repo.repo_type in ignored_repo_types: + raise AttributeError( + "Cannot access attribute git_repo on GitAutograderExercise. " + "Check that your repo_type is not 'ignore' or 'local-ignore'." + ) + return self.repo.repo + @property def answers(self) -> GitAutograderAnswers: """Parses a QnA file (answers.txt). Verifies that the file exists.""" diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 183608a..e952f45 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -22,3 +22,12 @@ def commit_or_none( return GitAutograderCommit(c) except Exception: return None + + def commit_from_message( + self, commits: list[GitAutograderCommit], message: str + ) -> Optional[GitAutograderCommit]: + target = message.strip() + for commit in commits: + if commit.message.strip() == target: + return commit + return None diff --git a/src/git_autograder/helpers/tag_helper.py b/src/git_autograder/helpers/tag_helper.py new file mode 100644 index 0000000..4f875d6 --- /dev/null +++ b/src/git_autograder/helpers/tag_helper.py @@ -0,0 +1,92 @@ +from typing import List, Optional + +from git import Repo +from git.exc import GitCommandError + +from git_autograder.commit import GitAutograderCommit +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.tag import GitAutograderTag + + +class TagHelper: + MISSING_TAG = "Tag {tag} is missing." + MISSING_REMOTE = "Remote {remote} is missing." + CANNOT_QUERY_REMOTE_TAGS = "Unable to query remote tags for '{remote}'." + + @staticmethod + def _parse_remote_tag_names(raw: str) -> List[str]: + names: List[str] = [] + seen = set() + + for line in raw.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + + ref = parts[1] + if not ref.startswith("refs/tags/"): + continue + + name = ref[len("refs/tags/") :] + if name.endswith("^{}"): + name = name[:-3] + + if name not in seen: + seen.add(name) + names.append(name) + + return names + + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def tag_or_none(self, tag_name: str) -> Optional[GitAutograderTag]: + for tag_ref in self.repo.tags: + if str(tag_ref) == tag_name: + return GitAutograderTag(tag_ref) + return None + + def tag(self, tag_name: str) -> GitAutograderTag: + t = self.tag_or_none(tag_name) + if t is None: + raise GitAutograderInvalidStateException( + self.MISSING_TAG.format(tag=tag_name) + ) + return t + + def has_tag(self, tag_name: str) -> bool: + return self.tag_or_none(tag_name) is not None + + def commit_or_none(self, tag_name: str) -> Optional[GitAutograderCommit]: + t = self.tag_or_none(tag_name) + if t is None: + return None + return t.commit + + def points_to(self, tag_name: str, commit: GitAutograderCommit) -> bool: + tag_commit = self.commit_or_none(tag_name) + return tag_commit is not None and tag_commit.hexsha == commit.hexsha + + def remote_tag_names_or_none(self, remote: str = "origin") -> Optional[List[str]]: + if not any(r.name == remote for r in self.repo.remotes): + return None + + try: + raw = self.repo.git.ls_remote("--tags", remote) + except GitCommandError: + return None + + return self._parse_remote_tag_names(raw) + + def remote_tag_names(self, remote: str = "origin") -> List[str]: + if not any(r.name == remote for r in self.repo.remotes): + raise GitAutograderInvalidStateException( + self.MISSING_REMOTE.format(remote=remote) + ) + + names = self.remote_tag_names_or_none(remote) + if names is None: + raise GitAutograderInvalidStateException( + self.CANNOT_QUERY_REMOTE_TAGS.format(remote=remote) + ) + return names diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 553e529..8b7bdca 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -4,6 +4,7 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.remote_helper import RemoteHelper +from git_autograder.helpers.tag_helper import TagHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -37,6 +38,12 @@ def files(self) -> FileHelper: raise AttributeError( "Cannot access attribute files on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) + + @property + def tags(self) -> TagHelper: + raise AttributeError( + "Cannot access attribute files on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + ) def __getattr__(self, name: str) -> None: raise AttributeError( diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9dbd36d..7e58724 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -6,6 +6,7 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.remote_helper import RemoteHelper +from git_autograder.helpers.tag_helper import TagHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -24,6 +25,7 @@ def __init__( self._commits: CommitHelper = CommitHelper(self._repo) self._remotes: RemoteHelper = RemoteHelper(self._repo) self._files: FileHelper = FileHelper(self._repo) + self._tags: TagHelper = TagHelper(self._repo) @property def repo(self) -> Repo: @@ -44,3 +46,7 @@ def remotes(self) -> RemoteHelper: @property def files(self) -> FileHelper: return self._files + + @property + def tags(self) -> TagHelper: + return self._tags diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index b0f2ef3..9302f87 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -6,6 +6,7 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.file_helper import FileHelper from git_autograder.helpers.remote_helper import RemoteHelper +from git_autograder.helpers.tag_helper import TagHelper class GitAutograderRepoBase(ABC): @@ -28,3 +29,7 @@ def remotes(self) -> RemoteHelper: ... @property @abstractmethod def files(self) -> FileHelper: ... + + @property + @abstractmethod + def tags(self) -> TagHelper: ... diff --git a/src/git_autograder/tag.py b/src/git_autograder/tag.py new file mode 100644 index 0000000..38749e3 --- /dev/null +++ b/src/git_autograder/tag.py @@ -0,0 +1,42 @@ +from typing import Any, Optional + +from git.refs.tag import TagReference + +from git_autograder.commit import GitAutograderCommit + + +class GitAutograderTag: + def __init__(self, tag_ref: TagReference) -> None: + self.tag_ref = tag_ref + + def __eq__(self, value: Any) -> bool: + return isinstance(value, GitAutograderTag) and value.tag_ref == self.tag_ref + + @property + def name(self) -> str: + return str(self.tag_ref) + + @property + def commit(self) -> GitAutograderCommit: + return GitAutograderCommit(self.tag_ref.commit) + + @property + def is_annotated(self) -> bool: + return self.tag_ref.tag is not None + + @property + def is_lightweight(self) -> bool: + return self.tag_ref.tag is None + + def message_or_none( + self, *, strip: bool = True, lower: bool = False + ) -> Optional[str]: + if self.tag_ref.tag is None: + return None + + message = self.tag_ref.tag.message + if strip: + message = message.strip() + if lower: + message = message.lower() + return message From 80d729e6c4aeee4468de0234afb072772454df3c Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Mon, 9 Mar 2026 13:18:40 +0800 Subject: [PATCH 2/7] make generalisation in FileHelper --- src/git_autograder/helpers/file_helper.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index b10595d..d91e7d9 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -28,3 +28,16 @@ def file(self, path: Union[str, os.PathLike[str]]) -> Iterator[TextIO]: def untracked_files(self) -> List[str]: return self.repo.untracked_files + + def content_equal(self, path: Union[str, os.PathLike[str]], expected: str): + with self.file_or_none(path) as input_file: + if input_file is None: + return False + contents = [ + line.strip() for line in input_file.readlines() if line.strip() != "" + ] + if contents != expected: + return False + + return True + From 87c80ecd8fc9b94e69d805a664e4c9028f21f029 Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Sun, 15 Mar 2026 02:48:36 +0800 Subject: [PATCH 3/7] further refactor gitautograder --- src/git_autograder/__init__.py | 2 ++ src/git_autograder/helpers/__init__.py | 3 ++- src/git_autograder/helpers/file_helper.py | 23 ++++++++++++++++++----- src/git_autograder/helpers/tag_helper.py | 19 +++---------------- src/git_autograder/repo/null_repo.py | 2 +- src/git_autograder/tag.py | 3 +++ 6 files changed, 29 insertions(+), 23 deletions(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 41ec0b0..bf0c7c7 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -10,6 +10,7 @@ "GitAutograderRemote", "GitAutograderCommit", "GitAutograderExercise", + "GitAutograderTag" ] from .branch import GitAutograderBranch @@ -25,3 +26,4 @@ from .repo.repo import GitAutograderRepo from .repo.repo_base import GitAutograderRepoBase from .status import GitAutograderStatus +from .tag import GitAutograderTag diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index 938eb72..a2e325d 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,6 +1,7 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper"] +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper", "TagHelper"] from .branch_helper import BranchHelper from .commit_helper import CommitHelper from .remote_helper import RemoteHelper from .file_helper import FileHelper +from .tag_helper import TagHelper diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index d91e7d9..9eb01c0 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -29,15 +29,28 @@ def file(self, path: Union[str, os.PathLike[str]]) -> Iterator[TextIO]: def untracked_files(self) -> List[str]: return self.repo.untracked_files - def content_equal(self, path: Union[str, os.PathLike[str]], expected: str): + def is_content_equal(self, path: Union[str, os.PathLike[str]], expected: str) -> bool: with self.file_or_none(path) as input_file: if input_file is None: return False - contents = [ + + input_lines = [ line.strip() for line in input_file.readlines() if line.strip() != "" ] - if contents != expected: - return False - return True + expected_lines = [ + line.strip() for line in expected.splitlines() if line.strip() != "" + ] + return input_lines == expected_lines + + def are_files_equal(self, given: str, expected: str) -> bool: + """Compare normalized contents of two files.""" + + with ( + open(given, "r") as given_file, + open(expected, "r") as expected_file, + ): + contents = given_file.read().replace("\n", "") + expected_contents = expected_file.read().replace("\n", "") + return contents == expected_contents diff --git a/src/git_autograder/helpers/tag_helper.py b/src/git_autograder/helpers/tag_helper.py index 4f875d6..e4c502e 100644 --- a/src/git_autograder/helpers/tag_helper.py +++ b/src/git_autograder/helpers/tag_helper.py @@ -3,7 +3,6 @@ from git import Repo from git.exc import GitCommandError -from git_autograder.commit import GitAutograderCommit from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.tag import GitAutograderTag @@ -14,9 +13,8 @@ class TagHelper: CANNOT_QUERY_REMOTE_TAGS = "Unable to query remote tags for '{remote}'." @staticmethod - def _parse_remote_tag_names(raw: str) -> List[str]: - names: List[str] = [] - seen = set() + def _parse_remote_tag_names(raw: str) -> list[str]: + names: list[str] = [] for line in raw.splitlines(): parts = line.split() @@ -31,8 +29,7 @@ def _parse_remote_tag_names(raw: str) -> List[str]: if name.endswith("^{}"): name = name[:-3] - if name not in seen: - seen.add(name) + if name not in names: names.append(name) return names @@ -57,16 +54,6 @@ def tag(self, tag_name: str) -> GitAutograderTag: def has_tag(self, tag_name: str) -> bool: return self.tag_or_none(tag_name) is not None - def commit_or_none(self, tag_name: str) -> Optional[GitAutograderCommit]: - t = self.tag_or_none(tag_name) - if t is None: - return None - return t.commit - - def points_to(self, tag_name: str, commit: GitAutograderCommit) -> bool: - tag_commit = self.commit_or_none(tag_name) - return tag_commit is not None and tag_commit.hexsha == commit.hexsha - def remote_tag_names_or_none(self, remote: str = "origin") -> Optional[List[str]]: if not any(r.name == remote for r in self.repo.remotes): return None diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 8b7bdca..f70557f 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -42,7 +42,7 @@ def files(self) -> FileHelper: @property def tags(self) -> TagHelper: raise AttributeError( - "Cannot access attribute files on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + "Cannot access attribute tags on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) def __getattr__(self, name: str) -> None: diff --git a/src/git_autograder/tag.py b/src/git_autograder/tag.py index 38749e3..e5a8577 100644 --- a/src/git_autograder/tag.py +++ b/src/git_autograder/tag.py @@ -40,3 +40,6 @@ def message_or_none( if lower: message = message.lower() return message + + def points_to(self, commit: GitAutograderCommit) -> bool: + return self.commit.hexsha == commit.hexsha From 77efbc41b97afc61764b257c1213a04f5fb15eb7 Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Sun, 22 Mar 2026 21:47:33 +0800 Subject: [PATCH 4/7] update file comparison logic --- src/git_autograder/helpers/file_helper.py | 45 ++++++++++++++++++----- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index 9eb01c0..1896c68 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -44,13 +44,38 @@ def is_content_equal(self, path: Union[str, os.PathLike[str]], expected: str) -> return input_lines == expected_lines - def are_files_equal(self, given: str, expected: str) -> bool: - """Compare normalized contents of two files.""" - - with ( - open(given, "r") as given_file, - open(expected, "r") as expected_file, - ): - contents = given_file.read().replace("\n", "") - expected_contents = expected_file.read().replace("\n", "") - return contents == expected_contents + def are_files_equal( + self, + given: Union[str, os.PathLike[str]], + expected: Union[str, os.PathLike[str]], + *, + exact_match: bool = False, + ) -> bool: + """ + Compare two repo-relative files. + Returns False if either file is missing. + + - exact_match=False: compare normalized non-empty stripped lines. + - exact_match=True: compare full file contents exactly. + """ + with self.file_or_none(given) as given_file: + if given_file is None: + return False + given_contents = given_file.read() + + with self.file_or_none(expected) as expected_file: + if expected_file is None: + return False + expected_contents = expected_file.read() + + if exact_match: + return given_contents == expected_contents + + given_lines = [ + line.strip() for line in given_contents.splitlines() if line.strip() != "" + ] + expected_lines = [ + line.strip() for line in expected_contents.splitlines() if line.strip() != "" + ] + + return given_lines == expected_lines From 4cf6b90f7a8c13b6a7da8573f3bbb644fe1eb102 Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Sun, 22 Mar 2026 23:40:05 +0800 Subject: [PATCH 5/7] ensure file is opened using encoding=utf-8 --- src/git_autograder/helpers/file_helper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index 1896c68..c6efd39 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -17,13 +17,13 @@ def file_or_none( if not os.path.isfile(file_path): yield None else: - with open(file_path, "r") as file: + with open(file_path, "r", encoding="utf-8") as file: yield file @contextmanager def file(self, path: Union[str, os.PathLike[str]]) -> Iterator[TextIO]: file_path = os.path.join(self.repo.working_dir, path) - with open(file_path, "r") as file: + with open(file_path, "r", encoding="utf-8") as file: yield file def untracked_files(self) -> List[str]: From d57e8f4631954299bd845a825766d51ee1a2aaf4 Mon Sep 17 00:00:00 2001 From: SAN-MUYUN Date: Mon, 23 Mar 2026 00:48:47 +0800 Subject: [PATCH 6/7] implement generalization for commit count --- src/git_autograder/branch.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index 8a4e3c7..e494510 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -141,5 +141,17 @@ def has_added_file(self, file_path: str) -> bool: return True return False + def has_at_least_commits(self, n: int, *, user_only: bool = False) -> bool: + """ + Return True if the branch has at least n commits. + + If user_only=True, count only user commits. + """ + if n < 0: + raise ValueError("n must be >= 0") + + commits = self.user_commits if user_only else self.commits + return len(commits) >= n + def checkout(self) -> None: self.branch.checkout() From b70127910592c1017a7f407fcc8f002af3d90d1d Mon Sep 17 00:00:00 2001 From: Desmond Wong <134215008+desmondwong1215@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:24:05 +0800 Subject: [PATCH 7/7] Apply suggestion from @desmondwong1215 --- src/git_autograder/branch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index e494510..55a4262 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -141,7 +141,7 @@ def has_added_file(self, file_path: str) -> bool: return True return False - def has_at_least_commits(self, n: int, *, user_only: bool = False) -> bool: + def has_at_least_commits(self, n: int, *, user_only: bool = True) -> bool: """ Return True if the branch has at least n commits.