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.
- git_private2public-0.1.0/.github/workflows/release.yml +27 -0
- git_private2public-0.1.0/.gitignore +4 -0
- git_private2public-0.1.0/LICENSE +21 -0
- git_private2public-0.1.0/PKG-INFO +150 -0
- git_private2public-0.1.0/README.md +126 -0
- git_private2public-0.1.0/README.ru.md +126 -0
- git_private2public-0.1.0/example.yaml +24 -0
- git_private2public-0.1.0/git_private2public.py +598 -0
- git_private2public-0.1.0/pyproject.toml +37 -0
- git_private2public-0.1.0/templates/publish.yml +46 -0
|
@@ -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,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."
|