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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ git-private2public = git_private2public:main
@@ -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())