diff --git a/src/aerie/sync.py b/src/aerie/sync.py index 858bb58..d58a6b7 100644 --- a/src/aerie/sync.py +++ b/src/aerie/sync.py @@ -76,6 +76,26 @@ def ensure_clean_git_tree(path: Path) -> None: ) +def count_local_commits_to_drop(path: Path, target: str) -> int: + commits = run( + ["git", "-C", str(path), "rev-list", "--count", f"{target}..HEAD"], + capture=True, + ) + return int(commits.stdout.strip() or "0") + + +def ensure_no_local_commits_to_drop(path: Path, target: str) -> None: + count = count_local_commits_to_drop(path, target) + if count: + raise AerieError( + f"cannot update {path}; local HEAD has {count} commit(s) that would be " + f"discarded by reset to {target}\n" + f"Inspect with: git -C {path} log --oneline {target}..HEAD\n" + "Publish those commits, disable auto-sync for generated repos, or use " + "'aerie sync --force' to discard them." + ) + + def discard_local_changes(path: Path) -> None: run(["git", "-C", str(path), "reset", "--hard"]) run(["git", "-C", str(path), "clean", "-fd"]) @@ -95,8 +115,11 @@ def update_repo(repo: RepoConfig, release: str, *, force: bool = False) -> None: else: ensure_clean_git_tree(repo.location) run(["git", "-C", str(repo.location), "fetch", "--prune", "origin"]) - run(["git", "-C", str(repo.location), "checkout", "-B", release, f"origin/{release}"]) - run(["git", "-C", str(repo.location), "reset", "--hard", f"origin/{release}"]) + target = f"origin/{release}" + if not force: + ensure_no_local_commits_to_drop(repo.location, target) + run(["git", "-C", str(repo.location), "checkout", "-B", release, target]) + run(["git", "-C", str(repo.location), "reset", "--hard", target]) def sync_repo( diff --git a/tests/test_sync.py b/tests/test_sync.py index ab85c07..d15a670 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -60,6 +60,40 @@ class SyncTests(unittest.TestCase): run.assert_called_once_with(["git", "-C", "/repo", "status", "--porcelain"], capture=True) + def test_update_repo_rejects_local_commits_without_force(self): + repo = repo_config(Path("/repo")) + + with patch("aerie.sync.run") as run: + run.side_effect = [ + subprocess.CompletedProcess(args=[], returncode=0, stdout=""), + subprocess.CompletedProcess(args=[], returncode=0, stdout=""), + subprocess.CompletedProcess(args=[], returncode=0, stdout="2\n"), + ] + with self.assertRaisesRegex(AerieError, "local HEAD has 2 commit"): + update_repo(repo, "develop") + + self.assertEqual( + run.call_args_list, + [ + call( + ["git", "-C", "/repo", "status", "--porcelain"], + capture=True, + ), + call(["git", "-C", "/repo", "fetch", "--prune", "origin"]), + call( + [ + "git", + "-C", + "/repo", + "rev-list", + "--count", + "origin/develop..HEAD", + ], + capture=True, + ), + ], + ) + def test_run_reports_captured_git_status_stderr(self): with patch("aerie.sync.subprocess.run") as subprocess_run: subprocess_run.side_effect = subprocess.CalledProcessError(