From 0ffb34045eb2382cf461dcdd721d7170d40cc035 Mon Sep 17 00:00:00 2001 From: jia xin Date: Mon, 19 Jan 2026 12:51:46 +0800 Subject: [PATCH 01/12] Add include_remote_repo parameter --- exercise_utils/test.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index bed433c9..95a65d85 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -45,11 +45,13 @@ def __init__( grade_func: Callable[[GitAutograderExercise], GitAutograderOutput], clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, + include_remote_repo: bool = False, ) -> None: self.exercise_name = exercise_name self.grade_func = grade_func self.clone_from = clone_from self.mock_answers = mock_answers + self.include_remote_repo = include_remote_repo self.__rs: Optional[RepoSmith] = None self.__rs_context: Optional[ContextManager[RepoSmith]] = None self.__temp_dir: Optional[tempfile.TemporaryDirectory] = None @@ -156,11 +158,13 @@ def __enter__(self) -> Tuple[Self, RepoSmith]: False, existing_path=repo_path.absolute().as_posix(), clone_from=self.clone_from, + include_remote_repo=self.include_remote_repo, ) else: self.__rs_context = create_repo_smith( False, existing_path=repo_path.absolute().as_posix(), + include_remote_repo=self.include_remote_repo, ) self.__rs = self.__rs_context.__enter__() self.__rs.add_helper(GitMasteryHelper) @@ -197,12 +201,14 @@ def start( self, clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, + include_remote_repo: bool = False, ) -> Iterator[Tuple[GitAutograderTest, RepoSmith]]: test = GitAutograderTest( self.exercise_name, self.grade_func, clone_from, mock_answers, + include_remote_repo, ) with test as (ctx, rs): yield (ctx, rs) From 0ea4b5e6ce36ab0c9061d343b2d460eb64531d48 Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 10:51:29 +0800 Subject: [PATCH 02/12] Return extra instance of rs for remote repo --- exercise_utils/test.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 95a65d85..0d01c4e9 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -53,7 +53,8 @@ def __init__( self.mock_answers = mock_answers self.include_remote_repo = include_remote_repo self.__rs: Optional[RepoSmith] = None - self.__rs_context: Optional[ContextManager[RepoSmith]] = None + self.__rs_remote: Optional[RepoSmith] = None + self.__rs_context: Optional[ContextManager[Tuple[RepoSmith, Optional[RepoSmith]]]] = None self.__temp_dir: Optional[tempfile.TemporaryDirectory] = None self.__patches: List[mock._patch] = [] @@ -61,6 +62,10 @@ def __init__( def rs(self) -> RepoSmith: assert self.__rs is not None return self.__rs + + @property + def rs_remote(self) -> Optional[RepoSmith]: + return self.__rs_remote def run(self) -> GitAutograderOutput: output: Optional[GitAutograderOutput] = None @@ -97,7 +102,7 @@ def run(self) -> GitAutograderOutput: assert output is not None return output - def __enter__(self) -> Tuple[Self, RepoSmith]: + def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: # We will mock all accesses to the config to avoid reading the file itself # Only the exercise name and repo_name matters, everything else isn't used repo_name = "repo" @@ -166,9 +171,12 @@ def __enter__(self) -> Tuple[Self, RepoSmith]: existing_path=repo_path.absolute().as_posix(), include_remote_repo=self.include_remote_repo, ) - self.__rs = self.__rs_context.__enter__() + self.__rs, self.__rs_remote = self.__rs_context.__enter__() self.__rs.add_helper(GitMasteryHelper) + if self.include_remote_repo: + return self, self.rs, self.rs_remote + return self, self.rs def __exit__( @@ -202,7 +210,7 @@ def start( clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, include_remote_repo: bool = False, - ) -> Iterator[Tuple[GitAutograderTest, RepoSmith]]: + ) -> Iterator[Tuple[GitAutograderTest, RepoSmith, *tuple[RepoSmith]]]: test = GitAutograderTest( self.exercise_name, self.grade_func, @@ -210,8 +218,8 @@ def start( mock_answers, include_remote_repo, ) - with test as (ctx, rs): - yield (ctx, rs) + with test as result: + yield result def assert_output( From 18529bb4a52ad07b29ba0414e3ffd763264f59d1 Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 12:07:15 +0800 Subject: [PATCH 03/12] Get repos via separate create_repo_smith calls --- exercise_utils/test.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 0d01c4e9..b46d14e4 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -54,7 +54,8 @@ def __init__( self.include_remote_repo = include_remote_repo self.__rs: Optional[RepoSmith] = None self.__rs_remote: Optional[RepoSmith] = None - self.__rs_context: Optional[ContextManager[Tuple[RepoSmith, Optional[RepoSmith]]]] = None + self.__rs_context: Optional[ContextManager[RepoSmith]] = None + self.__rs_remote_context: Optional[ContextManager[RepoSmith]] = None self.__temp_dir: Optional[tempfile.TemporaryDirectory] = None self.__patches: List[mock._patch] = [] @@ -158,20 +159,28 @@ def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: # run all commands within the repo os.chdir(repo_path) + # Create remote repo first if needed + if self.include_remote_repo: + self.__rs_remote_context = create_repo_smith(False) + self.__rs_remote = self.__rs_remote_context.__enter__() + # Initialize as bare repo + self.__rs_remote.repo = Repo.init( + self.__rs_remote.repo.working_dir, bare=True + ) + + # Create local repo if self.clone_from is not None: self.__rs_context = create_repo_smith( False, existing_path=repo_path.absolute().as_posix(), clone_from=self.clone_from, - include_remote_repo=self.include_remote_repo, ) else: self.__rs_context = create_repo_smith( False, existing_path=repo_path.absolute().as_posix(), - include_remote_repo=self.include_remote_repo, ) - self.__rs, self.__rs_remote = self.__rs_context.__enter__() + self.__rs = self.__rs_context.__enter__() self.__rs.add_helper(GitMasteryHelper) if self.include_remote_repo: @@ -194,6 +203,9 @@ def __exit__( if self.__rs_context is not None: self.__rs_context.__exit__(exc_type, exc_val, None) + if self.__rs_remote_context is not None: + self.__rs_remote_context.__exit__(exc_type, exc_val, None) + class GitAutograderTestLoader: def __init__( From 2d78fbce36904c24b373008ef0837b7e69a8bebf Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 12:12:31 +0800 Subject: [PATCH 04/12] Fix call to create_repo_smith --- exercise_utils/test.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index b46d14e4..160de055 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -161,12 +161,13 @@ def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: # Create remote repo first if needed if self.include_remote_repo: - self.__rs_remote_context = create_repo_smith(False) - self.__rs_remote = self.__rs_remote_context.__enter__() - # Initialize as bare repo - self.__rs_remote.repo = Repo.init( - self.__rs_remote.repo.working_dir, bare=True + # Create a bare repository in a temp directory + remote_temp_dir = tempfile.mkdtemp() + remote_repo = Repo.init(remote_temp_dir, bare=True) + self.__rs_remote_context = create_repo_smith( + False, existing_path=remote_temp_dir ) + self.__rs_remote = self.__rs_remote_context.__enter__() # Create local repo if self.clone_from is not None: From 5fbe3a3f73b012985e83e10752a9ce250d13827b Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 13:06:31 +0800 Subject: [PATCH 05/12] Overload start() function --- exercise_utils/test.py | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 160de055..8eadc8ec 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from datetime import datetime from pathlib import Path -from typing import Callable, ContextManager, Dict, Iterator, List, Optional, Self, Tuple +from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Self, Tuple, overload from unittest import mock import pytz @@ -103,7 +103,8 @@ def run(self) -> GitAutograderOutput: assert output is not None return output - def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: + + def __enter__(self) -> Any: # We will mock all accesses to the config to avoid reading the file itself # Only the exercise name and repo_name matters, everything else isn't used repo_name = "repo" @@ -163,7 +164,7 @@ def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: if self.include_remote_repo: # Create a bare repository in a temp directory remote_temp_dir = tempfile.mkdtemp() - remote_repo = Repo.init(remote_temp_dir, bare=True) + Repo.init(remote_temp_dir, bare=True) self.__rs_remote_context = create_repo_smith( False, existing_path=remote_temp_dir ) @@ -184,10 +185,7 @@ def __enter__(self) -> Tuple[Self, RepoSmith, *tuple[RepoSmith]]: self.__rs = self.__rs_context.__enter__() self.__rs.add_helper(GitMasteryHelper) - if self.include_remote_repo: - return self, self.rs, self.rs_remote - - return self, self.rs + return self, self.rs, self.rs_remote def __exit__( self, @@ -217,13 +215,29 @@ def __init__( self.exercise_name = exercise_name self.grade_func = grade_func + @overload + def start( + self, + clone_from: Optional[str] = None, + mock_answers: Optional[Dict[str, str]] = None, + include_remote_repo: Literal[False] = False, + ) -> ContextManager[Tuple[GitAutograderTest, RepoSmith]]: ... + + @overload + def start( + self, + clone_from: Optional[str] = None, + mock_answers: Optional[Dict[str, str]] = None, + include_remote_repo: Literal[True] = True, + ) -> ContextManager[Tuple[GitAutograderTest, RepoSmith, RepoSmith]]: ... + @contextmanager def start( self, clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, include_remote_repo: bool = False, - ) -> Iterator[Tuple[GitAutograderTest, RepoSmith, *tuple[RepoSmith]]]: + ) -> Iterator[Tuple]: test = GitAutograderTest( self.exercise_name, self.grade_func, @@ -231,8 +245,12 @@ def start( mock_answers, include_remote_repo, ) - with test as result: - yield result + if include_remote_repo: + with test as (test, rs, rs_remote): + yield test, rs, rs_remote + else: + with test as (test, rs): + yield test, rs def assert_output( From ef12c68d44c31edb1770fd8b4c1018c1655406fe Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 13:24:26 +0800 Subject: [PATCH 06/12] Remove * from function parameters --- exercise_utils/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 8eadc8ec..48e93cc1 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -249,7 +249,7 @@ def start( with test as (test, rs, rs_remote): yield test, rs, rs_remote else: - with test as (test, rs): + with test as (test, rs, rs_remote): yield test, rs From 2bdf5c2da5e441bf36a4148cf66195014d5ef313 Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 13:27:09 +0800 Subject: [PATCH 07/12] Remove unused imports --- exercise_utils/test.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 48e93cc1..e0e928ce 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from datetime import datetime from pathlib import Path -from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Self, Tuple, overload +from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Tuple, overload from unittest import mock import pytz @@ -160,9 +160,7 @@ def __enter__(self) -> Any: # run all commands within the repo os.chdir(repo_path) - # Create remote repo first if needed if self.include_remote_repo: - # Create a bare repository in a temp directory remote_temp_dir = tempfile.mkdtemp() Repo.init(remote_temp_dir, bare=True) self.__rs_remote_context = create_repo_smith( @@ -170,7 +168,6 @@ def __enter__(self) -> Any: ) self.__rs_remote = self.__rs_remote_context.__enter__() - # Create local repo if self.clone_from is not None: self.__rs_context = create_repo_smith( False, From 0202a23760eaf2a9201965f64673e30a02415b4d Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 14:53:53 +0800 Subject: [PATCH 08/12] Change directory creation logic --- exercise_utils/test.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index e0e928ce..91a4dc28 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -160,14 +160,6 @@ def __enter__(self) -> Any: # run all commands within the repo os.chdir(repo_path) - if self.include_remote_repo: - remote_temp_dir = tempfile.mkdtemp() - Repo.init(remote_temp_dir, bare=True) - self.__rs_remote_context = create_repo_smith( - False, existing_path=remote_temp_dir - ) - self.__rs_remote = self.__rs_remote_context.__enter__() - if self.clone_from is not None: self.__rs_context = create_repo_smith( False, @@ -179,7 +171,20 @@ def __enter__(self) -> Any: False, existing_path=repo_path.absolute().as_posix(), ) + self.__rs = self.__rs_context.__enter__() + + if self.include_remote_repo: + remote_temp_dir = tempfile.TemporaryDirectory() + remote_temp_path = Path(remote_temp_dir.name) + remote_repo_path = remote_temp_path / repo_name + os.makedirs(remote_repo_path, exist_ok=True) + self.__rs_remote_context = create_repo_smith( + False, + existing_path=remote_repo_path.absolute().as_posix() + ) + self.__rs_remote = self.__rs_remote_context.__enter__() + self.__rs.add_helper(GitMasteryHelper) return self, self.rs, self.rs_remote @@ -225,7 +230,8 @@ def start( self, clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, - include_remote_repo: Literal[True] = True, + *, + include_remote_repo: Literal[True], ) -> ContextManager[Tuple[GitAutograderTest, RepoSmith, RepoSmith]]: ... @contextmanager From 73de85dd7baeaf754621b98d6be2d00a582dd3b7 Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 18:49:00 +0800 Subject: [PATCH 09/12] Address copilot comments --- exercise_utils/test.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 91a4dc28..1892096e 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -3,7 +3,7 @@ from contextlib import contextmanager from datetime import datetime from pathlib import Path -from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Tuple, overload +from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Self, Tuple, overload from unittest import mock import pytz @@ -104,7 +104,7 @@ def run(self) -> GitAutograderOutput: return output - def __enter__(self) -> Any: + def __enter__(self) -> Tuple[Self, RepoSmith, RepoSmith | None]: # We will mock all accesses to the config to avoid reading the file itself # Only the exercise name and repo_name matters, everything else isn't used repo_name = "repo" @@ -180,7 +180,7 @@ def __enter__(self) -> Any: remote_repo_path = remote_temp_path / repo_name os.makedirs(remote_repo_path, exist_ok=True) self.__rs_remote_context = create_repo_smith( - False, + False, existing_path=remote_repo_path.absolute().as_posix() ) self.__rs_remote = self.__rs_remote_context.__enter__() @@ -189,6 +189,7 @@ def __enter__(self) -> Any: return self, self.rs, self.rs_remote + def __exit__( self, exc_type: type | None, @@ -249,11 +250,12 @@ def start( include_remote_repo, ) if include_remote_repo: - with test as (test, rs, rs_remote): - yield test, rs, rs_remote + with test as (ctx, rs, rs_remote): + yield ctx, rs, rs_remote else: - with test as (test, rs, rs_remote): - yield test, rs + # extract only rs if include_remote_repo is False + with test as (ctx, rs, rs_remote): + yield ctx, rs def assert_output( From f894599deb07d187614a95ce06915e4503b09805 Mon Sep 17 00:00:00 2001 From: jia xin Date: Fri, 23 Jan 2026 19:00:42 +0800 Subject: [PATCH 10/12] Clean up remote temp dir on exit --- exercise_utils/test.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 1892096e..66b409fe 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -57,6 +57,7 @@ def __init__( self.__rs_context: Optional[ContextManager[RepoSmith]] = None self.__rs_remote_context: Optional[ContextManager[RepoSmith]] = None self.__temp_dir: Optional[tempfile.TemporaryDirectory] = None + self.__remote_temp_dir: Optional[tempfile.TemporaryDirectory] = None self.__patches: List[mock._patch] = [] @property @@ -175,8 +176,8 @@ def __enter__(self) -> Tuple[Self, RepoSmith, RepoSmith | None]: self.__rs = self.__rs_context.__enter__() if self.include_remote_repo: - remote_temp_dir = tempfile.TemporaryDirectory() - remote_temp_path = Path(remote_temp_dir.name) + self.__remote_temp_dir = tempfile.TemporaryDirectory() + remote_temp_path = Path(self.__remote_temp_dir.name) remote_repo_path = remote_temp_path / repo_name os.makedirs(remote_repo_path, exist_ok=True) self.__rs_remote_context = create_repo_smith( @@ -207,6 +208,9 @@ def __exit__( if self.__rs_remote_context is not None: self.__rs_remote_context.__exit__(exc_type, exc_val, None) + + if self.__remote_temp_dir is not None: + self.__remote_temp_dir.cleanup() class GitAutograderTestLoader: @@ -241,7 +245,7 @@ def start( clone_from: Optional[str] = None, mock_answers: Optional[Dict[str, str]] = None, include_remote_repo: bool = False, - ) -> Iterator[Tuple]: + ) -> Iterator[Any]: test = GitAutograderTest( self.exercise_name, self.grade_func, From 5b8dd1245f862b0c3bd4e292c39cf95284cccdf4 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 24 Jan 2026 17:17:46 +0800 Subject: [PATCH 11/12] Address Devin Review comments --- exercise_utils/test.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 66b409fe..2ea7bb23 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -3,7 +3,19 @@ from contextlib import contextmanager from datetime import datetime from pathlib import Path -from typing import Any, Callable, ContextManager, Dict, Iterator, List, Literal, Optional, Self, Tuple, overload +from typing import ( + Any, + Callable, + ContextManager, + Dict, + Iterator, + List, + Literal, + Optional, + Self, + Tuple, + overload, +) from unittest import mock import pytz @@ -64,7 +76,7 @@ def __init__( def rs(self) -> RepoSmith: assert self.__rs is not None return self.__rs - + @property def rs_remote(self) -> Optional[RepoSmith]: return self.__rs_remote @@ -104,7 +116,6 @@ def run(self) -> GitAutograderOutput: assert output is not None return output - def __enter__(self) -> Tuple[Self, RepoSmith, RepoSmith | None]: # We will mock all accesses to the config to avoid reading the file itself # Only the exercise name and repo_name matters, everything else isn't used @@ -175,22 +186,21 @@ def __enter__(self) -> Tuple[Self, RepoSmith, RepoSmith | None]: self.__rs = self.__rs_context.__enter__() + self.__rs.add_helper(GitMasteryHelper) + if self.include_remote_repo: self.__remote_temp_dir = tempfile.TemporaryDirectory() remote_temp_path = Path(self.__remote_temp_dir.name) remote_repo_path = remote_temp_path / repo_name os.makedirs(remote_repo_path, exist_ok=True) self.__rs_remote_context = create_repo_smith( - False, - existing_path=remote_repo_path.absolute().as_posix() + False, existing_path=remote_repo_path.absolute().as_posix() ) self.__rs_remote = self.__rs_remote_context.__enter__() - - self.__rs.add_helper(GitMasteryHelper) + self.__rs_remote.add_helper(GitMasteryHelper) return self, self.rs, self.rs_remote - def __exit__( self, exc_type: type | None, @@ -208,7 +218,7 @@ def __exit__( if self.__rs_remote_context is not None: self.__rs_remote_context.__exit__(exc_type, exc_val, None) - + if self.__remote_temp_dir is not None: self.__remote_temp_dir.cleanup() From 7f8674ea2b4363460c9c0bf34e164b57b5bfaeb1 Mon Sep 17 00:00:00 2001 From: jia xin Date: Sat, 24 Jan 2026 17:24:03 +0800 Subject: [PATCH 12/12] Clean up code --- exercise_utils/test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/exercise_utils/test.py b/exercise_utils/test.py index 2ea7bb23..04df315f 100644 --- a/exercise_utils/test.py +++ b/exercise_utils/test.py @@ -183,9 +183,7 @@ def __enter__(self) -> Tuple[Self, RepoSmith, RepoSmith | None]: False, existing_path=repo_path.absolute().as_posix(), ) - self.__rs = self.__rs_context.__enter__() - self.__rs.add_helper(GitMasteryHelper) if self.include_remote_repo: