git-rewrite 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_rewrite/__init__.py +32 -0
- git_rewrite/__main__.py +32 -0
- git_rewrite/argbuilder.py +1424 -0
- git_rewrite/cli.py +415 -0
- git_rewrite/common/__init__.py +103 -0
- git_rewrite/common/config.py +65 -0
- git_rewrite/common/repo.py +353 -0
- git_rewrite/common/stream.py +125 -0
- git_rewrite/compose/__init__.py +20 -0
- git_rewrite/compose/run.py +105 -0
- git_rewrite/flatten/__init__.py +26 -0
- git_rewrite/flatten/rewriter.py +524 -0
- git_rewrite/flatten/run.py +98 -0
- git_rewrite/flatten/tree.py +213 -0
- git_rewrite/py.typed +0 -0
- git_rewrite/remap/__init__.py +21 -0
- git_rewrite/remap/remapper.py +361 -0
- git_rewrite/remap/run.py +85 -0
- git_rewrite/sanitise/__init__.py +38 -0
- git_rewrite/sanitise/config.py +93 -0
- git_rewrite/sanitise/patterns.py +112 -0
- git_rewrite/sanitise/rewriter.py +357 -0
- git_rewrite/sanitise/run.py +145 -0
- git_rewrite/scan/__init__.py +21 -0
- git_rewrite/scan/run.py +100 -0
- git_rewrite/scan/scanner.py +192 -0
- git_rewrite/version.py +36 -0
- git_rewrite-0.5.0.dist-info/METADATA +178 -0
- git_rewrite-0.5.0.dist-info/RECORD +32 -0
- git_rewrite-0.5.0.dist-info/WHEEL +4 -0
- git_rewrite-0.5.0.dist-info/entry_points.txt +2 -0
- git_rewrite-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# sanitise/run.py
|
|
3
|
+
# ────────────────
|
|
4
|
+
#
|
|
5
|
+
# CLI runner for the sanitise subcommand.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# Imports
|
|
20
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
import shutil
|
|
23
|
+
import sys
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
from ..common import copy_remotes
|
|
26
|
+
from .config import load_config
|
|
27
|
+
from .config import load_submodule_mapping
|
|
28
|
+
from .rewriter import rewrite_repository
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import argparse
|
|
32
|
+
|
|
33
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Constants
|
|
35
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
SAMPLE_CONFIG = """{
|
|
38
|
+
"words": ["secretword", "internalname", "sensitiveid"],
|
|
39
|
+
"word_mapping": {
|
|
40
|
+
"secretword": "publicword",
|
|
41
|
+
"internalname": "externalname"
|
|
42
|
+
},
|
|
43
|
+
"email_mapping": {
|
|
44
|
+
"john smith": "jsmith@example.com",
|
|
45
|
+
"jane doe": "jdoe@example.com"
|
|
46
|
+
},
|
|
47
|
+
"exclude_files": [
|
|
48
|
+
"package-lock.json",
|
|
49
|
+
"yarn.lock"
|
|
50
|
+
]
|
|
51
|
+
}"""
|
|
52
|
+
|
|
53
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
# Runner
|
|
55
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
def run(args: argparse.Namespace) -> int:
|
|
60
|
+
"""Run the sanitise subcommand."""
|
|
61
|
+
# Print sample config if requested
|
|
62
|
+
if args.sample_config:
|
|
63
|
+
print(SAMPLE_CONFIG)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
# Validate required arguments
|
|
67
|
+
if not args.repo:
|
|
68
|
+
print("Error: -r/--repo is required", file=sys.stderr)
|
|
69
|
+
return 1
|
|
70
|
+
if not args.output:
|
|
71
|
+
print("Error: -o/--output is required", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
if not args.config:
|
|
74
|
+
print("Error: -c/--config is required", file=sys.stderr)
|
|
75
|
+
return 1
|
|
76
|
+
|
|
77
|
+
# Validate paths exist
|
|
78
|
+
if not args.repo.exists():
|
|
79
|
+
print(f"Error: Repository not found: {args.repo}", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
if not args.config.exists():
|
|
82
|
+
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
# Handle output directory
|
|
86
|
+
if args.output.exists():
|
|
87
|
+
if args.delete:
|
|
88
|
+
print(f"Removing existing output directory: {args.output}")
|
|
89
|
+
shutil.rmtree(args.output)
|
|
90
|
+
else:
|
|
91
|
+
print(
|
|
92
|
+
f"Error: Output directory already exists: {args.output}",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
print("Use --delete to remove it first", file=sys.stderr)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
# Load config
|
|
99
|
+
config = load_config(args.config)
|
|
100
|
+
words = config.get("words", [])
|
|
101
|
+
word_mapping = config.get("word_mapping", {})
|
|
102
|
+
email_mapping = config.get("email_mapping", {})
|
|
103
|
+
exclude_patterns = config.get("exclude_files", [])
|
|
104
|
+
|
|
105
|
+
if not words:
|
|
106
|
+
print("Error: No words specified in config file", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
# Load submodule mapping if provided
|
|
110
|
+
submodule_mapping: dict[str, str] = {}
|
|
111
|
+
if args.submodule_map:
|
|
112
|
+
if not args.submodule_map.exists():
|
|
113
|
+
print(
|
|
114
|
+
f"Error: Submodule mapping file not found: {args.submodule_map}",
|
|
115
|
+
file=sys.stderr,
|
|
116
|
+
)
|
|
117
|
+
return 1
|
|
118
|
+
submodule_mapping = load_submodule_mapping(args.submodule_map)
|
|
119
|
+
print(f"Loaded {len(submodule_mapping)} submodule mappings")
|
|
120
|
+
|
|
121
|
+
print(f"Sanitising {len(words)} dirty words...")
|
|
122
|
+
rewrite_repository(
|
|
123
|
+
repo_path=args.repo,
|
|
124
|
+
output_path=args.output,
|
|
125
|
+
dirty_words=words,
|
|
126
|
+
word_mapping=word_mapping,
|
|
127
|
+
default_replacement=args.default,
|
|
128
|
+
commit_map_path=args.commit_map,
|
|
129
|
+
exclude_patterns=exclude_patterns,
|
|
130
|
+
email_mapping=email_mapping,
|
|
131
|
+
submodule_mapping=submodule_mapping,
|
|
132
|
+
verbose=args.verbose,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Copy remotes if requested
|
|
136
|
+
if args.copy_remotes:
|
|
137
|
+
print("Copying remotes from source repository...")
|
|
138
|
+
count = copy_remotes(args.repo, args.output, verbose=args.verbose)
|
|
139
|
+
if count > 0:
|
|
140
|
+
print(f" Copied {count} remote(s)")
|
|
141
|
+
else:
|
|
142
|
+
print(" No remotes found in source repository")
|
|
143
|
+
|
|
144
|
+
print(f"\nOutput repository created at: {args.output}")
|
|
145
|
+
return 0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# scan/__init__.py
|
|
3
|
+
# ─────────────────
|
|
4
|
+
#
|
|
5
|
+
# Scan subcommand - scan for sensitive words in git history.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
from .run import run
|
|
19
|
+
from .scanner import scan_repository
|
|
20
|
+
|
|
21
|
+
__all__ = ["run", "scan_repository"]
|
git_rewrite/scan/run.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# scan/run.py
|
|
3
|
+
# ───────────
|
|
4
|
+
#
|
|
5
|
+
# CLI runner for the scan subcommand.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# Imports
|
|
20
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
from ..sanitise.config import load_config
|
|
25
|
+
from .scanner import find_excluded_files
|
|
26
|
+
from .scanner import scan_repository
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import argparse
|
|
30
|
+
|
|
31
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
# Runner
|
|
33
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
def run(args: argparse.Namespace) -> int:
|
|
38
|
+
"""Run the scan subcommand."""
|
|
39
|
+
# Validate paths
|
|
40
|
+
if not args.repo.exists():
|
|
41
|
+
print(f"Error: Repository not found: {args.repo}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
if not args.config.exists():
|
|
44
|
+
print(f"Error: Config file not found: {args.config}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
# Load config
|
|
48
|
+
config = load_config(args.config)
|
|
49
|
+
words = config.get("words", [])
|
|
50
|
+
exclude_patterns = config.get("exclude_files", [])
|
|
51
|
+
|
|
52
|
+
if not words:
|
|
53
|
+
print("Error: No words specified in config file", file=sys.stderr)
|
|
54
|
+
return 1
|
|
55
|
+
|
|
56
|
+
print(f"Scanning for {len(words)} dirty words...")
|
|
57
|
+
blob_matches, commit_matches = scan_repository(args.repo, words, args.verbose)
|
|
58
|
+
|
|
59
|
+
# Find files that would be excluded
|
|
60
|
+
excluded_files: list[str] = []
|
|
61
|
+
if exclude_patterns:
|
|
62
|
+
print(f"\nChecking {len(exclude_patterns)} exclude patterns...")
|
|
63
|
+
excluded_files = find_excluded_files(args.repo, exclude_patterns, args.verbose)
|
|
64
|
+
|
|
65
|
+
# Report results
|
|
66
|
+
total_blob = sum(blob_matches.values())
|
|
67
|
+
total_commit = sum(commit_matches.values())
|
|
68
|
+
|
|
69
|
+
print("\n" + "=" * 60)
|
|
70
|
+
print("SCAN RESULTS")
|
|
71
|
+
print("=" * 60)
|
|
72
|
+
|
|
73
|
+
if blob_matches:
|
|
74
|
+
print("\nMatches in file contents:")
|
|
75
|
+
for word, count in sorted(blob_matches.items(), key=lambda x: -x[1]):
|
|
76
|
+
print(f" {word}: {count}")
|
|
77
|
+
|
|
78
|
+
if commit_matches:
|
|
79
|
+
print("\nMatches in commit messages:")
|
|
80
|
+
for word, count in sorted(commit_matches.items(), key=lambda x: -x[1]):
|
|
81
|
+
print(f" {word}: {count}")
|
|
82
|
+
|
|
83
|
+
print(f"\nTotal: {total_blob} in files, {total_commit} in commits")
|
|
84
|
+
|
|
85
|
+
if total_blob == 0 and total_commit == 0:
|
|
86
|
+
print("\nNo dirty words found!")
|
|
87
|
+
|
|
88
|
+
# Report excluded files
|
|
89
|
+
if exclude_patterns:
|
|
90
|
+
print("\n" + "-" * 60)
|
|
91
|
+
print("FILES THAT WOULD BE EXCLUDED")
|
|
92
|
+
print("-" * 60)
|
|
93
|
+
if excluded_files:
|
|
94
|
+
print(f"\n{len(excluded_files)} file(s) match exclude patterns:")
|
|
95
|
+
for filepath in excluded_files:
|
|
96
|
+
print(f" {filepath}")
|
|
97
|
+
else:
|
|
98
|
+
print("\nNo files match the exclude patterns.")
|
|
99
|
+
|
|
100
|
+
return 0
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# scanner.py
|
|
3
|
+
# ──────────
|
|
4
|
+
#
|
|
5
|
+
# Repository scanning for dirty words using git fast-export.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# Imports
|
|
20
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
import subprocess
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from pathlib import PurePath
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
from ..sanitise.patterns import build_combined_pattern
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
import re
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
# Functions
|
|
34
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
def scan_content_fast(
|
|
39
|
+
content: str,
|
|
40
|
+
pattern: re.Pattern[str],
|
|
41
|
+
) -> dict[str, int]:
|
|
42
|
+
"""Scan content with combined pattern and return match counts by word."""
|
|
43
|
+
matches: defaultdict[str, int] = defaultdict(int)
|
|
44
|
+
for match in pattern.finditer(content):
|
|
45
|
+
word = match.group(0).lower()
|
|
46
|
+
matches[word] += 1
|
|
47
|
+
return dict(matches)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
def scan_repository(
|
|
52
|
+
repo_path: Path,
|
|
53
|
+
dirty_words: list[str],
|
|
54
|
+
verbose: bool = False,
|
|
55
|
+
) -> tuple[dict[str, int], dict[str, int]]:
|
|
56
|
+
"""
|
|
57
|
+
Scan repository using fast-export streaming for speed.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Tuple of (blob_matches, commit_matches) where each is a dict
|
|
61
|
+
mapping dirty word to count of occurrences.
|
|
62
|
+
"""
|
|
63
|
+
blob_matches: dict[str, int] = defaultdict(int)
|
|
64
|
+
commit_matches: dict[str, int] = defaultdict(int)
|
|
65
|
+
|
|
66
|
+
pattern = build_combined_pattern(dirty_words)
|
|
67
|
+
|
|
68
|
+
print("Scanning repository...")
|
|
69
|
+
|
|
70
|
+
export_proc = subprocess.Popen(
|
|
71
|
+
["git", "fast-export", "--all"],
|
|
72
|
+
cwd=repo_path,
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.DEVNULL,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
assert export_proc.stdout is not None
|
|
78
|
+
|
|
79
|
+
commit_count = 0
|
|
80
|
+
blob_count = 0
|
|
81
|
+
in_data = False
|
|
82
|
+
data_remaining = 0
|
|
83
|
+
data_buffer = b""
|
|
84
|
+
in_commit_message = False
|
|
85
|
+
last_blob_update = 0
|
|
86
|
+
in_blobs_phase = True
|
|
87
|
+
|
|
88
|
+
for line in export_proc.stdout:
|
|
89
|
+
if in_data:
|
|
90
|
+
data_buffer += line
|
|
91
|
+
data_remaining -= len(line)
|
|
92
|
+
|
|
93
|
+
if data_remaining <= 0:
|
|
94
|
+
in_data = False
|
|
95
|
+
try:
|
|
96
|
+
text = data_buffer.decode("utf-8")
|
|
97
|
+
except UnicodeDecodeError:
|
|
98
|
+
try:
|
|
99
|
+
text = data_buffer.decode("latin-1")
|
|
100
|
+
except UnicodeDecodeError:
|
|
101
|
+
data_buffer = b""
|
|
102
|
+
in_commit_message = False
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
matches = scan_content_fast(text, pattern)
|
|
106
|
+
target = commit_matches if in_commit_message else blob_matches
|
|
107
|
+
for word, count in matches.items():
|
|
108
|
+
target[word] += count
|
|
109
|
+
if verbose:
|
|
110
|
+
location = "commit message" if in_commit_message else "blob"
|
|
111
|
+
print(f" Found '{word}' x{count} in {location}")
|
|
112
|
+
|
|
113
|
+
if not in_commit_message:
|
|
114
|
+
blob_count += 1
|
|
115
|
+
if blob_count - last_blob_update >= 100:
|
|
116
|
+
print(f" Scanned {blob_count} blobs...")
|
|
117
|
+
last_blob_update = blob_count
|
|
118
|
+
|
|
119
|
+
data_buffer = b""
|
|
120
|
+
in_commit_message = False
|
|
121
|
+
|
|
122
|
+
elif line.startswith(b"data "):
|
|
123
|
+
size_str = line[5:].strip().decode("utf-8")
|
|
124
|
+
data_remaining = int(size_str)
|
|
125
|
+
in_data = True
|
|
126
|
+
data_buffer = b""
|
|
127
|
+
|
|
128
|
+
elif line.startswith(b"commit "):
|
|
129
|
+
if in_blobs_phase:
|
|
130
|
+
in_blobs_phase = False
|
|
131
|
+
print(
|
|
132
|
+
f" Finished scanning {blob_count} blobs, now scanning commits..."
|
|
133
|
+
)
|
|
134
|
+
commit_count += 1
|
|
135
|
+
in_commit_message = True
|
|
136
|
+
if commit_count % 100 == 0:
|
|
137
|
+
print(f" Scanned {commit_count} commits...")
|
|
138
|
+
|
|
139
|
+
elif line.startswith(b"blob"):
|
|
140
|
+
in_commit_message = False
|
|
141
|
+
|
|
142
|
+
export_proc.wait()
|
|
143
|
+
print(f" Done: {commit_count} commits, {blob_count} blobs")
|
|
144
|
+
|
|
145
|
+
return dict(blob_matches), dict(commit_matches)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
def find_excluded_files(
|
|
150
|
+
repo_path: Path,
|
|
151
|
+
exclude_patterns: list[str],
|
|
152
|
+
verbose: bool = False,
|
|
153
|
+
) -> list[str]:
|
|
154
|
+
"""
|
|
155
|
+
Find all files in the repository that match the exclude patterns.
|
|
156
|
+
|
|
157
|
+
Scans all files across all branches to find matches.
|
|
158
|
+
Returns a sorted list of unique file paths that would be excluded.
|
|
159
|
+
"""
|
|
160
|
+
if not exclude_patterns:
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
# Get all unique file paths across all commits
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
["git", "log", "--all", "--pretty=format:", "--name-only", "--diff-filter=A"],
|
|
166
|
+
cwd=repo_path,
|
|
167
|
+
capture_output=True,
|
|
168
|
+
text=True,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if result.returncode != 0:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# Collect unique paths
|
|
175
|
+
all_paths: set[str] = set()
|
|
176
|
+
for line in result.stdout.splitlines():
|
|
177
|
+
line = line.strip()
|
|
178
|
+
if line:
|
|
179
|
+
all_paths.add(line)
|
|
180
|
+
|
|
181
|
+
# Check which paths match exclude patterns
|
|
182
|
+
excluded: set[str] = set()
|
|
183
|
+
for filepath in all_paths:
|
|
184
|
+
path = PurePath(filepath)
|
|
185
|
+
for pattern in exclude_patterns:
|
|
186
|
+
if path.match(pattern):
|
|
187
|
+
excluded.add(filepath)
|
|
188
|
+
if verbose:
|
|
189
|
+
print(f" Pattern '{pattern}' matches: {filepath}")
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
return sorted(excluded)
|
git_rewrite/version.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# version.py
|
|
3
|
+
# ──────────
|
|
4
|
+
#
|
|
5
|
+
# Version string handling - imports from generated _version.py at build time,
|
|
6
|
+
# falls back to "dev" when not built.
|
|
7
|
+
#
|
|
8
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
9
|
+
#
|
|
10
|
+
# Authors
|
|
11
|
+
# ───────
|
|
12
|
+
# bena (via Claude)
|
|
13
|
+
#
|
|
14
|
+
# Version History
|
|
15
|
+
# ───────────────
|
|
16
|
+
# Jan 2026 - Created
|
|
17
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
20
|
+
# Version
|
|
21
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
def _get_version() -> str:
|
|
26
|
+
try:
|
|
27
|
+
# fmt: off
|
|
28
|
+
from ._version import __version__ as _v # pyright: ignore[reportMissingImports,reportUnknownVariableType] # noqa: I001
|
|
29
|
+
# fmt: on
|
|
30
|
+
|
|
31
|
+
return str(_v) # pyright: ignore[reportUnknownArgumentType]
|
|
32
|
+
except ImportError:
|
|
33
|
+
return "dev"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
VERSION_STR: str = _get_version()
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: git-rewrite
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Git history rewriting tools - sanitise, flatten submodules, and remap references
|
|
5
|
+
Project-URL: Repository, https://gitlab.com/cyberassessmentlabs/public/tools/git-rewrite
|
|
6
|
+
Project-URL: Documentation, https://cyberassessmentlabs.gitlab.io/public/docs/git-rewrite/latest
|
|
7
|
+
Author: Cyber Assessment Labs
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: git,history,rewrite,sanitise,submodules
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
19
|
+
Requires-Python: >=3.14
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# git-rewrite
|
|
23
|
+
|
|
24
|
+
Git history rewriting tools - sanitise, flatten submodules, and remap references.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv sync
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### Scan for Sensitive Words
|
|
35
|
+
|
|
36
|
+
Scan a repository for dirty words without making any changes:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv run git-rewrite scan -r /path/to/repo -c config.json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
- `-r, --repo` - Path to the git repository
|
|
44
|
+
- `-c, --config` - Path to JSON config file
|
|
45
|
+
- `-v, --verbose` - Show verbose output
|
|
46
|
+
|
|
47
|
+
### Sanitise Repository
|
|
48
|
+
|
|
49
|
+
Rewrite history to remove sensitive words:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv run git-rewrite sanitise -r /path/to/repo -o /path/to/output -c config.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
- `-r, --repo` - Path to source repository
|
|
57
|
+
- `-o, --output` - Path for output repository
|
|
58
|
+
- `-c, --config` - Path to JSON config file
|
|
59
|
+
- `-d, --default` - Default replacement word (default: REDACTED)
|
|
60
|
+
- `-m, --commit-map` - Path to write commit mapping file
|
|
61
|
+
- `-s, --submodule-map` - Path to submodule commit mapping file
|
|
62
|
+
- `--delete` - Delete output directory if exists
|
|
63
|
+
- `--sample-config` - Print sample config and exit
|
|
64
|
+
- `-v, --verbose` - Show verbose output
|
|
65
|
+
|
|
66
|
+
#### Sample Configuration
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"words": ["secretword", "internalname", "sensitiveid"],
|
|
71
|
+
"word_mapping": {
|
|
72
|
+
"secretword": "publicword",
|
|
73
|
+
"internalname": "externalname"
|
|
74
|
+
},
|
|
75
|
+
"email_mapping": {
|
|
76
|
+
"john smith": "jsmith@example.com",
|
|
77
|
+
"jane doe": "jdoe@example.com"
|
|
78
|
+
},
|
|
79
|
+
"exclude_files": [
|
|
80
|
+
"package-lock.json",
|
|
81
|
+
"yarn.lock"
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Flatten Submodules
|
|
87
|
+
|
|
88
|
+
Flatten submodules into the main repository:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Scan for submodules only
|
|
92
|
+
uv run git-rewrite flatten -r /path/to/repo --scan-only
|
|
93
|
+
|
|
94
|
+
# Flatten submodules
|
|
95
|
+
uv run git-rewrite flatten -r /path/to/repo -o /path/to/output
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Options:
|
|
99
|
+
- `-r, --repo` - Path to source repository
|
|
100
|
+
- `-o, --output` - Path for output repository
|
|
101
|
+
- `-m, --commit-map` - Path to write commit mapping file
|
|
102
|
+
- `--scan-only` - Only scan and list submodules
|
|
103
|
+
- `--delete` - Delete output directory if exists
|
|
104
|
+
- `-v, --verbose` - Show verbose output
|
|
105
|
+
|
|
106
|
+
### Remap Submodule References
|
|
107
|
+
|
|
108
|
+
Update submodule commit references in a repository:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv run git-rewrite remap -r /path/to/repo -o /path/to/output -m mapping.txt
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Options:
|
|
115
|
+
- `-r, --repo` - Path to source repository
|
|
116
|
+
- `-o, --output` - Path for output repository
|
|
117
|
+
- `-m, --commit-map` - Path to commit mapping file
|
|
118
|
+
- `--submodule-path` - Specific submodule path to remap
|
|
119
|
+
- `--url-rewrite OLD NEW` - Rewrite URL in .gitmodules (can repeat)
|
|
120
|
+
- `--delete` - Delete output directory if exists
|
|
121
|
+
- `-v, --verbose` - Show verbose output
|
|
122
|
+
|
|
123
|
+
## Workflow Example
|
|
124
|
+
|
|
125
|
+
A typical workflow for cleaning up and flattening a repository:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
# 1. Scan for sensitive words
|
|
129
|
+
uv run git-rewrite scan -r ./myrepo -c dirty-words.json
|
|
130
|
+
|
|
131
|
+
# 2. Sanitise the repository
|
|
132
|
+
uv run git-rewrite sanitise -r ./myrepo -o ./myrepo-clean -c dirty-words.json \
|
|
133
|
+
-m commit-map.txt
|
|
134
|
+
|
|
135
|
+
# 3. Flatten submodules in the cleaned repo
|
|
136
|
+
uv run git-rewrite flatten -r ./myrepo-clean -o ./myrepo-flat -m submodule-map.txt
|
|
137
|
+
|
|
138
|
+
# 4. Update consumer repositories to use new submodule commits
|
|
139
|
+
uv run git-rewrite remap -r ./consumer-repo -o ./consumer-updated \
|
|
140
|
+
-m submodule-map.txt --submodule-path libs/mylib
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# Set up development environment
|
|
147
|
+
make dev
|
|
148
|
+
|
|
149
|
+
# Run linting and type checking
|
|
150
|
+
make check
|
|
151
|
+
|
|
152
|
+
# Auto-format code
|
|
153
|
+
make format
|
|
154
|
+
|
|
155
|
+
# Build wheel and docs
|
|
156
|
+
make build
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Publishing
|
|
160
|
+
|
|
161
|
+
Publishing requires `cal-publish-python` configuration. See the [cal-publish-python documentation](https://cyberassessmentlabs.gitlab.io/public/docs/cal-publish-python/latest/) for setup.
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# Build first
|
|
165
|
+
make build
|
|
166
|
+
|
|
167
|
+
# Publish wheel to PyPI and docs to GitLab Pages
|
|
168
|
+
make publish
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Requirements
|
|
172
|
+
|
|
173
|
+
- Python 3.14+
|
|
174
|
+
- Git
|
|
175
|
+
|
|
176
|
+
## Licence
|
|
177
|
+
|
|
178
|
+
MIT
|