git-private2public 0.1.0__py3-none-any.whl
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,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,6 @@
|
|
|
1
|
+
git_private2public.py,sha256=AHy41rBnuxPKRC8LPHA31diNoUImWP96s88zmCet94U,21147
|
|
2
|
+
git_private2public-0.1.0.dist-info/METADATA,sha256=z8K5_tDt96ZzllMcEDiVpEvI4HerezjDXiJPMuul6Yg,3543
|
|
3
|
+
git_private2public-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
4
|
+
git_private2public-0.1.0.dist-info/entry_points.txt,sha256=l8j8vyxPhr_9VBhtCzu-hGouiCY1b5hC2F3FONKs5CA,63
|
|
5
|
+
git_private2public-0.1.0.dist-info/licenses/LICENSE,sha256=VG960k7djgyoFu3-gl-hSBtie7NDXGj345ojWU24fc8,1076
|
|
6
|
+
git_private2public-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
git_private2public.py
ADDED
|
@@ -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())
|