From 474fb33d81e79add1e38ec0cfca36c53d6023997 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 01:30:49 +0000 Subject: [PATCH 01/13] refactor: collect_paths_to_keep extracted and tested --- git_sync_filtered/__main__.py | 23 +++++++++++++---------- tests/test_basic.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index 9dd06c2..0635c53 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -12,6 +12,7 @@ import tempfile from itertools import filterfalse from pathlib import Path +from typing import Optional import click import git @@ -23,6 +24,17 @@ def read_paths_from_file(path: Path) -> list[str]: 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)) + + @click.command() @click.option("--private", required=True, help="Private repo path or URL") @click.option("--public", required=True, help="Public repo path or URL") @@ -54,16 +66,7 @@ def main( ): """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))] + paths_to_keep = collect_paths_to_keep(keep, keep_from_file) if not paths_to_keep: raise click.ClickException( diff --git a/tests/test_basic.py b/tests/test_basic.py index 5eb9313..7a85f76 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,2 +1,33 @@ -def test_hello(): - assert "hello" == "hello" +from git_sync_filtered.__main__ 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"] From 86de1b029de3f987c9aff8cc233ba3167c8e2039 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 01:49:44 +0000 Subject: [PATCH 02/13] refactor: extract run_filter_repo and test --- git_sync_filtered/__main__.py | 39 ++++++++++++++-------------- tests/test_basic.py | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index 0635c53..ec50f73 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -35,6 +35,24 @@ def collect_paths_to_keep( return sorted(list(paths_to_keep)) +def run_filter_repo(repo_path: Path, paths_to_keep: list[str]) -> None: + import os + + 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) + + @click.command() @click.option("--private", required=True, help="Private repo path or URL") @click.option("--public", required=True, help="Public repo path or URL") @@ -92,26 +110,7 @@ def main( # 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) + run_filter_repo(private_clone, paths_to_keep) # Set up public remote using GitPython click.echo("[git-sync] Setting up public remote...") diff --git a/tests/test_basic.py b/tests/test_basic.py index 7a85f76..7aee42a 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -1,4 +1,19 @@ -from git_sync_filtered.__main__ import collect_paths_to_keep +from unittest.mock import MagicMock, patch + +import pytest + +from git_sync_filtered.__main__ import collect_paths_to_keep, run_filter_repo + + +@pytest.fixture +def mock_filter_repo(): + with ( + patch("git_sync_filtered.__main__.FilteringOptions") as mock_options, + patch("git_sync_filtered.__main__.RepoFilter") as mock_filter, + ): + mock_filter_instance = MagicMock() + mock_filter.return_value = mock_filter_instance + yield mock_options, mock_filter_instance def test_collect_paths_to_keep_from_args(): @@ -31,3 +46,35 @@ def test_collect_paths_to_keep_keeps_first_occurrence(): ) assert result == ["docs", "src", "tests"] + + +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() From 6a29ab0b709aeb8bf98287a9954b01490b103753 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 01:53:00 +0000 Subject: [PATCH 03/13] feature :add integration tests --- tests/integration/test_filter_repo.py | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/integration/test_filter_repo.py diff --git a/tests/integration/test_filter_repo.py b/tests/integration/test_filter_repo.py new file mode 100644 index 0000000..424b0f6 --- /dev/null +++ b/tests/integration/test_filter_repo.py @@ -0,0 +1,34 @@ +import subprocess + +from git_sync_filtered.__main__ 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() From 83f92a10552970ca60bb661a457161c459edc097 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 01:55:05 +0000 Subject: [PATCH 04/13] chore: regroup tests --- tests/unit/test_collect_paths.py | 33 ++++++++++++++++++ .../test_run_filter_repo.py} | 34 +------------------ 2 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 tests/unit/test_collect_paths.py rename tests/{test_basic.py => unit/test_run_filter_repo.py} (56%) diff --git a/tests/unit/test_collect_paths.py b/tests/unit/test_collect_paths.py new file mode 100644 index 0000000..7a85f76 --- /dev/null +++ b/tests/unit/test_collect_paths.py @@ -0,0 +1,33 @@ +from git_sync_filtered.__main__ 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/test_basic.py b/tests/unit/test_run_filter_repo.py similarity index 56% rename from tests/test_basic.py rename to tests/unit/test_run_filter_repo.py index 7aee42a..6c3faa3 100644 --- a/tests/test_basic.py +++ b/tests/unit/test_run_filter_repo.py @@ -2,7 +2,7 @@ import pytest -from git_sync_filtered.__main__ import collect_paths_to_keep, run_filter_repo +from git_sync_filtered.__main__ import run_filter_repo @pytest.fixture @@ -16,38 +16,6 @@ def mock_filter_repo(): yield mock_options, mock_filter_instance -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"] - - def test_run_filter_repo_restores_cwd(tmp_path, mock_filter_repo): import os From 3585dc1688c3805befd82018f660876ee64d64f1 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 02:19:46 +0000 Subject: [PATCH 05/13] refactor: extract push_to_remote and test --- git_sync_filtered/__main__.py | 56 +++++++++++++------ tests/unit/test_push_to_remote.py | 91 +++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 tests/unit/test_push_to_remote.py diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index ec50f73..ad7bc7e 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -53,6 +53,35 @@ def run_filter_repo(repo_path: Path, paths_to_keep: list[str]) -> None: 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]: + # Set up public remote + if "public" not in repo.remotes: + repo.create_remote("public", public_url) + else: + repo.remote("public").set_url(public_url) + + # Fetch from public + repo.remote("public").fetch() + + # Push to sync branch + 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 [] + + @click.command() @click.option("--private", required=True, help="Private repo path or URL") @click.option("--public", required=True, help="Public repo path or URL") @@ -112,30 +141,21 @@ def main( click.echo("[git-sync] Running git-filter-repo...") run_filter_repo(private_clone, paths_to_keep) - # 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 remote + click.echo("[git-sync] Pushing to sync branch...") + dry_run_commits = push_to_remote( + private_repo, public, sync_branch, private_branch, force, dry_run + ) - # 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}") + for commit in dry_run_commits: + click.echo(commit) 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} ===") + click.echo() + click.echo(f"=== Synced to {sync_branch} ===") if merge and not dry_run: click.echo(f"[git-sync] Merging into {main_branch}...") diff --git a/tests/unit/test_push_to_remote.py b/tests/unit/test_push_to_remote.py new file mode 100644 index 0000000..945b1ae --- /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.__main__ 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" From 9579af84c766bf15874452c45182048e302b1d99 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 02:32:54 +0000 Subject: [PATCH 06/13] refactor: extract merge_into_main and test --- git_sync_filtered/__main__.py | 36 ++++++++++++-------- tests/unit/test_merge_into_main.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_merge_into_main.py diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index ad7bc7e..bc2cc23 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -82,6 +82,25 @@ def push_to_remote( 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 + + @click.command() @click.option("--private", required=True, help="Private repo path or URL") @click.option("--public", required=True, help="Public repo path or URL") @@ -160,22 +179,11 @@ def main( 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}'" - ) + success = merge_into_main(private_repo, main_branch, sync_branch) - # Push merged result - private_repo.remote("public").push( - refspec=f"refs/heads/{main_branch}:refs/heads/{main_branch}" - ) + if success: click.echo(f"[git-sync] Merged and pushed to {main_branch}") - except git.GitCommandError: + else: click.echo("[git-sync] Merge conflict! Please resolve manually:") click.echo(f" cd {private_clone}") click.echo(f" git checkout {main_branch}") diff --git a/tests/unit/test_merge_into_main.py b/tests/unit/test_merge_into_main.py new file mode 100644 index 0000000..f47d341 --- /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.__main__ 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() From 89dbec9aeeec65f996e7fab606cc030a0cef5642 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 02:51:17 +0000 Subject: [PATCH 07/13] refactor: simplify temp dir --- git_sync_filtered/__main__.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index bc2cc23..02d3843 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -8,10 +8,9 @@ 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 +from tempfile import TemporaryDirectory from typing import Optional import click @@ -35,7 +34,7 @@ def collect_paths_to_keep( return sorted(list(paths_to_keep)) -def run_filter_repo(repo_path: Path, paths_to_keep: list[str]) -> None: +def run_filter_repo(repo_path: str, paths_to_keep: list[str]) -> None: import os old_cwd = os.getcwd() @@ -147,10 +146,10 @@ def main( click.echo() # Create temp directory - work_dir = Path(tempfile.mkdtemp(prefix="git-sync-")) - click.echo(f"[git-sync] Working in: {work_dir}") + with TemporaryDirectory(prefix="git-sync-") as work_dir: + work_dir = Path(work_dir) + 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}...") @@ -194,10 +193,6 @@ def main( click.echo() click.echo("Done!") - finally: - # Cleanup - shutil.rmtree(work_dir, ignore_errors=True) - if __name__ == "__main__": main() From 0ca084adf70d83ab308f6715f3ea65467d8ec1ec Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:03:21 +0000 Subject: [PATCH 08/13] feature: add full integration tests --- tests/integration/test_main.py | 111 +++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/integration/test_main.py diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py new file mode 100644 index 0000000..8e22942 --- /dev/null +++ b/tests/integration/test_main.py @@ -0,0 +1,111 @@ +import os +import subprocess + + +def test_full_sync_flow(tmp_path): + """Integration test covering the full main() flow.""" + 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!") + + subprocess.run(["git", "init"], cwd=private_repo, check=True) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=private_repo, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + }, + ) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + + from click.testing import CliRunner + + from git_sync_filtered.__main__ 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) + subprocess.run(["git", "checkout", "upstream/sync"], cwd=cloned, check=True) + + assert (cloned / "src" / "main.py").exists() + assert (cloned / "docs" / "README.md").exists() + assert not (cloned / "secrets").exists() + + +def test_dry_run(tmp_path, capsys): + """Integration test for dry-run mode.""" + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + + subprocess.run(["git", "init"], cwd=private_repo, check=True) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=private_repo, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + }, + ) + + public_repo = tmp_path / "public_repo" + public_repo.mkdir() + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + + from click.testing import CliRunner + + from git_sync_filtered.__main__ 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 From 5fdecf08bd25f0ae7e33a178bbe86f4ed0b2c5b5 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:11:08 +0000 Subject: [PATCH 09/13] refactor: move code into sub modules --- git_sync_filtered/__main__.py | 188 +------------------------- git_sync_filtered/cli.py | 77 +++++++++++ git_sync_filtered/sync.py | 128 ++++++++++++++++++ tests/integration/test_filter_repo.py | 2 +- tests/integration/test_main.py | 4 +- tests/unit/test_collect_paths.py | 2 +- tests/unit/test_merge_into_main.py | 2 +- tests/unit/test_push_to_remote.py | 2 +- tests/unit/test_run_filter_repo.py | 6 +- 9 files changed, 215 insertions(+), 196 deletions(-) create mode 100644 git_sync_filtered/cli.py create mode 100644 git_sync_filtered/sync.py diff --git a/git_sync_filtered/__main__.py b/git_sync_filtered/__main__.py index 02d3843..0881c8d 100644 --- a/git_sync_filtered/__main__.py +++ b/git_sync_filtered/__main__.py @@ -2,197 +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 """ -from itertools import filterfalse -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Optional - -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)) - - -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: - import os - - 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]: - # Set up public remote - if "public" not in repo.remotes: - repo.create_remote("public", public_url) - else: - repo.remote("public").set_url(public_url) - - # Fetch from public - repo.remote("public").fetch() - - # Push to sync branch - 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 - - -@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.""" - - paths_to_keep = collect_paths_to_keep(keep, keep_from_file) - - 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 - with TemporaryDirectory(prefix="git-sync-") as work_dir: - work_dir = Path(work_dir) - click.echo(f"[git-sync] Working in: {work_dir}") - - # 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...") - run_filter_repo(private_clone, paths_to_keep) - - # Push to remote - click.echo("[git-sync] Pushing to sync branch...") - dry_run_commits = push_to_remote( - private_repo, public, sync_branch, private_branch, force, dry_run - ) - - 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 dry_run_commits: - click.echo(commit) - else: - click.echo() - click.echo(f"=== Synced to {sync_branch} ===") - - if merge and not dry_run: - click.echo(f"[git-sync] Merging into {main_branch}...") - - success = merge_into_main(private_repo, main_branch, sync_branch) - - if success: - click.echo(f"[git-sync] Merged and pushed to {main_branch}") - else: - 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!") - +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 index 424b0f6..1706e72 100644 --- a/tests/integration/test_filter_repo.py +++ b/tests/integration/test_filter_repo.py @@ -1,6 +1,6 @@ import subprocess -from git_sync_filtered.__main__ import run_filter_repo +from git_sync_filtered.sync import run_filter_repo def test_run_filter_repo_filters_correctly(tmp_path): diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 8e22942..e126ef7 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -34,7 +34,7 @@ def test_full_sync_flow(tmp_path): from click.testing import CliRunner - from git_sync_filtered.__main__ import main + from git_sync_filtered.cli import main runner = CliRunner() result = runner.invoke( @@ -90,7 +90,7 @@ def test_dry_run(tmp_path, capsys): from click.testing import CliRunner - from git_sync_filtered.__main__ import main + from git_sync_filtered.cli import main runner = CliRunner() result = runner.invoke( diff --git a/tests/unit/test_collect_paths.py b/tests/unit/test_collect_paths.py index 7a85f76..7919f04 100644 --- a/tests/unit/test_collect_paths.py +++ b/tests/unit/test_collect_paths.py @@ -1,4 +1,4 @@ -from git_sync_filtered.__main__ import collect_paths_to_keep +from git_sync_filtered.sync import collect_paths_to_keep def test_collect_paths_to_keep_from_args(): diff --git a/tests/unit/test_merge_into_main.py b/tests/unit/test_merge_into_main.py index f47d341..01fc23d 100644 --- a/tests/unit/test_merge_into_main.py +++ b/tests/unit/test_merge_into_main.py @@ -2,7 +2,7 @@ import pytest -from git_sync_filtered.__main__ import merge_into_main +from git_sync_filtered.sync import merge_into_main @pytest.fixture diff --git a/tests/unit/test_push_to_remote.py b/tests/unit/test_push_to_remote.py index 945b1ae..bb3c1f6 100644 --- a/tests/unit/test_push_to_remote.py +++ b/tests/unit/test_push_to_remote.py @@ -2,7 +2,7 @@ import pytest -from git_sync_filtered.__main__ import push_to_remote +from git_sync_filtered.sync import push_to_remote @pytest.fixture diff --git a/tests/unit/test_run_filter_repo.py b/tests/unit/test_run_filter_repo.py index 6c3faa3..8069da7 100644 --- a/tests/unit/test_run_filter_repo.py +++ b/tests/unit/test_run_filter_repo.py @@ -2,14 +2,14 @@ import pytest -from git_sync_filtered.__main__ import run_filter_repo +from git_sync_filtered.sync import run_filter_repo @pytest.fixture def mock_filter_repo(): with ( - patch("git_sync_filtered.__main__.FilteringOptions") as mock_options, - patch("git_sync_filtered.__main__.RepoFilter") as mock_filter, + 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 From b2aabab60febeab53fcfebf0733759597296e479 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:16:23 +0000 Subject: [PATCH 10/13] feature: ad slightly more integration coverage --- tests/integration/test_sync.py | 144 +++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 tests/integration/test_sync.py diff --git a/tests/integration/test_sync.py b/tests/integration/test_sync.py new file mode 100644 index 0000000..ed9ae00 --- /dev/null +++ b/tests/integration/test_sync.py @@ -0,0 +1,144 @@ +import os +import subprocess + + +def test_sync_full_flow(tmp_path): + """Integration test for sync function.""" + 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!") + + subprocess.run(["git", "init"], cwd=private_repo, check=True) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=private_repo, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + }, + ) + + 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 + + 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) + subprocess.run(["git", "checkout", "upstream/sync"], cwd=cloned, check=True) + + 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.""" + private_repo = tmp_path / "private_source" + private_repo.mkdir() + (private_repo / "src").mkdir() + (private_repo / "src" / "main.py").write_text("print('hello')") + + subprocess.run(["git", "init"], cwd=private_repo, check=True) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run( + ["git", "commit", "-m", "initial"], + cwd=private_repo, + check=True, + env={ + **os.environ, + "GIT_AUTHOR_NAME": "Test", + "GIT_AUTHOR_EMAIL": "test@test.com", + "GIT_COMMITTER_NAME": "Test", + "GIT_COMMITTER_EMAIL": "test@test.com", + }, + ) + + 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 + + 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, 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) From 4954ca06e00b75b4a2ac49bdf509283dd1885740 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:24:39 +0000 Subject: [PATCH 11/13] fix: add fetch-depth for git operations in CI --- .github/workflows/python_ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python_ci.yaml b/.github/workflows/python_ci.yaml index 2fedb3c..54dd4ca 100644 --- a/.github/workflows/python_ci.yaml +++ b/.github/workflows/python_ci.yaml @@ -20,6 +20,8 @@ jobs: steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Install uv and set the Python version uses: astral-sh/setup-uv@v7 From 40daac8b236998eff6ef90406cf1415bdd7253c7 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:28:34 +0000 Subject: [PATCH 12/13] fix: use GIT_DEFAULT_BRANCH in integration tests --- tests/integration/test_main.py | 52 ++++++++++++++++++------------- tests/integration/test_sync.py | 57 ++++++++++++++++++++-------------- 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index e126ef7..6022fcd 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -4,6 +4,15 @@ 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", + "GIT_DEFAULT_BRANCH": "main", + } + private_repo = tmp_path / "private_source" private_repo.mkdir() (private_repo / "src").mkdir() @@ -13,24 +22,18 @@ def test_full_sync_flow(tmp_path): (private_repo / "secrets").mkdir() (private_repo / "secrets" / "password.txt").write_text("password!") - subprocess.run(["git", "init"], cwd=private_repo, check=True) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) subprocess.run( ["git", "commit", "-m", "initial"], cwd=private_repo, check=True, - env={ - **os.environ, - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@test.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@test.com", - }, + env=env, ) public_repo = tmp_path / "public_repo" public_repo.mkdir() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) from click.testing import CliRunner @@ -54,8 +57,10 @@ def test_full_sync_flow(tmp_path): assert result.exit_code == 0, result.output cloned = tmp_path / "check_public" - subprocess.run(["git", "clone", str(public_repo), str(cloned)], check=True) - subprocess.run(["git", "checkout", "upstream/sync"], cwd=cloned, check=True) + subprocess.run(["git", "clone", str(public_repo), str(cloned)], check=True, env=env) + subprocess.run( + ["git", "checkout", "upstream/sync"], cwd=cloned, check=True, env=env + ) assert (cloned / "src" / "main.py").exists() assert (cloned / "docs" / "README.md").exists() @@ -64,29 +69,32 @@ def test_full_sync_flow(tmp_path): def test_dry_run(tmp_path, capsys): """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", + "GIT_DEFAULT_BRANCH": "main", + } + private_repo = tmp_path / "private_source" private_repo.mkdir() (private_repo / "src").mkdir() (private_repo / "src" / "main.py").write_text("print('hello')") - subprocess.run(["git", "init"], cwd=private_repo, check=True) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) subprocess.run( ["git", "commit", "-m", "initial"], cwd=private_repo, check=True, - env={ - **os.environ, - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@test.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@test.com", - }, + env=env, ) public_repo = tmp_path / "public_repo" public_repo.mkdir() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) from click.testing import CliRunner diff --git a/tests/integration/test_sync.py b/tests/integration/test_sync.py index ed9ae00..74d0dc4 100644 --- a/tests/integration/test_sync.py +++ b/tests/integration/test_sync.py @@ -4,6 +4,15 @@ 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", + "GIT_DEFAULT_BRANCH": "main", + } + private_repo = tmp_path / "private_source" private_repo.mkdir() (private_repo / "src").mkdir() @@ -13,24 +22,18 @@ def test_sync_full_flow(tmp_path): (private_repo / "secrets").mkdir() (private_repo / "secrets" / "password.txt").write_text("password!") - subprocess.run(["git", "init"], cwd=private_repo, check=True) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) subprocess.run( ["git", "commit", "-m", "initial"], cwd=private_repo, check=True, - env={ - **os.environ, - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@test.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@test.com", - }, + env=env, ) public_repo = tmp_path / "public_repo" public_repo.mkdir() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) from git_sync_filtered.sync import sync @@ -52,8 +55,10 @@ def test_sync_full_flow(tmp_path): assert result["merge_success"] is None cloned = tmp_path / "check_public" - subprocess.run(["git", "clone", str(public_repo), str(cloned)], check=True) - subprocess.run(["git", "checkout", "upstream/sync"], cwd=cloned, check=True) + subprocess.run(["git", "clone", str(public_repo), str(cloned)], check=True, env=env) + subprocess.run( + ["git", "checkout", "upstream/sync"], cwd=cloned, check=True, env=env + ) assert (cloned / "src" / "main.py").exists() assert (cloned / "docs" / "README.md").exists() @@ -62,29 +67,32 @@ def test_sync_full_flow(tmp_path): 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", + "GIT_DEFAULT_BRANCH": "main", + } + private_repo = tmp_path / "private_source" private_repo.mkdir() (private_repo / "src").mkdir() (private_repo / "src" / "main.py").write_text("print('hello')") - subprocess.run(["git", "init"], cwd=private_repo, check=True) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True) + subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) + subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) subprocess.run( ["git", "commit", "-m", "initial"], cwd=private_repo, check=True, - env={ - **os.environ, - "GIT_AUTHOR_NAME": "Test", - "GIT_AUTHOR_EMAIL": "test@test.com", - "GIT_COMMITTER_NAME": "Test", - "GIT_COMMITTER_EMAIL": "test@test.com", - }, + env=env, ) public_repo = tmp_path / "public_repo" public_repo.mkdir() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True) + subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) from git_sync_filtered.sync import sync @@ -107,7 +115,10 @@ def test_sync_dry_run(tmp_path): cloned = tmp_path / "check_public" subprocess.run( - ["git", "clone", str(public_repo), str(cloned)], check=True, capture_output=True + ["git", "clone", str(public_repo), str(cloned)], + check=True, + env=env, + capture_output=True, ) assert not (cloned / "src").exists() From e92a1a196fa8b91fdce8e30d146b7ff33de58877 Mon Sep 17 00:00:00 2001 From: Robbie Kershaw Date: Thu, 26 Feb 2026 03:34:00 +0000 Subject: [PATCH 13/13] fix: explicitly create main branch in integration tests --- .github/workflows/python_ci.yaml | 2 -- tests/integration/test_main.py | 42 +++++++++++++++----------------- tests/integration/test_sync.py | 40 ++++++++++++++---------------- 3 files changed, 37 insertions(+), 47 deletions(-) diff --git a/.github/workflows/python_ci.yaml b/.github/workflows/python_ci.yaml index 54dd4ca..2fedb3c 100644 --- a/.github/workflows/python_ci.yaml +++ b/.github/workflows/python_ci.yaml @@ -20,8 +20,6 @@ jobs: steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 - name: Install uv and set the Python version uses: astral-sh/setup-uv@v7 diff --git a/tests/integration/test_main.py b/tests/integration/test_main.py index 6022fcd..cf49261 100644 --- a/tests/integration/test_main.py +++ b/tests/integration/test_main.py @@ -2,6 +2,10 @@ 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 = { @@ -10,7 +14,6 @@ def test_full_sync_flow(tmp_path): "GIT_AUTHOR_EMAIL": "test@test.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@test.com", - "GIT_DEFAULT_BRANCH": "main", } private_repo = tmp_path / "private_source" @@ -22,18 +25,14 @@ def test_full_sync_flow(tmp_path): (private_repo / "secrets").mkdir() (private_repo / "secrets" / "password.txt").write_text("password!") - subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) - subprocess.run( - ["git", "commit", "-m", "initial"], - cwd=private_repo, - check=True, - env=env, - ) + 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() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) + run_git(public_repo, "init", "--bare", env=env) from click.testing import CliRunner @@ -57,17 +56,19 @@ def test_full_sync_flow(tmp_path): 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) subprocess.run( - ["git", "checkout", "upstream/sync"], cwd=cloned, check=True, env=env + ["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, capsys): +def test_dry_run(tmp_path): """Integration test for dry-run mode.""" env = { **os.environ, @@ -75,7 +76,6 @@ def test_dry_run(tmp_path, capsys): "GIT_AUTHOR_EMAIL": "test@test.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@test.com", - "GIT_DEFAULT_BRANCH": "main", } private_repo = tmp_path / "private_source" @@ -83,18 +83,14 @@ def test_dry_run(tmp_path, capsys): (private_repo / "src").mkdir() (private_repo / "src" / "main.py").write_text("print('hello')") - subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) - subprocess.run( - ["git", "commit", "-m", "initial"], - cwd=private_repo, - check=True, - env=env, - ) + 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() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) + run_git(public_repo, "init", "--bare", env=env) from click.testing import CliRunner diff --git a/tests/integration/test_sync.py b/tests/integration/test_sync.py index 74d0dc4..73dff11 100644 --- a/tests/integration/test_sync.py +++ b/tests/integration/test_sync.py @@ -2,6 +2,10 @@ 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 = { @@ -10,7 +14,6 @@ def test_sync_full_flow(tmp_path): "GIT_AUTHOR_EMAIL": "test@test.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@test.com", - "GIT_DEFAULT_BRANCH": "main", } private_repo = tmp_path / "private_source" @@ -22,18 +25,14 @@ def test_sync_full_flow(tmp_path): (private_repo / "secrets").mkdir() (private_repo / "secrets" / "password.txt").write_text("password!") - subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) - subprocess.run( - ["git", "commit", "-m", "initial"], - cwd=private_repo, - check=True, - env=env, - ) + 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() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) + run_git(public_repo, "init", "--bare", env=env) from git_sync_filtered.sync import sync @@ -55,10 +54,12 @@ def test_sync_full_flow(tmp_path): assert result["merge_success"] is None cloned = tmp_path / "check_public" - subprocess.run(["git", "clone", str(public_repo), str(cloned)], check=True, env=env) subprocess.run( - ["git", "checkout", "upstream/sync"], cwd=cloned, check=True, env=env + ["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() @@ -73,7 +74,6 @@ def test_sync_dry_run(tmp_path): "GIT_AUTHOR_EMAIL": "test@test.com", "GIT_COMMITTER_NAME": "Test", "GIT_COMMITTER_EMAIL": "test@test.com", - "GIT_DEFAULT_BRANCH": "main", } private_repo = tmp_path / "private_source" @@ -81,18 +81,14 @@ def test_sync_dry_run(tmp_path): (private_repo / "src").mkdir() (private_repo / "src" / "main.py").write_text("print('hello')") - subprocess.run(["git", "init"], cwd=private_repo, check=True, env=env) - subprocess.run(["git", "add", "."], cwd=private_repo, check=True, env=env) - subprocess.run( - ["git", "commit", "-m", "initial"], - cwd=private_repo, - check=True, - env=env, - ) + 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() - subprocess.run(["git", "init", "--bare"], cwd=public_repo, check=True, env=env) + run_git(public_repo, "init", "--bare", env=env) from git_sync_filtered.sync import sync