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.
Files changed (23) hide show
  1. git_history_sanitize-0.1.0/LICENSE +21 -0
  2. git_history_sanitize-0.1.0/PKG-INFO +185 -0
  3. git_history_sanitize-0.1.0/README.md +164 -0
  4. git_history_sanitize-0.1.0/pyproject.toml +36 -0
  5. git_history_sanitize-0.1.0/setup.cfg +4 -0
  6. git_history_sanitize-0.1.0/src/git_history_sanitize/__init__.py +3 -0
  7. git_history_sanitize-0.1.0/src/git_history_sanitize/__main__.py +4 -0
  8. git_history_sanitize-0.1.0/src/git_history_sanitize/cleanup.py +35 -0
  9. git_history_sanitize-0.1.0/src/git_history_sanitize/cli.py +88 -0
  10. git_history_sanitize-0.1.0/src/git_history_sanitize/compact.py +134 -0
  11. git_history_sanitize-0.1.0/src/git_history_sanitize/engine.py +104 -0
  12. git_history_sanitize-0.1.0/src/git_history_sanitize/errors.py +10 -0
  13. git_history_sanitize-0.1.0/src/git_history_sanitize/filtering.py +60 -0
  14. git_history_sanitize-0.1.0/src/git_history_sanitize/git.py +101 -0
  15. git_history_sanitize-0.1.0/src/git_history_sanitize/policy.py +246 -0
  16. git_history_sanitize-0.1.0/src/git_history_sanitize/verify.py +98 -0
  17. git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/PKG-INFO +185 -0
  18. git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/SOURCES.txt +21 -0
  19. git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/dependency_links.txt +1 -0
  20. git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/entry_points.txt +2 -0
  21. git_history_sanitize-0.1.0/src/git_history_sanitize.egg-info/top_level.txt +1 -0
  22. git_history_sanitize-0.1.0/tests/test_end_to_end.py +160 -0
  23. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Git History Sanitize."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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
+ )