git-private2public 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ name: Release to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ release:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: '3.11'
17
+
18
+ - name: Install build tools
19
+ run: pip install build
20
+
21
+ - name: Build package
22
+ run: python -m build
23
+
24
+ - name: Publish to PyPI
25
+ uses: pypa/gh-action-pypi-publish@release/v1
26
+ with:
27
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,4 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Demiurge The Single
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-private2public
3
+ Version: 0.1.0
4
+ Summary: Like .gitignore, but for what goes public. Keep a sanitized public mirror of your private repo.
5
+ Author: megamen32
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: git,mirror,open-source,privacy,public,sanitization,secrets,security
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Version Control :: Git
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: git-filter-repo>=2.38
22
+ Requires-Dist: pyyaml>=6.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # git-private2public
26
+
27
+ **[English](./README.md)** · **[Русский](./README.ru.md)**
28
+
29
+ ---
30
+
31
+ **Like `.gitignore`, but for what goes public.**
32
+
33
+ You have a private repo. You want a public one — without the secrets. This
34
+ tool keeps them in sync. Automatically.
35
+
36
+ ## Quick start
37
+
38
+ ```bash
39
+ pip install git-filter-repo pyyaml
40
+ git-private2public init # creates .gitpublic/ folder
41
+ ```
42
+
43
+ Edit `.gitpublic/config` — set source + target:
44
+
45
+ ```
46
+ source = you/private-repo
47
+ target = you/public-repo
48
+ ```
49
+
50
+ Edit `.gitpublic/ignore` — files to hide, one per line (like `.gitignore`):
51
+
52
+ ```
53
+ .env
54
+ secrets/
55
+ *.key
56
+ ```
57
+
58
+ Publish:
59
+
60
+ ```bash
61
+ git-private2public publish
62
+ ```
63
+
64
+ Done. Your public repo is clean.
65
+
66
+ ## Auto-publish on every `git push`
67
+
68
+ ```bash
69
+ git-private2public hook enable # on
70
+ git push # also publishes public mirror
71
+ git-private2public hook disable # off
72
+ ```
73
+
74
+ Native git hook. No CI, no GitHub Actions. Works offline.
75
+
76
+ ## The `.gitpublic/` folder
77
+
78
+ Each file is one concern. Like `.gitignore` — one rule per line, `#` for
79
+ comments. If a file is missing, that setting is just empty.
80
+
81
+ | File | What goes in it | Format |
82
+ |------|-----------------|--------|
83
+ | `config` | source, target, push settings | `key = value` |
84
+ | `ignore` | files to NOT publish | one path/glob per line |
85
+ | `replace` | find → replace in file contents | `old ==> new` per line |
86
+ | `scan` | refuse to push if matched | one pattern per line |
87
+ | `allow` | domains OK to publish | one per line |
88
+
89
+ **Easy** — just edit `ignore`:
90
+
91
+ ```
92
+ .env
93
+ secrets/
94
+ *.key
95
+ ```
96
+
97
+ **Medium** — also edit `replace`:
98
+
99
+ ```
100
+ 10.0.0.5 ==> 203.0.113.5
101
+ real-token ==> ***
102
+ regex:[A-Fa-f0-9]{64} ==> ***
103
+ ```
104
+
105
+ **Hard** — also edit `scan` + `allow`:
106
+
107
+ ```
108
+ # scan:
109
+ regex:github_pat_[A-Za-z0-9_]{30,}
110
+ regex:192\.168\.
111
+
112
+ # allow:
113
+ get.docker.com
114
+ ```
115
+
116
+ ## Commands
117
+
118
+ ```
119
+ init create config
120
+ scan check, don't push
121
+ publish clean + push
122
+ hook enable / disable / status
123
+ ```
124
+
125
+ ## Install
126
+
127
+ ```bash
128
+ pip install git-private2public
129
+ ```
130
+
131
+ That's it. Now you have the `git-private2public` command.
132
+
133
+ > No pip? [Single-file manual install](./git_private2public.py) — download +
134
+ > `chmod +x` (needs `pip install git-filter-repo pyyaml`).
135
+
136
+ ## Why
137
+
138
+ Git has no "private file in a public repo". So you need two repos. This keeps
139
+ them in sync — without leaking.
140
+
141
+ | | delete files | replace text | scan | auto push |
142
+ |---|:---:|:---:|:---:|:---:|
143
+ | git-filter-repo | ✅ | ✅ | ❌ | ❌ |
144
+ | BFG | ✅ | ✅ | ❌ | ❌ |
145
+ | dupligit | ❌ | ❌ | ❌ | ✅ |
146
+ | **git-private2public** | ✅ | ✅ | ✅ | ✅ |
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,126 @@
1
+ # git-private2public
2
+
3
+ **[English](./README.md)** · **[Русский](./README.ru.md)**
4
+
5
+ ---
6
+
7
+ **Like `.gitignore`, but for what goes public.**
8
+
9
+ You have a private repo. You want a public one — without the secrets. This
10
+ tool keeps them in sync. Automatically.
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ pip install git-filter-repo pyyaml
16
+ git-private2public init # creates .gitpublic/ folder
17
+ ```
18
+
19
+ Edit `.gitpublic/config` — set source + target:
20
+
21
+ ```
22
+ source = you/private-repo
23
+ target = you/public-repo
24
+ ```
25
+
26
+ Edit `.gitpublic/ignore` — files to hide, one per line (like `.gitignore`):
27
+
28
+ ```
29
+ .env
30
+ secrets/
31
+ *.key
32
+ ```
33
+
34
+ Publish:
35
+
36
+ ```bash
37
+ git-private2public publish
38
+ ```
39
+
40
+ Done. Your public repo is clean.
41
+
42
+ ## Auto-publish on every `git push`
43
+
44
+ ```bash
45
+ git-private2public hook enable # on
46
+ git push # also publishes public mirror
47
+ git-private2public hook disable # off
48
+ ```
49
+
50
+ Native git hook. No CI, no GitHub Actions. Works offline.
51
+
52
+ ## The `.gitpublic/` folder
53
+
54
+ Each file is one concern. Like `.gitignore` — one rule per line, `#` for
55
+ comments. If a file is missing, that setting is just empty.
56
+
57
+ | File | What goes in it | Format |
58
+ |------|-----------------|--------|
59
+ | `config` | source, target, push settings | `key = value` |
60
+ | `ignore` | files to NOT publish | one path/glob per line |
61
+ | `replace` | find → replace in file contents | `old ==> new` per line |
62
+ | `scan` | refuse to push if matched | one pattern per line |
63
+ | `allow` | domains OK to publish | one per line |
64
+
65
+ **Easy** — just edit `ignore`:
66
+
67
+ ```
68
+ .env
69
+ secrets/
70
+ *.key
71
+ ```
72
+
73
+ **Medium** — also edit `replace`:
74
+
75
+ ```
76
+ 10.0.0.5 ==> 203.0.113.5
77
+ real-token ==> ***
78
+ regex:[A-Fa-f0-9]{64} ==> ***
79
+ ```
80
+
81
+ **Hard** — also edit `scan` + `allow`:
82
+
83
+ ```
84
+ # scan:
85
+ regex:github_pat_[A-Za-z0-9_]{30,}
86
+ regex:192\.168\.
87
+
88
+ # allow:
89
+ get.docker.com
90
+ ```
91
+
92
+ ## Commands
93
+
94
+ ```
95
+ init create config
96
+ scan check, don't push
97
+ publish clean + push
98
+ hook enable / disable / status
99
+ ```
100
+
101
+ ## Install
102
+
103
+ ```bash
104
+ pip install git-private2public
105
+ ```
106
+
107
+ That's it. Now you have the `git-private2public` command.
108
+
109
+ > No pip? [Single-file manual install](./git_private2public.py) — download +
110
+ > `chmod +x` (needs `pip install git-filter-repo pyyaml`).
111
+
112
+ ## Why
113
+
114
+ Git has no "private file in a public repo". So you need two repos. This keeps
115
+ them in sync — without leaking.
116
+
117
+ | | delete files | replace text | scan | auto push |
118
+ |---|:---:|:---:|:---:|:---:|
119
+ | git-filter-repo | ✅ | ✅ | ❌ | ❌ |
120
+ | BFG | ✅ | ✅ | ❌ | ❌ |
121
+ | dupligit | ❌ | ❌ | ❌ | ✅ |
122
+ | **git-private2public** | ✅ | ✅ | ✅ | ✅ |
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,126 @@
1
+ # git-private2public
2
+
3
+ **[English](./README.md)** · **[Русский](./README.ru.md)**
4
+
5
+ ---
6
+
7
+ **Как `.gitignore`, только для публичности.**
8
+
9
+ У тебя приватный репо. Нужен публичный — без секретов. Эта тулза держит их в
10
+ синке. Автоматически.
11
+
12
+ ## Быстрый старт
13
+
14
+ ```bash
15
+ pip install git-filter-repo pyyaml
16
+ git-private2public init # создаёт папку .gitpublic/
17
+ ```
18
+
19
+ Отредактируй `.gitpublic/config` — source и target:
20
+
21
+ ```
22
+ source = you/private-repo
23
+ target = you/public-repo
24
+ ```
25
+
26
+ Отредактируй `.gitpublic/ignore` — что прятать, по строке (как `.gitignore`):
27
+
28
+ ```
29
+ .env
30
+ secrets/
31
+ *.key
32
+ ```
33
+
34
+ Опубликуй:
35
+
36
+ ```bash
37
+ git-private2public publish
38
+ ```
39
+
40
+ Готово. Публичный репо чистый.
41
+
42
+ ## Авто-публикация при каждом `git push`
43
+
44
+ ```bash
45
+ git-private2public hook enable # вкл
46
+ git push # также публикует публичный mirror
47
+ git-private2public hook disable # выкл
48
+ ```
49
+
50
+ Нативный git-хук. Без CI, без GitHub Actions. Работает офлайн.
51
+
52
+ ## Папка `.gitpublic/`
53
+
54
+ Каждый файл — одна забота. Как `.gitignore` — одно правило на строку, `#` для
55
+ комментариев. Если файла нет — настройки просто нет.
56
+
57
+ | Файл | Что внутри | Формат |
58
+ |------|------------|--------|
59
+ | `config` | source, target, push | `key = value` |
60
+ | `ignore` | что НЕ публиковать | путь/маска на строку |
61
+ | `replace` | найти → заменить в файлах | `old ==> new` на строку |
62
+ | `scan` | отказаться пушить если найдёт | паттерн на строку |
63
+ | `allow` | домены которые ОК | по одному на строку |
64
+
65
+ **Простой** — редактируй только `ignore`:
66
+
67
+ ```
68
+ .env
69
+ secrets/
70
+ *.key
71
+ ```
72
+
73
+ **Средний** — ещё `replace`:
74
+
75
+ ```
76
+ 10.0.0.5 ==> 203.0.113.5
77
+ real-token ==> ***
78
+ regex:[A-Fa-f0-9]{64} ==> ***
79
+ ```
80
+
81
+ **Сложный** — ещё `scan` + `allow`:
82
+
83
+ ```
84
+ # scan:
85
+ regex:github_pat_[A-Za-z0-9_]{30,}
86
+ regex:192\.168\.
87
+
88
+ # allow:
89
+ get.docker.com
90
+ ```
91
+
92
+ ## Команды
93
+
94
+ ```
95
+ init создать конфиг
96
+ scan проверить, не пушить
97
+ publish вычистить + запушить
98
+ hook enable / disable / status
99
+ ```
100
+
101
+ ## Установка
102
+
103
+ ```bash
104
+ pip install git-private2public
105
+ ```
106
+
107
+ Готово. Теперь есть команда `git-private2public`.
108
+
109
+ > Без pip? [Ручная установка одного файла](./git_private2public.py) — скачать +
110
+ > `chmod +x` (нужно `pip install git-filter-repo pyyaml`).
111
+
112
+ ## Зачем
113
+
114
+ В Git нет «приватного файла в публичном репо». Поэтому нужно два репо. Эта
115
+ тулза держит их в синке — без утечек.
116
+
117
+ | | удалить файлы | заменить текст | скан | авто пуш |
118
+ |---|:---:|:---:|:---:|:---:|
119
+ | git-filter-repo | ✅ | ✅ | ❌ | ❌ |
120
+ | BFG | ✅ | ✅ | ❌ | ❌ |
121
+ | dupligit | ❌ | ❌ | ❌ | ✅ |
122
+ | **git-private2public** | ✅ | ✅ | ✅ | ✅ |
123
+
124
+ ## Лицензия
125
+
126
+ MIT
@@ -0,0 +1,24 @@
1
+ # git-private2public config
2
+ # Easy mode: just list files to NOT publish. Like .gitignore.
3
+
4
+ source: owner/private-repo
5
+ target: owner/public-repo
6
+
7
+ ignore: # these won't be in the public repo
8
+ - ".env"
9
+ - "secrets/"
10
+ - "*.key"
11
+
12
+ # --- medium mode (uncomment to scrub secrets inside files) ---
13
+ # replace:
14
+ # - "10.0.0.5==>203.0.113.5"
15
+ # - "real-token==>***"
16
+
17
+ # --- hard mode (uncomment to refuse push if these survive) ---
18
+ # fail_on_match:
19
+ # - "regex:github_pat_[A-Za-z0-9_]{30,}"
20
+ # - "regex:192\.168\."
21
+
22
+ push:
23
+ force: true
24
+ branches: [main]
@@ -0,0 +1,598 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ git-private2public & mirror a private repo to a public one.
4
+
5
+ Config-driven wrapper over git-filter-repo:
6
+ - delete paths (globs, dirs, exact files) from history
7
+ - replace text (literal or regex, optionally scoped by glob)
8
+ - scan the result for secrets / private data (fail_on_match)
9
+ - push to the target public repo
10
+
11
+ Usage:
12
+ git-private2public publish --config rules.yaml
13
+ git-private2public scan --config rules.yaml # scan only, don't push
14
+ git-private2public init # write example config
15
+
16
+ Install:
17
+ pip install git-filter-repo pyyaml
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import fnmatch
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import tempfile
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+ from typing import Iterable
33
+
34
+ try:
35
+ import yaml
36
+ except ImportError:
37
+ sys.exit("Missing dependency: pip install pyyaml")
38
+
39
+
40
+ # --------------------------------------------------------------------------- #
41
+ # Config
42
+ # --------------------------------------------------------------------------- #
43
+
44
+ @dataclass
45
+ class Config:
46
+ source: str
47
+ target: str
48
+ delete: list[str] = field(default_factory=list) # alias: ignore
49
+ replace: list[str] = field(default_factory=list)
50
+ allow_domains: list[str] = field(default_factory=list)
51
+ fail_on_match: list[str] = field(default_factory=list)
52
+ push_force: bool = True
53
+ push_branches: list[str] = field(default_factory=lambda: ["main"])
54
+ push_tags: bool = False
55
+
56
+ @classmethod
57
+ def from_yaml(cls, path: Path) -> "Config":
58
+ data = yaml.safe_load(path.read_text())
59
+ push = data.get("push") or {}
60
+ return cls(
61
+ source=data["source"],
62
+ target=data["target"],
63
+ delete=list(data.get("delete") or data.get("ignore") or []),
64
+ replace=list(data.get("replace") or []),
65
+ allow_domains=list(data.get("allow_domains") or []),
66
+ fail_on_match=list(data.get("fail_on_match") or []),
67
+ push_force=push.get("force", True),
68
+ push_branches=list(push.get("branches") or ["main"]),
69
+ push_tags=push.get("tags", False),
70
+ )
71
+
72
+ @classmethod
73
+ def from_folder(cls, folder: Path) -> "Config":
74
+ """Load from a .gitpublic/ folder — each file is one concern, gitignore-style.
75
+
76
+ Files (all optional — if missing, that setting is empty):
77
+ config — source=, target=, push_force=, push_branches= (key=value)
78
+ ignore — one path/glob per line (# for comments)
79
+ replace — old==>new per line (regex: prefix supported, glob:*.ext: scoped)
80
+ scan — one regex/literal per line (fail_on_match)
81
+ allow — one domain per line
82
+ """
83
+ def read_lines(name: str) -> list[str]:
84
+ f = folder / name
85
+ if not f.exists():
86
+ return []
87
+ lines = []
88
+ for line in f.read_text().splitlines():
89
+ line = line.strip()
90
+ if not line or line.startswith("#"):
91
+ continue
92
+ lines.append(line)
93
+ return lines
94
+
95
+ # config file — simple key=value
96
+ source = ""
97
+ target = ""
98
+ push_force = True
99
+ push_branches = ["main"]
100
+ push_tags = False
101
+ cfg_file = folder / "config"
102
+ if cfg_file.exists():
103
+ for line in cfg_file.read_text().splitlines():
104
+ line = line.strip()
105
+ if not line or line.startswith("#") or "=" not in line:
106
+ continue
107
+ k, v = line.split("=", 1)
108
+ k, v = k.strip(), v.strip()
109
+ if k == "source":
110
+ source = v
111
+ elif k == "target":
112
+ target = v
113
+ elif k == "push_force":
114
+ push_force = v.lower() in ("true", "yes", "1")
115
+ elif k == "push_branches":
116
+ push_branches = [b.strip() for b in v.split(",") if b.strip()]
117
+ elif k == "push_tags":
118
+ push_tags = v.lower() in ("true", "yes", "1")
119
+
120
+ return cls(
121
+ source=source,
122
+ target=target,
123
+ delete=read_lines("ignore"),
124
+ replace=read_lines("replace"),
125
+ allow_domains=read_lines("allow"),
126
+ fail_on_match=read_lines("scan"),
127
+ push_force=push_force,
128
+ push_branches=push_branches,
129
+ push_tags=push_tags,
130
+ )
131
+
132
+ @classmethod
133
+ def load(cls, path: str | Path) -> "Config":
134
+ """Auto-detect: .gitpublic/ folder OR .yaml file."""
135
+ p = Path(path)
136
+ if p.is_dir():
137
+ return cls.from_folder(p)
138
+ # If path doesn't exist, try .gitpublic/ folder in same dir
139
+ if not p.exists():
140
+ folder = p.parent / ".gitpublic"
141
+ if folder.is_dir():
142
+ return cls.from_folder(folder)
143
+ return cls.from_yaml(p)
144
+
145
+
146
+ # --------------------------------------------------------------------------- #
147
+ # Rule parsing
148
+ # --------------------------------------------------------------------------- #
149
+
150
+ @dataclass
151
+ class DeleteRule:
152
+ pattern: str
153
+ is_dir: bool
154
+ is_glob: bool
155
+
156
+ @classmethod
157
+ def parse(cls, raw: str) -> "DeleteRule":
158
+ s = raw.strip()
159
+ return cls(
160
+ pattern=s,
161
+ is_dir=s.endswith("/"),
162
+ is_glob=any(c in s for c in "*?["),
163
+ )
164
+
165
+ def matches(self, path: str) -> bool:
166
+ if self.is_dir:
167
+ return path.startswith(self.pattern) or path == self.pattern.rstrip("/")
168
+ if self.is_glob:
169
+ return fnmatch.fnmatch(path, self.pattern)
170
+ return path == self.pattern
171
+
172
+
173
+ @dataclass
174
+ class ReplaceRule:
175
+ pattern: str
176
+ replacement: str
177
+ is_regex: bool
178
+ file_glob: str | None # None = all files
179
+
180
+ @classmethod
181
+ def parse(cls, raw: str) -> "ReplaceRule":
182
+ # Format: "pattern==>replacement"
183
+ # Optional prefix: "regex:" or "glob:*.json:"
184
+ s = raw.strip()
185
+ is_regex = False
186
+ file_glob = None
187
+
188
+ if s.startswith("regex:"):
189
+ s = s[len("regex:"):]
190
+ is_regex = True
191
+ elif s.startswith("glob:"):
192
+ rest = s[len("glob:"):]
193
+ # glob:*.json:pattern==>replacement
194
+ colon = rest.find(":")
195
+ if colon == -1:
196
+ raise ValueError(f"bad glob rule: {raw}")
197
+ file_glob = rest[:colon]
198
+ s = rest[colon + 1:]
199
+
200
+ sep = "==>"
201
+ idx = s.find(sep)
202
+ if idx == -1:
203
+ raise ValueError(f"replace rule missing '==>': {raw}")
204
+ return cls(
205
+ pattern=s[:idx],
206
+ replacement=s[idx + len(sep):],
207
+ is_regex=is_regex,
208
+ file_glob=file_glob,
209
+ )
210
+
211
+
212
+ # --------------------------------------------------------------------------- #
213
+ # git-filter-repo bridge
214
+ # --------------------------------------------------------------------------- #
215
+
216
+ def run(cmd: list[str], cwd: str | None = None, check: bool = True) -> subprocess.CompletedProcess:
217
+ if os.environ.get("GIT_PRIVATE2PUBLIC_DEBUG"):
218
+ sys.stderr.write(f"$ {' '.join(cmd)}\n")
219
+ return subprocess.run(cmd, cwd=cwd, check=check, capture_output=True, text=True)
220
+
221
+
222
+ def clone_source(source: str, dest: Path) -> None:
223
+ """Clone the source repo (full history, all branches)."""
224
+ run(["git", "clone", "--mirror", source, str(dest)])
225
+ # Re-init as a normal (non-bare-mirror) working clone so filter-repo is happy.
226
+ # filter-repo works on bare clones too; keep it simple.
227
+
228
+
229
+ def make_filter_repo_args(deletes: list[DeleteRule], replaces_path: Path) -> list[str]:
230
+ args: list[str] = []
231
+ if deletes:
232
+ # filter-repo --invert-paths --path ... --path ...
233
+ args.append("--invert-paths")
234
+ for d in deletes:
235
+ args.extend(["--path", d.pattern.rstrip("/")])
236
+ if replaces_path.exists():
237
+ args.extend(["--replace-text", str(replaces_path)])
238
+ return args
239
+
240
+
241
+ def write_replace_file(path: Path, rules: list[ReplaceRule]) -> None:
242
+ """git-filter-repo --replace-text expects a file with one rule per line.
243
+ Format: 'literal==>replacement' or 'regex:...==>...' or 'glob:*.json:...==>...'
244
+ """
245
+ lines = []
246
+ for r in rules:
247
+ if r.is_regex:
248
+ prefix = "regex:"
249
+ elif r.file_glob:
250
+ prefix = f"glob:{r.file_glob}:"
251
+ else:
252
+ prefix = ""
253
+ lines.append(f"{prefix}{r.pattern}==>{r.replacement}")
254
+ path.write_text("\n".join(lines) + "\n")
255
+
256
+
257
+ # --------------------------------------------------------------------------- #
258
+ # Scanning
259
+ # --------------------------------------------------------------------------- #
260
+
261
+ def scan_tree(repo: Path, config: Config) -> list[str]:
262
+ """Return list of violations (pattern + file:line) in the current tree."""
263
+ violations: list[str] = []
264
+ allow_re = re.compile("|".join(re.escape(d) for d in config.allow_domains)) if config.allow_domains else None
265
+
266
+ # Compile fail_on_match patterns
267
+ compiled: list[tuple[str, re.Pattern]] = []
268
+ for raw in config.fail_on_match:
269
+ s = raw.strip()
270
+ if s.startswith("regex:"):
271
+ compiled.append((raw, re.compile(s[len("regex:"):].encode())))
272
+ else:
273
+ compiled.append((raw, re.compile(re.escape(s.encode()))))
274
+
275
+ # List tracked files
276
+ res = run(["git", "ls-files"], cwd=str(repo))
277
+ files = [f for f in res.stdout.strip().split("\n") if f]
278
+
279
+ for fpath in files:
280
+ full = repo / fpath
281
+ if not full.is_file():
282
+ continue
283
+ try:
284
+ data = full.read_bytes()
285
+ except Exception:
286
+ continue
287
+ for raw, pat in compiled:
288
+ for m in pat.finditer(data):
289
+ # Check if it's inside an allowlisted domain context
290
+ ctx = data[max(0, m.start() - 30):m.end() + 30]
291
+ if allow_re and allow_re.search(ctx):
292
+ continue
293
+ # Find line number
294
+ line = data[:m.start()].count(b"\n") + 1
295
+ snippet = data[m.start():m.end() + 20].decode("utf-8", "replace")[:60]
296
+ violations.append(f"{fpath}:{line}: matches '{raw}' → ...{snippet}...")
297
+ return violations
298
+
299
+
300
+ # --------------------------------------------------------------------------- #
301
+ # Publish flow
302
+ # --------------------------------------------------------------------------- #
303
+
304
+ def publish(config: Config, scan_only: bool = False) -> int:
305
+ deletes = [DeleteRule.parse(d) for d in config.delete]
306
+ replaces = [ReplaceRule.parse(r) for r in config.replace]
307
+
308
+ with tempfile.TemporaryDirectory(prefix="git-private2public-") as tmp:
309
+ tmp_path = Path(tmp)
310
+ work = tmp_path / "work"
311
+
312
+ print(f"▸ Cloning {config.source} ...", file=sys.stderr)
313
+ run(["git", "clone", "--no-local", config.source, str(work)])
314
+
315
+ # Detach origin (filter-repo removes it anyway)
316
+ run(["git", "remote", "remove", "origin"], cwd=str(work), check=False)
317
+
318
+ # Write replace-text file
319
+ replace_file = tmp_path / "replacements.txt"
320
+ if replaces:
321
+ write_replace_file(replace_file, replaces)
322
+
323
+ # Run git-filter-repo
324
+ filter_repo = shutil.which("git-filter-repo")
325
+ if not filter_repo:
326
+ # Try as git subcommand
327
+ filter_repo = "git filter-repo"
328
+ fr_args = make_filter_repo_args(deletes, replace_file)
329
+ if not fr_args:
330
+ print("▸ No delete/replace rules — nothing to filter.", file=sys.stderr)
331
+ else:
332
+ print(f"▸ Rewriting history ({len(deletes)} delete rules, {len(replaces)} replace rules) ...", file=sys.stderr)
333
+ cmd = filter_repo.split() + fr_args + ["--force"]
334
+ res = subprocess.run(cmd, cwd=str(work), capture_output=True, text=True)
335
+ if res.returncode != 0:
336
+ sys.stderr.write(res.stderr)
337
+ sys.exit(f"git-filter-repo failed (rc={res.returncode})")
338
+
339
+ # Scan the result
340
+ print("▸ Scanning result for secrets / private data ...", file=sys.stderr)
341
+ violations = scan_tree(work, config)
342
+ if violations:
343
+ print(f"\n✗ {len(violations)} violation(s) found in final tree:", file=sys.stderr)
344
+ for v in violations[:30]:
345
+ print(f" {v}", file=sys.stderr)
346
+ if len(violations) > 30:
347
+ print(f" ... and {len(violations) - 30} more", file=sys.stderr)
348
+ print("\nRefusing to push. Fix the rules and retry.", file=sys.stderr)
349
+ return 1
350
+ print("✓ No violations found.", file=sys.stderr)
351
+
352
+ if scan_only:
353
+ print("▸ Scan-only mode — not pushing.", file=sys.stderr)
354
+ return 0
355
+
356
+ # Push to target
357
+ target_url = config.target
358
+ # If target is "owner/repo" shorthand, expand to GitHub HTTPS
359
+ if "/" in target_url and not target_url.startswith(("http", "git@", "ssh://")):
360
+ target_url = f"https://github.com/{target_url}.git"
361
+
362
+ # Auth from env if provided
363
+ token = os.environ.get("GIT_PRIVATE2PUBLIC_TOKEN")
364
+ if token and "github.com" in target_url:
365
+ target_url = target_url.replace("https://", f"https://x-access-token:{token}@")
366
+
367
+ print(f"▸ Pushing to {target_url} ...", file=sys.stderr)
368
+ run(["git", "remote", "add", "target", target_url], cwd=str(work))
369
+
370
+ for branch in config.push_branches:
371
+ push_cmd = ["git", "push"]
372
+ if config.push_force:
373
+ push_cmd.append("--force")
374
+ push_cmd.extend(["target", branch])
375
+ res = subprocess.run(push_cmd, cwd=str(work), capture_output=True, text=True)
376
+ if res.returncode != 0:
377
+ sys.stderr.write(res.stderr)
378
+ sys.exit(f"push of {branch} failed (rc={res.returncode})")
379
+
380
+ if config.push_tags:
381
+ res = subprocess.run(
382
+ ["git", "push", "--force", "target", "--tags"],
383
+ cwd=str(work), capture_output=True, text=True
384
+ )
385
+ if res.returncode != 0:
386
+ sys.stderr.write(res.stderr)
387
+
388
+ print(f"✓ Done. {config.target} updated.", file=sys.stderr)
389
+ return 0
390
+
391
+
392
+ # --------------------------------------------------------------------------- #
393
+ # CLI
394
+ # --------------------------------------------------------------------------- #
395
+
396
+ EXAMPLE_CONFIG = """\
397
+ # git-private2public config
398
+ # Easy mode: just list files to NOT publish. Like .gitignore.
399
+
400
+ source: owner/private-repo
401
+ target: owner/public-repo
402
+
403
+ ignore: # these won't be in the public repo
404
+ - ".env"
405
+ - "secrets/"
406
+ - "*.key"
407
+
408
+ # --- medium mode (uncomment to scrub secrets inside files) ---
409
+ # replace:
410
+ # - "10.0.0.5==>203.0.113.5"
411
+ # - "real-token==>***"
412
+
413
+ # --- hard mode (uncomment to refuse push if these survive) ---
414
+ # fail_on_match:
415
+ # - "regex:github_pat_[A-Za-z0-9_]{30,}"
416
+ # - "regex:192\\.168\\."
417
+
418
+ push:
419
+ force: true
420
+ branches: [main]
421
+ """
422
+
423
+
424
+ def cmd_hook(args) -> int:
425
+ """Install / remove / show the local git pre-push hook."""
426
+ repo_root = find_git_root(Path.cwd())
427
+ if not repo_root:
428
+ sys.exit("Not inside a git repo.")
429
+
430
+ hook_dir = repo_root / ".git" / "hooks"
431
+ hook_path = hook_dir / "pre-push"
432
+ marker = "# git-private2public hook"
433
+
434
+ if args.action == "enable":
435
+ hook_dir.mkdir(parents=True, exist_ok=True)
436
+ # Resolve path to this tool + config
437
+ tool = str(Path(__file__).resolve())
438
+ cfg = str(Path(args.config).resolve())
439
+ hook_content = f"""#!/bin/sh
440
+ {marker}
441
+ # Auto-generated by: {tool}
442
+ # Runs `git-private2public publish` before `git push` goes out.
443
+ # To disable: `git-private2public hook disable` (or delete this file)
444
+ exec python3 "{tool}" publish -c "{cfg}"
445
+ """
446
+ hook_path.write_text(hook_content)
447
+ hook_path.chmod(0o755)
448
+ print(f"✓ Hook installed: {hook_path}")
449
+ print(f" Every `git push` will now also publish your clean public mirror.")
450
+ print(f" Config: {cfg}")
451
+ print(f" Disable: git-private2public hook disable")
452
+ return 0
453
+
454
+ if args.action == "disable":
455
+ if hook_path.exists():
456
+ content = hook_path.read_text()
457
+ if marker in content:
458
+ hook_path.unlink()
459
+ print(f"✓ Hook removed: {hook_path}")
460
+ print(f" `git push` will no longer auto-publish. Run `git-private2public publish` manually.")
461
+ else:
462
+ print(f" {hook_path} exists but is not ours — leaving it alone.")
463
+ return 1
464
+ else:
465
+ print(f" No hook at {hook_path} — nothing to remove.")
466
+ return 0
467
+
468
+ if args.action == "status":
469
+ if hook_path.exists() and marker in hook_path.read_text():
470
+ print(f"✓ Hook is ENABLED: {hook_path}")
471
+ # Show the config it points to
472
+ for line in hook_path.read_text().splitlines():
473
+ if "-c" in line:
474
+ print(f" {line.strip()}")
475
+ else:
476
+ print(f"✗ Hook is disabled (no hook at {hook_path}).")
477
+ print(f" Enable: git-private2public hook enable")
478
+ return 0
479
+
480
+ return 1
481
+
482
+
483
+ def find_git_root(start: Path) -> Path | None:
484
+ """Walk up from `start` to find the nearest .git directory."""
485
+ p = start.resolve()
486
+ while p != p.parent:
487
+ if (p / ".git").is_dir():
488
+ return p
489
+ p = p.parent
490
+ return None
491
+
492
+
493
+ # Files written by `init` into .gitpublic/
494
+ GITPUBLIC_FILES = {
495
+ "config": """# Required: which repos to sync
496
+ source = you/private-repo
497
+ target = you/public-repo
498
+
499
+ # Push settings
500
+ push_force = true
501
+ push_branches = main
502
+ """,
503
+ "ignore": """# Files/dirs to NOT publish. Like .gitignore, one per line.
504
+ .env
505
+ secrets/
506
+ *.key
507
+ *.pem
508
+ """,
509
+ "replace": """# Find ==> replace, one per line. Literal by default.
510
+ # Prefix with regex: for regex. glob:*.json: to scope to file type.
511
+ # 10.0.0.5 ==> 203.0.113.5
512
+ # real-token ==> ***
513
+ # regex:[A-Fa-f0-9]{64} ==> ***
514
+ """,
515
+ "scan": """# Refuse to push if these appear in the result. One per line.
516
+ # regex:github_pat_[A-Za-z0-9_]{30,}
517
+ # regex:sk-[A-Za-z0-9]{40,}
518
+ # regex:192\\.168\\.
519
+ """,
520
+ "allow": """# Domains that are OK to publish (won't trigger scan).
521
+ # get.docker.com
522
+ # example.com
523
+ """,
524
+ }
525
+
526
+
527
+ def cmd_init(args) -> int:
528
+ # Folder mode: .gitpublic/ with one file per concern (like .gitignore)
529
+ folder = Path(args.path)
530
+ if folder.is_file() and folder.suffix in (".yaml", ".yml"):
531
+ # Legacy YAML mode
532
+ if folder.exists() and not args.force:
533
+ sys.exit(f"{folder} exists (use --force to overwrite)")
534
+ folder.write_text(EXAMPLE_CONFIG)
535
+ print(f"✓ Wrote example config to {folder}")
536
+ return 0
537
+
538
+ # Default: folder mode
539
+ if folder.exists() and not args.force:
540
+ sys.exit(f"{folder} exists (use --force to overwrite)")
541
+ folder.mkdir(parents=True, exist_ok=True)
542
+ for name, content in GITPUBLIC_FILES.items():
543
+ (folder / name).write_text(content)
544
+ print(f"✓ Created {folder}/ with:")
545
+ for name in GITPUBLIC_FILES:
546
+ print(f" {name}")
547
+ print()
548
+ print(f" Edit {folder}/config — set source + target")
549
+ print(f" Edit {folder}/ignore — files to hide (like .gitignore)")
550
+ print(f" Run: git-private2public publish")
551
+ return 0
552
+
553
+
554
+ def cmd_publish(args) -> int:
555
+ config = Config.load(args.config)
556
+ return publish(config, scan_only=args.scan)
557
+
558
+
559
+ def cmd_scan(args) -> int:
560
+ config = Config.load(args.config)
561
+ return publish(config, scan_only=True)
562
+
563
+
564
+ def main() -> int:
565
+ p = argparse.ArgumentParser(
566
+ prog="git-private2public",
567
+ description="Like .gitignore, but for what goes public. Folder-based config.",
568
+ )
569
+ sub = p.add_subparsers(dest="cmd", required=True)
570
+
571
+ p_init = sub.add_parser("init", help="write an example config")
572
+ p_init.add_argument("path", nargs="?", default=".gitpublic")
573
+ p_init.add_argument("--force", action="store_true")
574
+ p_init.set_defaults(func=cmd_init)
575
+
576
+ p_pub = sub.add_parser("publish", help="sanitize + push to target")
577
+ p_pub.add_argument("-c", "--config", default=".gitpublic")
578
+ p_pub.add_argument("--scan", action="store_true", help="scan only, don't push")
579
+ p_pub.set_defaults(func=cmd_publish)
580
+
581
+ p_scan = sub.add_parser("scan", help="scan only (no push)")
582
+ p_scan.add_argument("-c", "--config", default=".gitpublic")
583
+ p_scan.set_defaults(func=cmd_scan)
584
+
585
+ p_hook = sub.add_parser("hook", help="enable/disable a local git pre-push hook")
586
+ p_hook_sub = p_hook.add_subparsers(dest="action", required=True)
587
+ p_hook_sub.add_parser("enable", help="install the pre-push hook (auto-publish on every git push)")
588
+ p_hook_sub.add_parser("disable", help="remove the hook")
589
+ p_hook_sub.add_parser("status", help="show whether the hook is on or off")
590
+ p_hook.add_argument("-c", "--config", default=".gitpublic")
591
+ p_hook.set_defaults(func=cmd_hook)
592
+
593
+ args = p.parse_args()
594
+ return args.func(args)
595
+
596
+
597
+ if __name__ == "__main__":
598
+ sys.exit(main())
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "git-private2public"
3
+ version = "0.1.0"
4
+ description = "Like .gitignore, but for what goes public. Keep a sanitized public mirror of your private repo."
5
+ readme = "README.md"
6
+ license = {text = "MIT"}
7
+ requires-python = ">=3.9"
8
+ authors = [{name = "megamen32"}]
9
+ keywords = ["git", "security", "open-source", "privacy", "secrets", "sanitization", "mirror", "public"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Topic :: Software Development :: Version Control :: Git",
21
+ "Topic :: Security",
22
+ ]
23
+ dependencies = [
24
+ "git-filter-repo>=2.38",
25
+ "pyyaml>=6.0",
26
+ ]
27
+
28
+ [project.scripts]
29
+ git-private2public = "git_private2public:main"
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ # Single-file module — include the .py at root
37
+ only-include = ["git_private2public.py"]
@@ -0,0 +1,46 @@
1
+ # Auto-publish a clean public mirror on every push to main.
2
+ # Put this file in your PRIVATE repo at .github/workflows/publish.yml
3
+ #
4
+ # Toggle: set ENABLED to false to pause auto-publish without deleting the file.
5
+ name: publish-public-mirror
6
+
7
+ on:
8
+ push:
9
+ branches: [main]
10
+ workflow_dispatch: # also runnable manually from Actions tab
11
+
12
+ # ────────────────────────────────────────────────────────────────────
13
+ # TOGGLE: set to false to disable auto-publish (hook off)
14
+ # ────────────────────────────────────────────────────────────────────
15
+ env:
16
+ ENABLED: "true"
17
+
18
+ jobs:
19
+ publish:
20
+ if: env.ENABLED == 'true'
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - name: Checkout private repo (full history)
24
+ uses: actions/checkout@v4
25
+ with:
26
+ fetch-depth: 0
27
+
28
+ - name: Install deps
29
+ run: pip install git-filter-repo pyyaml
30
+
31
+ - name: Install git-private2public
32
+ run: |
33
+ curl -fsSL https://raw.githubusercontent.com/megamen32/git-private2public/main/git-private2public.py \
34
+ -o git-private2public && chmod +x git-private2public
35
+
36
+ - name: Scan (no push) — see what would change
37
+ run: ./git-private2public scan -c .git-private2public.yaml
38
+
39
+ - name: Publish to public repo
40
+ run: ./git-private2public publish -c .git-private2public.yaml
41
+ env:
42
+ GIT_PRIVATE2PUBLIC_TOKEN: ${{ secrets.PUBLIC_REPO_PAT }}
43
+
44
+ - name: Disabled — hook is off
45
+ if: env.ENABLED != 'true'
46
+ run: echo "Auto-publish is disabled. Set ENABLED=true in this workflow to turn it back on."