Skip to content

Commit bb69157

Browse files
authored
Version bump, GitHub Actions workflow, and validation improvements (#7)
* update badges * add examples for running the sync from github actions * Added checks for missing type hints Added missing type hints * add a bit more validation * version bump
1 parent 9a74804 commit bb69157

13 files changed

Lines changed: 432 additions & 57 deletions

File tree

.github/workflows/sync.yaml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: Sync to Public Repo
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
private_repo:
7+
required: true
8+
type: string
9+
description: Private repository URL
10+
public_repo:
11+
required: true
12+
type: string
13+
description: Public repository URL
14+
keep:
15+
required: false
16+
type: string
17+
default: src
18+
description: Space-separated paths to keep
19+
keep_from_file:
20+
required: false
21+
type: string
22+
default: ""
23+
description: File containing paths to keep
24+
sync_branch:
25+
required: false
26+
type: string
27+
default: upstream/sync
28+
description: Sync branch name
29+
main_branch:
30+
required: false
31+
type: string
32+
default: main
33+
description: Main branch name
34+
private_branch:
35+
required: false
36+
type: string
37+
default: main
38+
description: Private branch to sync from
39+
merge:
40+
required: false
41+
type: boolean
42+
default: false
43+
description: Merge into main after sync
44+
force:
45+
required: false
46+
type: boolean
47+
default: false
48+
description: Force push
49+
dry_run:
50+
required: false
51+
type: boolean
52+
default: false
53+
description: Dry run mode
54+
secrets:
55+
GH_TOKEN:
56+
required: true
57+
description: GitHub token with push access to public repo
58+
59+
permissions:
60+
contents: write
61+
62+
jobs:
63+
sync:
64+
runs-on: ubuntu-latest
65+
steps:
66+
- name: Install git-sync-filtered
67+
run: pip install git-sync-filtered
68+
69+
- name: Sync filtered repo
70+
run: |
71+
ARGS=(
72+
--private "${{ inputs.private_repo }}"
73+
--public "${{ inputs.public_repo }}"
74+
--sync-branch "${{ inputs.sync_branch }}"
75+
--main-branch "${{ inputs.main_branch }}"
76+
--private-branch "${{ inputs.private_branch }}"
77+
)
78+
79+
if [ -n "${{ inputs.keep_from_file }}" ]; then
80+
ARGS+=(--keep-from-file "${{ inputs.keep_from_file }}")
81+
fi
82+
83+
if [ -n "${{ inputs.keep }}" ]; then
84+
for path in ${{ inputs.keep }}; do
85+
ARGS+=(--keep "$path")
86+
done
87+
fi
88+
89+
[ "${{ inputs.merge }}" = "true" ] && ARGS+=(--merge)
90+
[ "${{ inputs.force }}" = "true" ] && ARGS+=(--force)
91+
[ "${{ inputs.dry_run }}" = "true" ] && ARGS+=(--dry-run)
92+
93+
git-sync-filtered "${ARGS[@]}"
94+
env:
95+
GH_TOKEN: ${{ secrets.GH_TOKEN }}

README.md

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
# git-sync-filtered
22

33
![GitHub Repo stars](https://img.shields.io/github/stars/Merge-42/git-sync-filtered?style=social)
4-
![Python versions](https://img.shields.io/pypi/pyversions/git-sync-filtered)
5-
![License](https://img.shields.io/pypi/l/git-sync-filtered)
4+
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/Merge-42/git-sync-filtered/python_ci.yaml)
5+
![Supported Python versions](https://img.shields.io/pypi/pyversions/git-sync-filtered)
6+
![GitHub Release](https://img.shields.io/github/v/release/Merge-42/git-sync-filtered)
7+
![License](https://img.shields.io/github/license/Merge-42/git-sync-filtered)
68

79
A thin wrapper around [git-filter-repo](https://github.com/newren/git-filter-repo) for syncing filtered commits from a private repository to a public repository.
810

@@ -34,6 +36,51 @@ uvx git-sync-filtered \
3436
--keep docs
3537
```
3638

39+
## GitHub Actions
40+
41+
You can use this workflow to sync from your private repo to a public repo when the private repo receives a push.
42+
43+
In your **private repository**, create `.github/workflows/sync.yaml`:
44+
45+
```yaml
46+
name: Sync to Public Repo
47+
48+
on:
49+
push:
50+
branches:
51+
- main
52+
53+
jobs:
54+
sync:
55+
uses: Merge-42/git-sync-filtered/.github/workflows/sync.yaml@v0.1.4
56+
with:
57+
private_repo: ${{ github.repositoryUrl }}
58+
public_repo: git@github.com:org/public.git
59+
keep: src docs
60+
merge: true
61+
secrets:
62+
GH_TOKEN: ${{ secrets.GH_PAT }}
63+
```
64+
65+
Required secrets:
66+
67+
- `GH_PAT` - A GitHub Personal Access Token with `repo` scope (for pushing to the public repo)
68+
69+
Available inputs:
70+
71+
| Input | Description | Default |
72+
| ---------------- | ----------------------------- | --------------- |
73+
| `private_repo` | Private repository URL | Required |
74+
| `public_repo` | Public repository URL | Required |
75+
| `keep` | Space-separated paths to keep | - |
76+
| `keep_from_file` | File containing paths to keep | - |
77+
| `sync_branch` | Sync branch name | `upstream/sync` |
78+
| `main_branch` | Main branch name | `main` |
79+
| `private_branch` | Private branch to sync from | `main` |
80+
| `merge` | Merge into main after sync | `false` |
81+
| `force` | Force push | `false` |
82+
| `dry_run` | Dry run mode | `false` |
83+
3784
## Usage
3885

3986
```bash

git_sync_filtered/cli.py

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,52 @@
1+
from fnmatch import translate as glob_translate
2+
from pathlib import Path
3+
14
import click
5+
from pydantic import BaseModel, ConfigDict, FilePath, field_validator
26

37
from git_sync_filtered.sync import sync
48

59

10+
class SyncConfig(BaseModel):
11+
model_config = ConfigDict(frozen=True)
12+
13+
private: str
14+
public: str
15+
keep: tuple[str, ...]
16+
keep_from_file: FilePath | None = None
17+
sync_branch: str = "upstream/sync"
18+
main_branch: str = "main"
19+
private_branch: str = "main"
20+
dry_run: bool = False
21+
merge: bool = False
22+
force: bool = False
23+
24+
@field_validator("keep", mode="before")
25+
@classmethod
26+
def ensure_non_empty(cls, v: tuple[str, ...]) -> tuple[str, ...]:
27+
if not v:
28+
raise ValueError("At least one --keep path required")
29+
return v
30+
31+
@field_validator("keep", mode="after")
32+
@classmethod
33+
def validate_glob_paths(cls, v: tuple[str, ...]) -> tuple[str, ...]:
34+
for path in v:
35+
if not path:
36+
raise ValueError("Keep path cannot be empty")
37+
glob_translate(path)
38+
return v
39+
40+
@field_validator("sync_branch", "main_branch", "private_branch", mode="after")
41+
@classmethod
42+
def validate_branch_name(cls, v: str) -> str:
43+
if not v:
44+
raise ValueError("Branch name cannot be empty")
45+
if v.startswith("/") or ".." in v:
46+
raise ValueError(f"Invalid branch name: {v!r}")
47+
return v
48+
49+
650
@click.command()
751
@click.option("--private", required=True, help="Private repo path or URL")
852
@click.option("--public", required=True, help="Public repo path or URL")
@@ -21,32 +65,44 @@
2165
@click.option("--merge", is_flag=True, help="Merge into main branch after sync")
2266
@click.option("--force", is_flag=True, help="Force push")
2367
def main(
24-
private,
25-
public,
26-
keep,
27-
keep_from_file,
28-
sync_branch,
29-
main_branch,
30-
private_branch,
31-
dry_run,
32-
merge,
33-
force,
34-
):
68+
private: str,
69+
public: str,
70+
keep: tuple[str, ...],
71+
keep_from_file: str | None,
72+
sync_branch: str,
73+
main_branch: str,
74+
private_branch: str,
75+
dry_run: bool,
76+
merge: bool,
77+
force: bool,
78+
) -> None:
3579
"""Sync filtered commits from private to public repository."""
3680

3781
try:
38-
result = sync(
82+
config = SyncConfig(
3983
private=private,
4084
public=public,
4185
keep=keep,
42-
keep_from_file=keep_from_file,
86+
keep_from_file=Path(keep_from_file) if keep_from_file else None,
4387
sync_branch=sync_branch,
4488
main_branch=main_branch,
4589
private_branch=private_branch,
4690
dry_run=dry_run,
4791
merge=merge,
4892
force=force,
4993
)
94+
result = sync(
95+
private=config.private,
96+
public=config.public,
97+
keep=config.keep,
98+
keep_from_file=config.keep_from_file,
99+
sync_branch=config.sync_branch,
100+
main_branch=config.main_branch,
101+
private_branch=config.private_branch,
102+
dry_run=config.dry_run,
103+
merge=config.merge,
104+
force=config.force,
105+
)
50106
except ValueError as e:
51107
raise click.ClickException(str(e))
52108

git_sync_filtered/sync.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,35 @@
22
from itertools import filterfalse
33
from pathlib import Path
44
from tempfile import TemporaryDirectory
5-
from typing import Optional
5+
from typing import Optional, TypedDict
66

77
import git
88
from git_filter_repo import FilteringOptions, RepoFilter
99

1010

11+
class SyncResult(TypedDict):
12+
paths_to_keep: list[str]
13+
dry_run_commits: list[str]
14+
merge_success: bool | None
15+
16+
1117
def read_paths_from_file(path: Path) -> list[str]:
1218
lines = (line.strip() for line in path.read_text().splitlines())
1319
return list(filterfalse(lambda line: line.startswith("#") or not line, lines))
1420

1521

1622
def collect_paths_to_keep(
17-
keep: tuple[str, ...], keep_from_file: Optional[str]
23+
keep: tuple[str, ...], keep_from_file: Optional[Path]
1824
) -> list[str]:
19-
paths_to_keep = set(keep)
25+
paths_to_keep: set[str] = set(keep)
2026

2127
if keep_from_file:
22-
paths_to_keep.update(read_paths_from_file(Path(keep_from_file)))
28+
paths_to_keep.update(read_paths_from_file(keep_from_file))
2329

24-
return sorted(list(paths_to_keep))
30+
return sorted(paths_to_keep)
2531

2632

27-
def run_filter_repo(repo_path: str, paths_to_keep: list[str]) -> None:
33+
def run_filter_repo(repo_path: Path | str, paths_to_keep: list[str]) -> None:
2834
old_cwd = os.getcwd()
2935
os.chdir(repo_path)
3036

@@ -89,14 +95,14 @@ def sync(
8995
private: str,
9096
public: str,
9197
keep: tuple[str, ...],
92-
keep_from_file: Optional[str],
98+
keep_from_file: Optional[Path],
9399
sync_branch: str,
94100
main_branch: str,
95101
private_branch: str,
96102
dry_run: bool,
97103
merge: bool,
98104
force: bool,
99-
):
105+
) -> SyncResult:
100106
paths_to_keep = collect_paths_to_keep(keep, keep_from_file)
101107

102108
if not paths_to_keep:

pyproject.toml

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "git-sync-filtered"
7-
version = "0.1.3"
7+
version = "0.1.4"
88
description = "Sync filtered commits from private to public repository"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -22,7 +22,12 @@ classifiers = [
2222
"Programming Language :: Python :: 3.14",
2323
]
2424

25-
dependencies = ["click>=8.0", "gitpython>=3.1", "git-filter-repo>=2.0"]
25+
dependencies = [
26+
"click>=8.0",
27+
"gitpython>=3.1",
28+
"git-filter-repo>=2.0",
29+
"pydantic>=2.12.5",
30+
]
2631

2732
[project.optional-dependencies]
2833
dev = ["pytest", "ruff", "mypy"]
@@ -37,9 +42,7 @@ include = ["git_sync_filtered*"]
3742
[tool.ruff]
3843
line-length = 100
3944
target-version = "py310"
40-
41-
[tool.mypy]
42-
python_version = "3.10"
45+
lint.select = ["E", "F", "ANN"]
4346

4447
[dependency-groups]
4548
dev = ["pytest>=9.0.2"]

tests/integration/test_filter_repo.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import subprocess
2+
from pathlib import Path
23

34
from git_sync_filtered.sync import run_filter_repo
45

56

6-
def test_run_filter_repo_filters_correctly(tmp_path):
7+
def test_run_filter_repo_filters_correctly(tmp_path: Path) -> None:
78
repo_path = tmp_path / "repo"
89
repo_path.mkdir()
910

0 commit comments

Comments
 (0)