git-history-sanitize 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_history_sanitize-0.1.0/LICENSE +21 -0
- git_history_sanitize-0.1.0/PKG-INFO +185 -0
- git_history_sanitize-0.1.0/README.md +164 -0
- git_history_sanitize-0.1.0/pyproject.toml +36 -0
- git_history_sanitize-0.1.0/setup.cfg +4 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/__init__.py +3 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/__main__.py +4 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/cleanup.py +35 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/cli.py +88 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/compact.py +134 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/engine.py +104 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/errors.py +10 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/filtering.py +60 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/git.py +101 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/policy.py +246 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize/verify.py +98 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/PKG-INFO +185 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/SOURCES.txt +21 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/dependency_links.txt +1 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/entry_points.txt +2 -0
- git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/top_level.txt +1 -0
- git_history_sanitize-0.1.0/tests/test_end_to_end.py +160 -0
- git_history_sanitize-0.1.0/tests/test_policy.py +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Git History Sanitize contributors
|
|
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,185 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-history-sanitize
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create independently verifiable sanitized Git histories
|
|
5
|
+
Author: f4irline
|
|
6
|
+
Maintainer: f4irline
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/f4irline/git-history-sanitize
|
|
9
|
+
Project-URL: Repository, https://github.com/f4irline/git-history-sanitize
|
|
10
|
+
Project-URL: Issues, https://github.com/f4irline/git-history-sanitize/issues
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Git History Sanitize
|
|
23
|
+
|
|
24
|
+
Git History Sanitize creates a new, standalone Git database from an existing
|
|
25
|
+
repository according to a declarative policy. It is a history-rewriting and
|
|
26
|
+
verification tool, not a container or runtime sandbox.
|
|
27
|
+
|
|
28
|
+
It never mutates the source repository. Rewrites happen in a disposable clone,
|
|
29
|
+
the completed bare repository is independently verified, and it is atomically
|
|
30
|
+
moved to the requested output path only after verification succeeds.
|
|
31
|
+
|
|
32
|
+
The original sandbox-specific prototype is preserved in
|
|
33
|
+
[`proto/`](proto/). It demonstrates one possible consumer of a sanitized Git
|
|
34
|
+
database, but is not part of this tool. The separate `examples/` namespace is
|
|
35
|
+
reserved for samples that use this tool.
|
|
36
|
+
|
|
37
|
+
## Choose an installation
|
|
38
|
+
|
|
39
|
+
| Situation | Recommended route |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| CI, Docker, devcontainer, or sandbox integration | Digest-pinned GHCR image |
|
|
42
|
+
| Developer workstation with supported prerequisites | Pinned PyPI package |
|
|
43
|
+
| Air-gapped or internally mirrored environment | Source distribution or mirrored OCI image |
|
|
44
|
+
|
|
45
|
+
### Production OCI image
|
|
46
|
+
|
|
47
|
+
The public image is the recommended production installation because it bundles
|
|
48
|
+
the supported Git, Python, and `git-filter-repo` runtime. Pin the published
|
|
49
|
+
digest rather than a mutable tag:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
docker run --rm \
|
|
53
|
+
-v "$PWD/.git:/input.git:ro" \
|
|
54
|
+
-v "$PWD/policy.yml:/policy.yml:ro" \
|
|
55
|
+
-v "$PWD/build:/output" \
|
|
56
|
+
ghcr.io/f4irline/git-history-sanitize@sha256:<published-digest> \
|
|
57
|
+
rewrite --source /input.git --output /output/sanitized.git --policy /policy.yml
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### PyPI command-line package
|
|
61
|
+
|
|
62
|
+
Install a pinned release with `pipx`:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pipx install git-history-sanitize==<version>
|
|
66
|
+
git-history-sanitize doctor
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The PyPI package requires Git 2.36 or later and `git-filter-repo` on `PATH`.
|
|
70
|
+
Use `doctor` to confirm the installed tools before processing a repository. The
|
|
71
|
+
OCI image is the supported alternative when supplying those prerequisites on a
|
|
72
|
+
workstation or runner is inconvenient.
|
|
73
|
+
|
|
74
|
+
## Requirements
|
|
75
|
+
|
|
76
|
+
- Python 3.11 or later
|
|
77
|
+
- Git 2.36 or later
|
|
78
|
+
- `git-filter-repo` on `PATH`
|
|
79
|
+
|
|
80
|
+
For development from a checkout:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pipx install .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Policy
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
version: 1
|
|
90
|
+
|
|
91
|
+
history:
|
|
92
|
+
cutoff: "2026-09-03T00:00:00+03:00"
|
|
93
|
+
prefixMessage: "[sanitized]"
|
|
94
|
+
|
|
95
|
+
paths:
|
|
96
|
+
exclude:
|
|
97
|
+
- secret.json
|
|
98
|
+
- infra/
|
|
99
|
+
|
|
100
|
+
commits:
|
|
101
|
+
mixedMessage: "[sanitized]"
|
|
102
|
+
|
|
103
|
+
refs:
|
|
104
|
+
keep:
|
|
105
|
+
- HEAD
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The policy parser intentionally accepts a restricted, security-auditable YAML
|
|
109
|
+
subset: mappings, indented mappings, scalar values, and `-` lists. Strings may
|
|
110
|
+
be quoted. Timestamps must be RFC 3339 values with explicit timezones.
|
|
111
|
+
|
|
112
|
+
Use either `history.cutoff` or `history.cutoffCommit`. A cutoff timestamp is
|
|
113
|
+
compared with committer timestamps; the cutoff commit itself is retained.
|
|
114
|
+
|
|
115
|
+
## Usage
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git-history-sanitize doctor
|
|
119
|
+
|
|
120
|
+
git-history-sanitize plan \
|
|
121
|
+
--source .git \
|
|
122
|
+
--policy .git-history-sanitize.yml
|
|
123
|
+
|
|
124
|
+
git-history-sanitize rewrite \
|
|
125
|
+
--source .git \
|
|
126
|
+
--output build/sanitized.git \
|
|
127
|
+
--policy .git-history-sanitize.yml
|
|
128
|
+
|
|
129
|
+
git-history-sanitize verify \
|
|
130
|
+
--repository build/sanitized.git \
|
|
131
|
+
--policy .git-history-sanitize.yml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`rewrite` always creates a parentless synthetic root with the configured
|
|
135
|
+
prefix message. It retains the tree at the first allowed commit, removes
|
|
136
|
+
pre-cutoff commits, then filters sensitive paths from the shortened history.
|
|
137
|
+
Mixed commits retain allowed file changes but have their messages replaced.
|
|
138
|
+
Sensitive-only commits are pruned when they become empty.
|
|
139
|
+
|
|
140
|
+
The first release supports a single linear retained branch. Merge histories,
|
|
141
|
+
non-monotonic cutoff timestamps, and unsupported refs fail closed instead of
|
|
142
|
+
producing an ambiguous rewrite.
|
|
143
|
+
|
|
144
|
+
## Container image
|
|
145
|
+
|
|
146
|
+
Build the OCI image:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
docker buildx build --load -t git-history-sanitize:local -f Containerfile .
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Use it with a read-only Git input and writable output directory:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
docker run --rm \
|
|
156
|
+
-v "$PWD/.git:/input.git:ro" \
|
|
157
|
+
-v "$PWD/.git-history-sanitize.yml:/policy.yml:ro" \
|
|
158
|
+
-v "$PWD/build:/output" \
|
|
159
|
+
git-history-sanitize:local rewrite \
|
|
160
|
+
--source /input.git \
|
|
161
|
+
--output /output/sanitized.git \
|
|
162
|
+
--policy /policy.yml
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Run the isolated package tests with:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
docker buildx build --target test -f Containerfile .
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Verification
|
|
172
|
+
|
|
173
|
+
Verification checks the cutoff, synthetic root, configured paths, retained
|
|
174
|
+
refs, remotes, reflogs, backup metadata, and unreachable objects. It can print
|
|
175
|
+
a JSON report:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
git-history-sanitize verify \
|
|
179
|
+
--repository build/sanitized.git \
|
|
180
|
+
--policy .git-history-sanitize.yml \
|
|
181
|
+
--json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The report intentionally contains no source-to-output mappings or removed
|
|
185
|
+
commit messages.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Git History Sanitize
|
|
2
|
+
|
|
3
|
+
Git History Sanitize creates a new, standalone Git database from an existing
|
|
4
|
+
repository according to a declarative policy. It is a history-rewriting and
|
|
5
|
+
verification tool, not a container or runtime sandbox.
|
|
6
|
+
|
|
7
|
+
It never mutates the source repository. Rewrites happen in a disposable clone,
|
|
8
|
+
the completed bare repository is independently verified, and it is atomically
|
|
9
|
+
moved to the requested output path only after verification succeeds.
|
|
10
|
+
|
|
11
|
+
The original sandbox-specific prototype is preserved in
|
|
12
|
+
[`proto/`](proto/). It demonstrates one possible consumer of a sanitized Git
|
|
13
|
+
database, but is not part of this tool. The separate `examples/` namespace is
|
|
14
|
+
reserved for samples that use this tool.
|
|
15
|
+
|
|
16
|
+
## Choose an installation
|
|
17
|
+
|
|
18
|
+
| Situation | Recommended route |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| CI, Docker, devcontainer, or sandbox integration | Digest-pinned GHCR image |
|
|
21
|
+
| Developer workstation with supported prerequisites | Pinned PyPI package |
|
|
22
|
+
| Air-gapped or internally mirrored environment | Source distribution or mirrored OCI image |
|
|
23
|
+
|
|
24
|
+
### Production OCI image
|
|
25
|
+
|
|
26
|
+
The public image is the recommended production installation because it bundles
|
|
27
|
+
the supported Git, Python, and `git-filter-repo` runtime. Pin the published
|
|
28
|
+
digest rather than a mutable tag:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
docker run --rm \
|
|
32
|
+
-v "$PWD/.git:/input.git:ro" \
|
|
33
|
+
-v "$PWD/policy.yml:/policy.yml:ro" \
|
|
34
|
+
-v "$PWD/build:/output" \
|
|
35
|
+
ghcr.io/f4irline/git-history-sanitize@sha256:<published-digest> \
|
|
36
|
+
rewrite --source /input.git --output /output/sanitized.git --policy /policy.yml
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### PyPI command-line package
|
|
40
|
+
|
|
41
|
+
Install a pinned release with `pipx`:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pipx install git-history-sanitize==<version>
|
|
45
|
+
git-history-sanitize doctor
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The PyPI package requires Git 2.36 or later and `git-filter-repo` on `PATH`.
|
|
49
|
+
Use `doctor` to confirm the installed tools before processing a repository. The
|
|
50
|
+
OCI image is the supported alternative when supplying those prerequisites on a
|
|
51
|
+
workstation or runner is inconvenient.
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python 3.11 or later
|
|
56
|
+
- Git 2.36 or later
|
|
57
|
+
- `git-filter-repo` on `PATH`
|
|
58
|
+
|
|
59
|
+
For development from a checkout:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pipx install .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Policy
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
version: 1
|
|
69
|
+
|
|
70
|
+
history:
|
|
71
|
+
cutoff: "2026-09-03T00:00:00+03:00"
|
|
72
|
+
prefixMessage: "[sanitized]"
|
|
73
|
+
|
|
74
|
+
paths:
|
|
75
|
+
exclude:
|
|
76
|
+
- secret.json
|
|
77
|
+
- infra/
|
|
78
|
+
|
|
79
|
+
commits:
|
|
80
|
+
mixedMessage: "[sanitized]"
|
|
81
|
+
|
|
82
|
+
refs:
|
|
83
|
+
keep:
|
|
84
|
+
- HEAD
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The policy parser intentionally accepts a restricted, security-auditable YAML
|
|
88
|
+
subset: mappings, indented mappings, scalar values, and `-` lists. Strings may
|
|
89
|
+
be quoted. Timestamps must be RFC 3339 values with explicit timezones.
|
|
90
|
+
|
|
91
|
+
Use either `history.cutoff` or `history.cutoffCommit`. A cutoff timestamp is
|
|
92
|
+
compared with committer timestamps; the cutoff commit itself is retained.
|
|
93
|
+
|
|
94
|
+
## Usage
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git-history-sanitize doctor
|
|
98
|
+
|
|
99
|
+
git-history-sanitize plan \
|
|
100
|
+
--source .git \
|
|
101
|
+
--policy .git-history-sanitize.yml
|
|
102
|
+
|
|
103
|
+
git-history-sanitize rewrite \
|
|
104
|
+
--source .git \
|
|
105
|
+
--output build/sanitized.git \
|
|
106
|
+
--policy .git-history-sanitize.yml
|
|
107
|
+
|
|
108
|
+
git-history-sanitize verify \
|
|
109
|
+
--repository build/sanitized.git \
|
|
110
|
+
--policy .git-history-sanitize.yml
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`rewrite` always creates a parentless synthetic root with the configured
|
|
114
|
+
prefix message. It retains the tree at the first allowed commit, removes
|
|
115
|
+
pre-cutoff commits, then filters sensitive paths from the shortened history.
|
|
116
|
+
Mixed commits retain allowed file changes but have their messages replaced.
|
|
117
|
+
Sensitive-only commits are pruned when they become empty.
|
|
118
|
+
|
|
119
|
+
The first release supports a single linear retained branch. Merge histories,
|
|
120
|
+
non-monotonic cutoff timestamps, and unsupported refs fail closed instead of
|
|
121
|
+
producing an ambiguous rewrite.
|
|
122
|
+
|
|
123
|
+
## Container image
|
|
124
|
+
|
|
125
|
+
Build the OCI image:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
docker buildx build --load -t git-history-sanitize:local -f Containerfile .
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Use it with a read-only Git input and writable output directory:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
docker run --rm \
|
|
135
|
+
-v "$PWD/.git:/input.git:ro" \
|
|
136
|
+
-v "$PWD/.git-history-sanitize.yml:/policy.yml:ro" \
|
|
137
|
+
-v "$PWD/build:/output" \
|
|
138
|
+
git-history-sanitize:local rewrite \
|
|
139
|
+
--source /input.git \
|
|
140
|
+
--output /output/sanitized.git \
|
|
141
|
+
--policy /policy.yml
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Run the isolated package tests with:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
docker buildx build --target test -f Containerfile .
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Verification
|
|
151
|
+
|
|
152
|
+
Verification checks the cutoff, synthetic root, configured paths, retained
|
|
153
|
+
refs, remotes, reflogs, backup metadata, and unreachable objects. It can print
|
|
154
|
+
a JSON report:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
git-history-sanitize verify \
|
|
158
|
+
--repository build/sanitized.git \
|
|
159
|
+
--policy .git-history-sanitize.yml \
|
|
160
|
+
--json
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The report intentionally contains no source-to-output mappings or removed
|
|
164
|
+
commit messages.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "git-history-sanitize"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Create independently verifiable sanitized Git histories"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "f4irline"}]
|
|
14
|
+
maintainers = [{name = "f4irline"}]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
git-history-sanitize = "git_history_sanitize.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/f4irline/git-history-sanitize"
|
|
29
|
+
Repository = "https://github.com/f4irline/git-history-sanitize"
|
|
30
|
+
Issues = "https://github.com/f4irline/git-history-sanitize/issues"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
|
34
|
+
|
|
35
|
+
[tool.unittest]
|
|
36
|
+
start-directory = "tests"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Remove metadata and objects that must not survive sanitization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
from .git import Repository
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def retain_head_only(repository: Repository) -> str:
|
|
11
|
+
head_ref = repository.head_ref()
|
|
12
|
+
for remote in repository.text("remote").splitlines():
|
|
13
|
+
if remote:
|
|
14
|
+
repository.run("remote", "remove", remote)
|
|
15
|
+
for ref in repository.text("for-each-ref", "--format=%(refname)").splitlines():
|
|
16
|
+
if ref and ref != head_ref:
|
|
17
|
+
repository.run("update-ref", "-d", ref)
|
|
18
|
+
branch = head_ref.removeprefix("refs/heads/")
|
|
19
|
+
repository.run("config", "--local", "--remove-section", f"branch.{branch}", check=False)
|
|
20
|
+
return head_ref
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cleanup(repository: Repository) -> None:
|
|
24
|
+
retain_head_only(repository)
|
|
25
|
+
repository.run(
|
|
26
|
+
"reflog", "expire", "--expire=now", "--expire-unreachable=now", "--all"
|
|
27
|
+
)
|
|
28
|
+
for path in (
|
|
29
|
+
repository.git_dir / "logs",
|
|
30
|
+
repository.git_dir / "refs" / "original",
|
|
31
|
+
repository.git_dir / "filter-repo",
|
|
32
|
+
):
|
|
33
|
+
if path.exists():
|
|
34
|
+
shutil.rmtree(path)
|
|
35
|
+
repository.run("gc", "--prune=now", "--aggressive")
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Command-line interface for Git History Sanitize."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import asdict
|
|
9
|
+
|
|
10
|
+
from .engine import plan, rewrite
|
|
11
|
+
from .errors import SanitizeError
|
|
12
|
+
from .git import ensure_dependencies
|
|
13
|
+
from .policy import Policy
|
|
14
|
+
from .verify import verify
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _policy(path: str) -> Policy:
|
|
18
|
+
return Policy.from_file(path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parser() -> argparse.ArgumentParser:
|
|
22
|
+
parser = argparse.ArgumentParser(prog="git-history-sanitize")
|
|
23
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
doctor = subcommands.add_parser("doctor", help="check required Git tooling")
|
|
26
|
+
doctor.add_argument("--json", action="store_true")
|
|
27
|
+
|
|
28
|
+
preview = subcommands.add_parser("plan", help="inspect a proposed rewrite")
|
|
29
|
+
preview.add_argument("--source", required=True)
|
|
30
|
+
preview.add_argument("--policy", required=True)
|
|
31
|
+
preview.add_argument("--json", action="store_true")
|
|
32
|
+
|
|
33
|
+
rewrite_command = subcommands.add_parser("rewrite", help="create sanitized output")
|
|
34
|
+
rewrite_command.add_argument("--source", required=True)
|
|
35
|
+
rewrite_command.add_argument("--output", required=True)
|
|
36
|
+
rewrite_command.add_argument("--policy", required=True)
|
|
37
|
+
rewrite_command.add_argument("--json", action="store_true")
|
|
38
|
+
|
|
39
|
+
verification = subcommands.add_parser("verify", help="verify sanitized output")
|
|
40
|
+
verification.add_argument("--repository", required=True)
|
|
41
|
+
verification.add_argument("--policy", required=True)
|
|
42
|
+
verification.add_argument("--forbid", action="append", default=[])
|
|
43
|
+
verification.add_argument("--json", action="store_true")
|
|
44
|
+
return parser
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _print(value: object, as_json: bool) -> None:
|
|
48
|
+
if as_json:
|
|
49
|
+
if hasattr(value, "to_dict"):
|
|
50
|
+
print(json.dumps(value.to_dict(), sort_keys=True))
|
|
51
|
+
elif hasattr(value, "to_json"):
|
|
52
|
+
print(value.to_json())
|
|
53
|
+
else:
|
|
54
|
+
print(json.dumps(asdict(value), sort_keys=True))
|
|
55
|
+
return
|
|
56
|
+
if hasattr(value, "verification"):
|
|
57
|
+
print(f"Sanitized HEAD: {value.verification.head}")
|
|
58
|
+
print(f"Commits in output: {value.verification.commit_count}")
|
|
59
|
+
return
|
|
60
|
+
if hasattr(value, "source_commits"):
|
|
61
|
+
print(f"Source commits: {value.source_commits}")
|
|
62
|
+
print(f"Pre-cutoff commits: {value.discarded_commits}")
|
|
63
|
+
print(f"Commits before path filtering: {value.retained_commits_before_path_filter}")
|
|
64
|
+
return
|
|
65
|
+
print("Verification passed.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
arguments = _parser().parse_args(argv)
|
|
70
|
+
try:
|
|
71
|
+
if arguments.command == "doctor":
|
|
72
|
+
result = ensure_dependencies()
|
|
73
|
+
print(json.dumps(result, sort_keys=True) if arguments.json else "\n".join(result.values()))
|
|
74
|
+
return 0
|
|
75
|
+
policy = _policy(arguments.policy)
|
|
76
|
+
if arguments.command == "plan":
|
|
77
|
+
_print(plan(arguments.source, policy), arguments.json)
|
|
78
|
+
elif arguments.command == "rewrite":
|
|
79
|
+
_print(rewrite(arguments.source, arguments.output, policy), arguments.json)
|
|
80
|
+
elif arguments.command == "verify":
|
|
81
|
+
_print(
|
|
82
|
+
verify(arguments.repository, policy, tuple(arguments.forbid)),
|
|
83
|
+
arguments.json,
|
|
84
|
+
)
|
|
85
|
+
return 0
|
|
86
|
+
except SanitizeError as error:
|
|
87
|
+
print(f"error: {error}", file=sys.stderr)
|
|
88
|
+
return 2
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Cutoff compaction for a single linear retained Git branch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from .errors import SanitizeError
|
|
10
|
+
from .git import Repository
|
|
11
|
+
from .policy import Policy
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class CompactResult:
|
|
16
|
+
original_commits: int
|
|
17
|
+
discarded_commits: int
|
|
18
|
+
boundary_commit: str
|
|
19
|
+
synthetic_root: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _commits(repository: Repository) -> list[str]:
|
|
23
|
+
commits = repository.text("rev-list", "--reverse", "--topo-order", "HEAD").splitlines()
|
|
24
|
+
if not commits:
|
|
25
|
+
raise SanitizeError("Cannot compact an empty repository")
|
|
26
|
+
previous: str | None = None
|
|
27
|
+
for commit in commits:
|
|
28
|
+
parents = repository.text("show", "-s", "--format=%P", commit).split()
|
|
29
|
+
if parents != ([] if previous is None else [previous]):
|
|
30
|
+
raise SanitizeError(
|
|
31
|
+
"Version 1 cutoff compaction requires a linear retained HEAD history"
|
|
32
|
+
)
|
|
33
|
+
previous = commit
|
|
34
|
+
return commits
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _boundary_index(repository: Repository, commits: list[str], policy: Policy) -> int:
|
|
38
|
+
if policy.history.cutoff_commit:
|
|
39
|
+
resolved = repository.text(
|
|
40
|
+
"rev-parse", "--verify", f"{policy.history.cutoff_commit}^{{commit}}"
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
return commits.index(resolved)
|
|
44
|
+
except ValueError as error:
|
|
45
|
+
raise SanitizeError("history.cutoffCommit is not reachable from HEAD") from error
|
|
46
|
+
|
|
47
|
+
cutoff = policy.history.cutoff_epoch
|
|
48
|
+
assert cutoff is not None
|
|
49
|
+
allowed: int | None = None
|
|
50
|
+
for index, commit in enumerate(commits):
|
|
51
|
+
if int(repository.text("show", "-s", "--format=%ct", commit)) >= cutoff:
|
|
52
|
+
allowed = index
|
|
53
|
+
break
|
|
54
|
+
if allowed is None:
|
|
55
|
+
raise SanitizeError("No retained commit exists at or after history.cutoff")
|
|
56
|
+
for commit in commits[allowed:]:
|
|
57
|
+
if int(repository.text("show", "-s", "--format=%ct", commit)) < cutoff:
|
|
58
|
+
raise SanitizeError(
|
|
59
|
+
"Committer timestamps cross the cutoff more than once; refusing "
|
|
60
|
+
"an ambiguous history rewrite"
|
|
61
|
+
)
|
|
62
|
+
return allowed
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _metadata(repository: Repository, commit: str) -> dict[str, str]:
|
|
66
|
+
fields = repository.run(
|
|
67
|
+
"show",
|
|
68
|
+
"-s",
|
|
69
|
+
"--format=%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI",
|
|
70
|
+
commit,
|
|
71
|
+
).rstrip(b"\n").split(b"\x00")
|
|
72
|
+
if len(fields) != 6:
|
|
73
|
+
raise SanitizeError(f"Cannot read commit metadata for {commit}")
|
|
74
|
+
keys = (
|
|
75
|
+
"GIT_AUTHOR_NAME",
|
|
76
|
+
"GIT_AUTHOR_EMAIL",
|
|
77
|
+
"GIT_AUTHOR_DATE",
|
|
78
|
+
"GIT_COMMITTER_NAME",
|
|
79
|
+
"GIT_COMMITTER_EMAIL",
|
|
80
|
+
"GIT_COMMITTER_DATE",
|
|
81
|
+
)
|
|
82
|
+
environment = os.environ.copy()
|
|
83
|
+
environment.update(
|
|
84
|
+
{key: value.decode("utf-8", "surrogateescape") for key, value in zip(keys, fields)}
|
|
85
|
+
)
|
|
86
|
+
return environment
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _message(repository: Repository, commit: str) -> bytes:
|
|
90
|
+
raw = repository.run("cat-file", "commit", commit)
|
|
91
|
+
try:
|
|
92
|
+
return raw.split(b"\n\n", 1)[1]
|
|
93
|
+
except IndexError as error:
|
|
94
|
+
raise SanitizeError(f"Malformed commit object {commit}") from error
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _create_commit(
|
|
98
|
+
repository: Repository, source: str, parent: str | None, message: bytes
|
|
99
|
+
) -> str:
|
|
100
|
+
tree = repository.text("rev-parse", f"{source}^{{tree}}")
|
|
101
|
+
command = ["git", "-C", str(repository.path), "commit-tree", tree]
|
|
102
|
+
if parent:
|
|
103
|
+
command.extend(["-p", parent])
|
|
104
|
+
command.extend(["-F", "-"])
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
command,
|
|
107
|
+
input=message,
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
env=_metadata(repository, source),
|
|
111
|
+
)
|
|
112
|
+
if result.returncode:
|
|
113
|
+
raise SanitizeError("Could not recreate a sanitized commit")
|
|
114
|
+
return result.stdout.decode().strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def compact(repository: Repository, policy: Policy) -> CompactResult:
|
|
118
|
+
commits = _commits(repository)
|
|
119
|
+
boundary_index = _boundary_index(repository, commits, policy)
|
|
120
|
+
boundary = commits[boundary_index]
|
|
121
|
+
synthetic_root = _create_commit(
|
|
122
|
+
repository, boundary, None, f"{policy.history.prefix_message}\n".encode()
|
|
123
|
+
)
|
|
124
|
+
new_head = synthetic_root
|
|
125
|
+
for commit in commits[boundary_index + 1 :]:
|
|
126
|
+
new_head = _create_commit(repository, commit, new_head, _message(repository, commit))
|
|
127
|
+
|
|
128
|
+
repository.run("update-ref", repository.head_ref(), new_head, commits[-1])
|
|
129
|
+
return CompactResult(
|
|
130
|
+
original_commits=len(commits),
|
|
131
|
+
discarded_commits=boundary_index,
|
|
132
|
+
boundary_commit=boundary,
|
|
133
|
+
synthetic_root=synthetic_root,
|
|
134
|
+
)
|