diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index 9dd06c2..0881c8d 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -2,172 +2,11 @@ """ git-sync-filtered - Sync filtered commits from private to public repo -Uses git-filter-repo and GitPython. - Usage: git-sync-filtered --private /path/to/private --public /path/to/public --keep src --keep docs """ -import shutil -import tempfile -from itertools import filterfalse -from pathlib import Path - -import click -import git -from git_filter_repo import FilteringOptions, RepoFilter - - -def read_paths_from_file(path: Path) -> list[str]: - lines = (line.strip() for line in path.read_text().splitlines()) - return list(filterfalse(lambda line: line.startswith("#") or not line, lines)) - - -@click.command() -@click.option("--private", required=True, help="Private repo path or URL") -@click.option("--public", required=True, help="Public repo path or URL") -@click.option("--keep", multiple=True, help="Paths to keep (can specify multiple)") -@click.option( - "--keep-from-file", - type=click.Path(exists=True), - help="File containing paths to keep (one per line)", -) -@click.option("--sync-branch", default="upstream/sync", help="Sync branch name") -@click.option("--main-branch", default="main", help="Main branch name") -@click.option("--private-branch", default="main", help="Private branch to sync from") -@click.option( - "--dry-run", is_flag=True, help="Show what would happen without making changes" -) -@click.option("--merge", is_flag=True, help="Merge into main branch after sync") -@click.option("--force", is_flag=True, help="Force push") -def main( - private, - public, - keep, - keep_from_file, - sync_branch, - main_branch, - private_branch, - dry_run, - merge, - force, -): - """Sync filtered commits from private to public repository.""" - - # Collect all paths to keep - paths_to_keep = list(keep) - - # Add paths from file if specified - if keep_from_file: - paths_to_keep.extend(read_paths_from_file(Path(keep_from_file))) - - # Remove duplicates while preserving order - seen = set() - paths_to_keep = [x for x in paths_to_keep if not (x in seen or seen.add(x))] - - if not paths_to_keep: - raise click.ClickException( - "At least one --keep path or --keep-from-file required" - ) - - click.echo("=== Git Filter Sync ===") - click.echo(f"Private: {private}") - click.echo(f"Public: {public}") - click.echo(f"Keep: {paths_to_keep}") - click.echo(f"Sync to: {sync_branch}") - click.echo() - - # Create temp directory - work_dir = Path(tempfile.mkdtemp(prefix="git-sync-")) - click.echo(f"[git-sync] Working in: {work_dir}") - - try: - # Clone private repo using GitPython - private_clone = work_dir / "private" - click.echo(f"[git-sync] Cloning private repo to {private_clone}...") - private_repo = git.Repo.clone_from(private, str(private_clone)) - - # Run filter-repo using the library - click.echo("[git-sync] Running git-filter-repo...") - - # Change to the repo directory for filter-repo - import os - - old_cwd = os.getcwd() - os.chdir(private_clone) - - try: - # Build argv for filtering - argv = ["--force", "--partial"] - for path in paths_to_keep: - argv.extend(["--path", path]) - - # Parse arguments and run filter - filter_args = FilteringOptions.parse_args(argv, error_on_empty=False) - repo_filter = RepoFilter(filter_args) - repo_filter.run() - - finally: - os.chdir(old_cwd) - - # Set up public remote using GitPython - click.echo("[git-sync] Setting up public remote...") - if "public" not in private_repo.remotes: - private_repo.create_remote("public", public) - else: - private_repo.remote("public").set_url(public) - - # Fetch from public - private_repo.remote("public").fetch() - - # Push to sync branch using GitPython - if dry_run: - click.echo(f"[git-sync] DRY RUN - Would push to {sync_branch}") - click.echo() - click.echo("Commits that would be pushed:") - for commit in private_repo.iter_commits(private_branch): - click.echo(f" {commit.hexsha[:8]} {commit.summary}") - else: - click.echo("[git-sync] Pushing to sync branch...") - refspec = f"refs/heads/{private_branch}:refs/heads/{sync_branch}" - private_repo.remote("public").push(refspec=refspec, force=force) - - click.echo() - click.echo(f"=== Synced to {sync_branch} ===") - - if merge and not dry_run: - click.echo(f"[git-sync] Merging into {main_branch}...") - - # Checkout main - private_repo.heads[main_branch].checkout() - - try: - # Merge sync branch into main - sync_head = private_repo.heads[sync_branch] - private_repo.index.merge_commit( - sync_head, msg=f"Merge branch '{sync_branch}'" - ) - - # Push merged result - private_repo.remote("public").push( - refspec=f"refs/heads/{main_branch}:refs/heads/{main_branch}" - ) - click.echo(f"[git-sync] Merged and pushed to {main_branch}") - except git.GitCommandError: - click.echo("[git-sync] Merge conflict! Please resolve manually:") - click.echo(f" cd {private_clone}") - click.echo(f" git checkout {main_branch}") - click.echo(f" git merge {sync_branch}") - click.echo(" # Fix conflicts") - click.echo(f" git push public {main_branch}") - - click.echo() - click.echo("Done!") - - finally: - # Cleanup - shutil.rmtree(work_dir, ignore_errors=True) - +from git_sync_filtered.cli import main if __name__ == "__main__": main() diff --git a/git_sync_filtered/cli.py b/git_sync_filtered/cli.py new file mode 100644 index 0000000..77a4644 --- /dev/null +++ b/git_sync_filtered/cli.py @@ -0,0 +1,77 @@ +import click + +from git_sync_filtered.sync import sync + + +@click.command() +@click.option("--private", required=True, help="Private repo path or URL") +@click.option("--public", required=True, help="Public repo path or URL") +@click.option("--keep", multiple=True, help="Paths to keep (can specify multiple)") +@click.option( + "--keep-from-file", + type=click.Path(exists=True), + help="File containing paths to keep (one per line)", +) +@click.option("--sync-branch", default="upstream/sync", help="Sync branch name") +@click.option("--main-branch", default="main", help="Main branch name") +@click.option("--private-branch", default="main", help="Private branch to sync from") +@click.option( + "--dry-run", is_flag=True, help="Show what would happen without making changes" +) +@click.option("--merge", is_flag=True, help="Merge into main branch after sync") +@click.option("--force", is_flag=True, help="Force push") +def main( + private, + public, + keep, + keep_from_file, + sync_branch, + main_branch, + private_branch, + dry_run, + merge, + force, +): + """Sync filtered commits from private to public repository.""" + + try: + result = sync( + private=private, + public=public, + keep=keep, + keep_from_file=keep_from_file, + sync_branch=sync_branch, + main_branch=main_branch, + private_branch=private_branch, + dry_run=dry_run, + merge=merge, + force=force, + ) + except ValueError as e: + raise click.ClickException(str(e)) + + click.echo("=== Git Filter Sync ===") + click.echo(f"Private: {private}") + click.echo(f"Public: {public}") + click.echo(f"Keep: {result['paths_to_keep']}") + click.echo(f"Sync to: {sync_branch}") + click.echo() + + if dry_run: + click.echo(f"[git-sync] DRY RUN - Would push to {sync_branch}") + click.echo() + click.echo("Commits that would be pushed:") + for commit in result["dry_run_commits"]: + click.echo(commit) + else: + click.echo() + click.echo(f"=== Synced to {sync_branch} ===") + + if merge and not dry_run: + if result["merge_success"]: + click.echo(f"[git-sync] Merged and pushed to {main_branch}") + else: + click.echo("[git-sync] Merge conflict! Please resolve manually:") + + click.echo() + click.echo("Done!") diff --git a/git_sync_filtered/sync.py b/git_sync_filtered/sync.py new file mode 100644 index 0000000..435ef47 --- /dev/null +++ b/git_sync_filtered/sync.py @@ -0,0 +1,128 @@ +import os +from itertools import filterfalse +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional + +import git +from git_filter_repo import FilteringOptions, RepoFilter + + +def read_paths_from_file(path: Path) -> list[str]: + lines = (line.strip() for line in path.read_text().splitlines()) + return list(filterfalse(lambda line: line.startswith("#") or not line, lines)) + + +def collect_paths_to_keep( + keep: tuple[str, ...], keep_from_file: Optional[str] +) -> list[str]: + paths_to_keep = set(keep) + + if keep_from_file: + paths_to_keep.update(read_paths_from_file(Path(keep_from_file))) + + return sorted(list(paths_to_keep)) + + +def run_filter_repo(repo_path: str, paths_to_keep: list[str]) -> None: + old_cwd = os.getcwd() + os.chdir(repo_path) + + try: + argv = ["--force", "--partial"] + for path in paths_to_keep: + argv.extend(["--path", path]) + + filter_args = FilteringOptions.parse_args(argv, error_on_empty=False) + repo_filter = RepoFilter(filter_args) + repo_filter.run() + finally: + os.chdir(old_cwd) + + +def push_to_remote( + repo: git.Repo, + public_url: str, + sync_branch: str, + private_branch: str, + force: bool = False, + dry_run: bool = False, +) -> list[str]: + if "public" not in repo.remotes: + repo.create_remote("public", public_url) + else: + repo.remote("public").set_url(public_url) + + repo.remote("public").fetch() + + if dry_run: + commits = [] + for commit in repo.iter_commits(private_branch): + commits.append(f" {commit.hexsha[:8]} {commit.summary}") + return commits + else: + refspec = f"refs/heads/{private_branch}:refs/heads/{sync_branch}" + repo.remote("public").push(refspec=refspec, force=force) + return [] + + +def merge_into_main( + repo: git.Repo, + main_branch: str, + sync_branch: str, +) -> bool: + repo.heads[main_branch].checkout() + + try: + sync_head = repo.heads[sync_branch] + repo.index.merge_commit(sync_head, msg=f"Merge branch '{sync_branch}'") + + repo.remote("public").push( + refspec=f"refs/heads/{main_branch}:refs/heads/{main_branch}" + ) + return True + except git.GitCommandError: + return False + + +def sync( + private: str, + public: str, + keep: tuple[str, ...], + keep_from_file: Optional[str], + sync_branch: str, + main_branch: str, + private_branch: str, + dry_run: bool, + merge: bool, + force: bool, +): + paths_to_keep = collect_paths_to_keep(keep, keep_from_file) + + if not paths_to_keep: + raise ValueError("At least one --keep path or --keep-from-file required") + + with TemporaryDirectory(prefix="git-sync-") as work_dir: + work_dir_path = Path(work_dir) + private_clone = work_dir_path / "private" + private_repo = git.Repo.clone_from(private, str(private_clone)) + + run_filter_repo(str(private_clone), paths_to_keep) + + dry_run_commits = push_to_remote( + private_repo, public, sync_branch, private_branch, force, dry_run + ) + + if merge and not dry_run: + success = merge_into_main(private_repo, main_branch, sync_branch) + return { + "paths_to_keep": paths_to_keep, + "dry_run_commits": dry_run_commits, + "merge_success": success, + } + + return { + "paths_to_keep": paths_to_keep, + "dry_run_commits": dry_run_commits, + "merge_success": None, + } diff --git a/tests/integration/test_filter_repo.py b/tests/integration/test_filter_repo.py new file mode 100644 index 0000000..1706e72 --- /dev/null +++ b/tests/integration/test_filter_repo.py @@ -0,0 +1,34 @@ +import subprocess + +from git_sync_filtered.sync import run_filter_repo + + +def test_run_filter_repo_filters_correctly(tmp_path): + repo_path = tmp_path / "repo" + repo_path.mkdir() + + (repo_path / "src").mkdir() + (repo_path / "src" / "main.py").write_text("print('hello')") + (repo_path / "docs").mkdir() + (repo_path / "docs" / "README.md").write_text("# Docs") + (repo_path / "secrets").mkdir() + (repo_path / "secrets" / "passwords.txt").write_text("passwords!") + + subprocess.run(["git", "init"], cwd=repo_path, check=True) + subprocess.run(["git", "add", "."], cwd=repo_path, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=repo_path, + check=True, + env={ + **__import__("os").environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_COMMITTER_NAME": "Test", + }, + ) + + run_filter_repo(repo_path, ["src", "docs"]) + + assert (repo_path / "src" / "main.py").exists() + assert (repo_path / "docs" / "README.md").exists() + assert not (repo_path / "secrets").exists() diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py new file mode 100644 index 0000000..cf49261 --- /dev/null +++ b/tests/integration/test_main.py @@ -0,0 +1,115 @@ +import os +import subprocess + + +def run_git(cwd, *args, env=None): + subprocess.run(["git", *args], cwd=cwd, check=True, env=env) + + +def test_full_sync_flow(tmp_path): + """Integration test covering the full main() flow.""" + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + } + + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + (private_repo / "docs").mkdir() + (private_repo / "docs" / "README.md").write_text("# Docs") + (private_repo / "secrets").mkdir() + (private_repo / "secrets" / "password.txt").write_text("password!") + + run_git(private_repo, "init", env=env) + run_git(private_repo, "checkout", "-b", "main", env=env) + run_git(private_repo, "add", ".", env=env) + run_git(private_repo, "commit", "-m", "initial", env=env) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + run_git(public_repo, "init", "--bare", env=env) + + from click.testing import CliRunner + + from git_sync_filtered.cli import main + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--private", + str(private_repo), + "--public", + str(public_repo), + "--keep", + "src", + "--keep", + "docs", + ], + ) + + assert result.exit_code == 0, result.output + + cloned = tmp_path / "check_public" + subprocess.run( + ["git", "clone", str(public_repo), str(cloned)], + check=True, + env=env, + ) + run_git(cloned, "checkout", "upstream/sync", env=env) + + assert (cloned / "src" / "main.py").exists() + assert (cloned / "docs" / "README.md").exists() + assert not (cloned / "secrets").exists() + + +def test_dry_run(tmp_path): + """Integration test for dry-run mode.""" + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + } + + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + + run_git(private_repo, "init", env=env) + run_git(private_repo, "checkout", "-b", "main", env=env) + run_git(private_repo, "add", ".", env=env) + run_git(private_repo, "commit", "-m", "initial", env=env) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + run_git(public_repo, "init", "--bare", env=env) + + from click.testing import CliRunner + + from git_sync_filtered.cli import main + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--private", + str(private_repo), + "--public", + str(public_repo), + "--keep", + "src", + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + assert "DRY RUN" in result.output + assert "Commits that would be pushed" in result.output diff --git a/tests/integration/test_sync.py b/tests/integration/test_sync.py new file mode 100644 index 0000000..73dff11 --- /dev/null +++ b/tests/integration/test_sync.py @@ -0,0 +1,151 @@ +import os +import subprocess + + +def run_git(cwd, *args, env=None): + subprocess.run(["git", *args], cwd=cwd, check=True, env=env) + + +def test_sync_full_flow(tmp_path): + """Integration test for sync function.""" + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + } + + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + (private_repo / "docs").mkdir() + (private_repo / "docs" / "README.md").write_text("# Docs") + (private_repo / "secrets").mkdir() + (private_repo / "secrets" / "password.txt").write_text("password!") + + run_git(private_repo, "init", env=env) + run_git(private_repo, "checkout", "-b", "main", env=env) + run_git(private_repo, "add", ".", env=env) + run_git(private_repo, "commit", "-m", "initial", env=env) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + run_git(public_repo, "init", "--bare", env=env) + + from git_sync_filtered.sync import sync + + result = sync( + private=str(private_repo), + public=str(public_repo), + keep=("src", "docs"), + keep_from_file=None, + sync_branch="upstream/sync", + main_branch="main", + private_branch="main", + dry_run=False, + merge=False, + force=False, + ) + + assert result["paths_to_keep"] == ["docs", "src"] + assert result["dry_run_commits"] == [] + assert result["merge_success"] is None + + cloned = tmp_path / "check_public" + subprocess.run( + ["git", "clone", str(public_repo), str(cloned)], + check=True, + env=env, + ) + run_git(cloned, "checkout", "upstream/sync", env=env) + + assert (cloned / "src" / "main.py").exists() + assert (cloned / "docs" / "README.md").exists() + assert not (cloned / "secrets").exists() + + +def test_sync_dry_run(tmp_path): + """Integration test for sync function with dry_run=True.""" + env = { + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + } + + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + + run_git(private_repo, "init", env=env) + run_git(private_repo, "checkout", "-b", "main", env=env) + run_git(private_repo, "add", ".", env=env) + run_git(private_repo, "commit", "-m", "initial", env=env) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + run_git(public_repo, "init", "--bare", env=env) + + from git_sync_filtered.sync import sync + + result = sync( + private=str(private_repo), + public=str(public_repo), + keep=("src",), + keep_from_file=None, + sync_branch="upstream/sync", + main_branch="main", + private_branch="main", + dry_run=True, + merge=False, + force=False, + ) + + assert result["paths_to_keep"] == ["src"] + assert len(result["dry_run_commits"]) == 1 + assert "initial" in result["dry_run_commits"][0] + + cloned = tmp_path / "check_public" + subprocess.run( + ["git", "clone", str(public_repo), str(cloned)], + check=True, + env=env, + capture_output=True, + ) + + assert not (cloned / "src").exists() + + +def test_sync_requires_keep_paths(tmp_path): + """Integration test that sync raises error when no paths provided.""" + private_repo = tmp_path / "private_source" + private_repo.mkdir() + + subprocess.run(["git", "init"], cwd=private_repo, check=True) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + + from git_sync_filtered.sync import sync + + try: + sync( + private=str(private_repo), + public=str(public_repo), + keep=(), + keep_from_file=None, + sync_branch="upstream/sync", + main_branch="main", + private_branch="main", + dry_run=False, + merge=False, + force=False, + ) + assert False, "Expected ValueError" + except ValueError as e: + assert "At least one --keep path" in str(e) diff --git a/tests/test_basic.py b/tests/test_basic.py deleted file mode 100644 index 5eb9313..0000000 --- a/tests/test_basic.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_hello(): - assert "hello" == "hello" diff --git a/tests/unit/test_collect_paths.py b/tests/unit/test_collect_paths.py new file mode 100644 index 0000000..7919f04 --- /dev/null +++ b/tests/unit/test_collect_paths.py @@ -0,0 +1,33 @@ +from git_sync_filtered.sync import collect_paths_to_keep + + +def test_collect_paths_to_keep_from_args(): + result = collect_paths_to_keep(keep=("src", "docs"), keep_from_file=None) + assert result == ["docs", "src"] + + +def test_collect_paths_to_keep_combines_args_and_file(tmp_path): + file_path = tmp_path / "paths.txt" + file_path.write_text("tests\nlib\n") + + result = collect_paths_to_keep( + keep=("src",), + keep_from_file=str(file_path), + ) + + assert result == ["lib", "src", "tests"] + + +def test_collect_paths_to_keep_removes_duplicates(): + result = collect_paths_to_keep(keep=("src", "docs", "src"), keep_from_file=None) + + assert result == ["docs", "src"] + + +def test_collect_paths_to_keep_keeps_first_occurrence(): + result = collect_paths_to_keep( + keep=("src", "docs", "src", "tests"), + keep_from_file=None, + ) + + assert result == ["docs", "src", "tests"] diff --git a/tests/unit/test_merge_into_main.py b/tests/unit/test_merge_into_main.py new file mode 100644 index 0000000..01fc23d --- /dev/null +++ b/tests/unit/test_merge_into_main.py @@ -0,0 +1,53 @@ +from unittest.mock import MagicMock + +import pytest + +from git_sync_filtered.sync import merge_into_main + + +@pytest.fixture +def mock_repo(): + repo = MagicMock() + mock_head = MagicMock() + repo.heads = {"main": mock_head, "upstream/sync": MagicMock()} + repo.remote.return_value = MagicMock() + return repo + + +def test_merge_into_main_checkouts_main_branch(mock_repo): + merge_into_main(repo=mock_repo, main_branch="main", sync_branch="upstream/sync") + + mock_repo.heads["main"].checkout.assert_called_once() + + +def test_merge_into_main_performs_merge(mock_repo): + merge_into_main(repo=mock_repo, main_branch="main", sync_branch="upstream/sync") + + mock_repo.index.merge_commit.assert_called_once() + + +def test_merge_into_main_pushes_on_success(mock_repo): + merge_into_main(repo=mock_repo, main_branch="main", sync_branch="upstream/sync") + + mock_repo.remote("public").push.assert_called_once() + + +def test_merge_into_main_returns_true_on_success(mock_repo): + result = merge_into_main( + repo=mock_repo, main_branch="main", sync_branch="upstream/sync" + ) + + assert result is True + + +def test_merge_into_main_returns_false_on_conflict(mock_repo): + import git + + mock_repo.index.merge_commit.side_effect = git.GitCommandError("merge", 1) + + result = merge_into_main( + repo=mock_repo, main_branch="main", sync_branch="upstream/sync" + ) + + assert result is False + mock_repo.remote("public").push.assert_not_called() diff --git a/tests/unit/test_push_to_remote.py b/tests/unit/test_push_to_remote.py new file mode 100644 index 0000000..bb3c1f6 --- /dev/null +++ b/tests/unit/test_push_to_remote.py @@ -0,0 +1,91 @@ +from unittest.mock import MagicMock + +import pytest + +from git_sync_filtered.sync import push_to_remote + + +@pytest.fixture +def mock_repo(): + repo = MagicMock() + repo.remotes = [] + mock_remote = MagicMock() + repo.create_remote.return_value = mock_remote + repo.remote.return_value = mock_remote + return repo + + +def test_push_to_remote_creates_remote_when_not_exists(mock_repo): + mock_repo.remotes = [] + + push_to_remote( + repo=mock_repo, + public_url="https://github.com/user/public.git", + sync_branch="upstream/sync", + private_branch="main", + ) + + mock_repo.create_remote.assert_called_once_with( + "public", "https://github.com/user/public.git" + ) + mock_repo.remote("public").fetch.assert_called_once() + mock_repo.remote("public").push.assert_called_once() + + +def test_push_to_remote_updates_url_when_exists(mock_repo): + mock_repo.remotes = ["public"] + + push_to_remote( + repo=mock_repo, + public_url="https://github.com/user/public.git", + sync_branch="upstream/sync", + private_branch="main", + ) + + mock_repo.remote("public").set_url.assert_called_once_with( + "https://github.com/user/public.git" + ) + mock_repo.create_remote.assert_not_called() + + +def test_push_to_remote_dry_run_returns_commits(mock_repo): + mock_commit = MagicMock() + mock_commit.hexsha = "abc123def" + mock_commit.summary = "Initial commit" + mock_repo.iter_commits.return_value = [mock_commit] + + result = push_to_remote( + repo=mock_repo, + public_url="https://github.com/user/public.git", + sync_branch="upstream/sync", + private_branch="main", + dry_run=True, + ) + + assert result == [" abc123de Initial commit"] + mock_repo.remote("public").push.assert_not_called() + + +def test_push_to_remote_uses_force(mock_repo): + push_to_remote( + repo=mock_repo, + public_url="https://github.com/user/public.git", + sync_branch="upstream/sync", + private_branch="main", + force=True, + ) + + call_kwargs = mock_repo.remote("public").push.call_args[1] + assert call_kwargs["force"] is True + + +def test_push_to_remote_builds_correct_refspec(mock_repo): + push_to_remote( + repo=mock_repo, + public_url="https://github.com/user/public.git", + sync_branch="upstream/sync", + private_branch="feature branch", + ) + + call_args = mock_repo.remote("public").push.call_args[1] + assert call_args["refspec"] == "refs/heads/feature branch:refs/heads/upstream/sync" diff --git a/tests/unit/test_run_filter_repo.py b/tests/unit/test_run_filter_repo.py new file mode 100644 index 0000000..8069da7 --- /dev/null +++ b/tests/unit/test_run_filter_repo.py @@ -0,0 +1,48 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from git_sync_filtered.sync import run_filter_repo + + +@pytest.fixture +def mock_filter_repo(): + with ( + patch("git_sync_filtered.sync.FilteringOptions") as mock_options, + patch("git_sync_filtered.sync.RepoFilter") as mock_filter, + ): + mock_filter_instance = MagicMock() + mock_filter.return_value = mock_filter_instance + yield mock_options, mock_filter_instance + + +def test_run_filter_repo_restores_cwd(tmp_path, mock_filter_repo): + import os + + original_cwd = os.getcwd() + repo_path = tmp_path / "repo" + repo_path.mkdir() + + run_filter_repo(repo_path, ["src"]) + + assert os.getcwd() == original_cwd + + +def test_run_filter_repo_builds_correct_argv(tmp_path, mock_filter_repo): + mock_options, mock_filter_instance = mock_filter_repo + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + run_filter_repo(repo_path, ["src", "docs"]) + + mock_options.parse_args.assert_called_once() + call_args = mock_options.parse_args.call_args + argv = call_args[0][0] + + assert "--force" in argv + assert "--partial" in argv + assert "--path" in argv + assert "src" in argv + assert "docs" in argv + mock_filter_instance.run.assert_called_once()