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.
@@ -0,0 +1,38 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # sanitise/__init__.py
3
+ # ─────────────────────
4
+ #
5
+ # Sanitise subcommand - rewrite history to remove sensitive words.
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 .config import SanitiseConfig
19
+ from .config import load_config
20
+ from .config import load_submodule_mapping
21
+ from .patterns import build_combined_pattern
22
+ from .patterns import build_replacement_func
23
+ from .patterns import replace_in_text
24
+ from .patterns import rewrite_author_line
25
+ from .rewriter import rewrite_repository
26
+ from .run import run
27
+
28
+ __all__ = [
29
+ "run",
30
+ "SanitiseConfig",
31
+ "load_config",
32
+ "load_submodule_mapping",
33
+ "build_combined_pattern",
34
+ "build_replacement_func",
35
+ "replace_in_text",
36
+ "rewrite_author_line",
37
+ "rewrite_repository",
38
+ ]
@@ -0,0 +1,93 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # config.py
3
+ # ─────────
4
+ #
5
+ # Configuration loading and types 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 json
23
+ from typing import TYPE_CHECKING
24
+ from typing import TypedDict
25
+
26
+ if TYPE_CHECKING:
27
+ from pathlib import Path
28
+
29
+ # ────────────────────────────────────────────────────────────────────────────────────────
30
+ # Types
31
+ # ────────────────────────────────────────────────────────────────────────────────────────
32
+
33
+
34
+ # ────────────────────────────────────────────────────────────────────────────────────────
35
+ class SanitiseConfig(TypedDict, total=False):
36
+ """Configuration for repository sanitisation."""
37
+
38
+ words: list[str]
39
+ word_mapping: dict[str, str]
40
+ email_mapping: dict[str, str]
41
+ exclude_files: list[str]
42
+
43
+
44
+ # ────────────────────────────────────────────────────────────────────────────────────────
45
+ # Functions
46
+ # ────────────────────────────────────────────────────────────────────────────────────────
47
+
48
+
49
+ # ────────────────────────────────────────────────────────────────────────────────────────
50
+ def load_config(filepath: str | Path) -> SanitiseConfig:
51
+ """Load sanitise configuration from a JSON file."""
52
+ with open(filepath, encoding="utf-8") as f:
53
+ data = json.load(f)
54
+
55
+ config: SanitiseConfig = {}
56
+
57
+ if "words" in data and isinstance(data["words"], list):
58
+ config["words"] = [str(w) for w in data["words"] if w]
59
+
60
+ if "word_mapping" in data and isinstance(data["word_mapping"], dict):
61
+ config["word_mapping"] = {k.lower(): v for k, v in data["word_mapping"].items()}
62
+
63
+ if "email_mapping" in data and isinstance(data["email_mapping"], dict):
64
+ config["email_mapping"] = {
65
+ k.lower(): v for k, v in data["email_mapping"].items()
66
+ }
67
+
68
+ if "exclude_files" in data and isinstance(data["exclude_files"], list):
69
+ config["exclude_files"] = data["exclude_files"]
70
+
71
+ return config
72
+
73
+
74
+ # ────────────────────────────────────────────────────────────────────────────────────────
75
+ def load_submodule_mapping(filepath: str | Path) -> dict[str, str]:
76
+ """
77
+ Load submodule commit mapping from a file.
78
+
79
+ Format: old_sha new_sha (one per line)
80
+ """
81
+ mapping: dict[str, str] = {}
82
+ with open(filepath, encoding="utf-8") as f:
83
+ for line in f:
84
+ line = line.strip()
85
+ if not line:
86
+ continue
87
+ parts = line.split()
88
+ if len(parts) >= 2:
89
+ old_sha = parts[0].strip()
90
+ new_sha = parts[1].strip()
91
+ if old_sha and new_sha:
92
+ mapping[old_sha] = new_sha
93
+ return mapping
@@ -0,0 +1,112 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # patterns.py
3
+ # ───────────
4
+ #
5
+ # Regex pattern building and text replacement for sanitisation.
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 re
23
+
24
+ # ────────────────────────────────────────────────────────────────────────────────────────
25
+ # Functions
26
+ # ────────────────────────────────────────────────────────────────────────────────────────
27
+
28
+
29
+ # ────────────────────────────────────────────────────────────────────────────────────────
30
+ def build_combined_pattern(dirty_words: list[str]) -> re.Pattern[str]:
31
+ """Build a single combined regex pattern for all dirty words."""
32
+ escaped_words = [re.escape(word) for word in dirty_words]
33
+ combined = r"\b(" + "|".join(escaped_words) + r")\b"
34
+ return re.compile(combined, re.IGNORECASE)
35
+
36
+
37
+ # ────────────────────────────────────────────────────────────────────────────────────────
38
+ def build_replacement_func(
39
+ dirty_words: list[str],
40
+ word_mapping: dict[str, str],
41
+ default_replacement: str = "REDACTED",
42
+ ) -> tuple[re.Pattern[str], dict[str, str]]:
43
+ """Build a combined regex pattern and mapping for replacements."""
44
+ replacements: dict[str, str] = {}
45
+ for word in dirty_words:
46
+ lower_word = word.lower()
47
+ if lower_word in word_mapping:
48
+ replacements[lower_word] = word_mapping[lower_word]
49
+ else:
50
+ replacements[lower_word] = default_replacement
51
+
52
+ escaped_words = [re.escape(word) for word in dirty_words]
53
+ combined_pattern = r"\b(" + "|".join(escaped_words) + r")\b"
54
+ pattern = re.compile(combined_pattern, re.IGNORECASE)
55
+
56
+ return pattern, replacements
57
+
58
+
59
+ # ────────────────────────────────────────────────────────────────────────────────────────
60
+ def replace_in_text(
61
+ text: str,
62
+ pattern: re.Pattern[str],
63
+ replacements: dict[str, str],
64
+ ) -> str:
65
+ """Replace all dirty words in text with their replacements."""
66
+
67
+ def replace_match(match: re.Match[str]) -> str:
68
+ matched_word = match.group(0)
69
+ lower_word = matched_word.lower()
70
+ replacement = replacements.get(lower_word, "REDACTED")
71
+
72
+ if matched_word.isupper():
73
+ return replacement.upper()
74
+ elif matched_word[0].isupper() and len(matched_word) > 1:
75
+ return replacement.capitalize()
76
+ else:
77
+ return replacement
78
+
79
+ return pattern.sub(replace_match, text)
80
+
81
+
82
+ # ────────────────────────────────────────────────────────────────────────────────────────
83
+ def rewrite_author_line(
84
+ line: str,
85
+ pattern: re.Pattern[str],
86
+ replacements: dict[str, str],
87
+ email_mapping: dict[str, str],
88
+ ) -> str:
89
+ """Rewrite an author or committer line with name replacement and email lookup."""
90
+ # Format: author Name <email> timestamp timezone
91
+ # or: committer Name <email> timestamp timezone
92
+ match = re.match(r"^(author|committer) (.+?) <([^>]*)> (.+)$", line)
93
+ if not match:
94
+ return replace_in_text(line, pattern, replacements)
95
+
96
+ line_type = match.group(1)
97
+ name = match.group(2)
98
+ timestamp_tz = match.group(4)
99
+
100
+ # Replace dirty words in the name
101
+ new_name = replace_in_text(name, pattern, replacements)
102
+
103
+ # Look up the email based on the new name
104
+ name_lower = new_name.lower()
105
+ if name_lower in email_mapping:
106
+ new_email = email_mapping[name_lower]
107
+ else:
108
+ # Default to name@example.com (spaces replaced with dots)
109
+ email_local = new_name.lower().replace(" ", ".")
110
+ new_email = f"{email_local}@example.com"
111
+
112
+ return f"{line_type} {new_name} <{new_email}> {timestamp_tz}"
@@ -0,0 +1,357 @@
1
+ # ────────────────────────────────────────────────────────────────────────────────────────
2
+ # rewriter.py
3
+ # ───────────
4
+ #
5
+ # Repository rewriting to replace dirty words using git fast-export/fast-import.
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
+ import sys
24
+ from pathlib import PurePath
25
+ from typing import TYPE_CHECKING
26
+ from ..common import get_all_branches
27
+ from ..common import git
28
+ from ..common import init_repo
29
+ from .patterns import build_replacement_func
30
+ from .patterns import replace_in_text
31
+ from .patterns import rewrite_author_line
32
+
33
+ if TYPE_CHECKING:
34
+ from pathlib import Path
35
+
36
+ # ────────────────────────────────────────────────────────────────────────────────────────
37
+ # Functions
38
+ # ────────────────────────────────────────────────────────────────────────────────────────
39
+
40
+
41
+ # ────────────────────────────────────────────────────────────────────────────────────────
42
+ def _matches_exclude_pattern(filepath: str, patterns: list[str]) -> bool:
43
+ """
44
+ Check if a filepath matches any of the exclude patterns.
45
+
46
+ Uses PurePath.match() which supports glob patterns including:
47
+ - `*` matches any characters within a path component
48
+ - `**` matches any number of path components
49
+ - `?` matches a single character
50
+
51
+ Patterns are matched from the right side of the path, so:
52
+ - `package-lock.json` matches `package-lock.json` and `src/package-lock.json`
53
+ - `*.lock` matches any .lock file anywhere in the tree
54
+ - `**/*.lock` is equivalent to `*.lock` (matches anywhere)
55
+ - `node_modules/**` matches anything under any node_modules directory
56
+ """
57
+ path = PurePath(filepath)
58
+ return any(path.match(pattern) for pattern in patterns)
59
+
60
+
61
+ # ────────────────────────────────────────────────────────────────────────────────────────
62
+ def rewrite_repository(
63
+ repo_path: Path,
64
+ output_path: Path,
65
+ dirty_words: list[str],
66
+ word_mapping: dict[str, str],
67
+ default_replacement: str,
68
+ commit_map_path: Path | None,
69
+ exclude_patterns: list[str] | None = None,
70
+ email_mapping: dict[str, str] | None = None,
71
+ submodule_mapping: dict[str, str] | None = None,
72
+ verbose: bool = False,
73
+ ) -> dict[str, str]:
74
+ """Rewrite the repository, replacing dirty words and excluding files."""
75
+ if exclude_patterns is None:
76
+ exclude_patterns = []
77
+ if email_mapping is None:
78
+ email_mapping = {}
79
+ if submodule_mapping is None:
80
+ submodule_mapping = {}
81
+ pattern, replacements = build_replacement_func(
82
+ dirty_words, word_mapping, default_replacement
83
+ )
84
+
85
+ print(f"Initialising output repository at {output_path}...")
86
+ init_repo(output_path)
87
+
88
+ print("Exporting repository history...")
89
+ export_proc = subprocess.Popen(
90
+ [
91
+ "git",
92
+ "fast-export",
93
+ "--all",
94
+ "--signed-tags=strip",
95
+ "--tag-of-filtered-object=rewrite",
96
+ ],
97
+ cwd=repo_path,
98
+ stdout=subprocess.PIPE,
99
+ stderr=subprocess.PIPE,
100
+ )
101
+
102
+ import_proc = subprocess.Popen(
103
+ ["git", "fast-import", "--quiet"],
104
+ cwd=output_path,
105
+ stdin=subprocess.PIPE,
106
+ stdout=subprocess.PIPE,
107
+ stderr=subprocess.PIPE,
108
+ )
109
+
110
+ assert export_proc.stdout is not None
111
+ assert import_proc.stdin is not None
112
+
113
+ commit_count = 0
114
+ blob_count = 0
115
+ excluded_count = 0
116
+ submodule_count = 0
117
+ in_data = False
118
+ data_remaining = 0
119
+ data_buffer = b""
120
+ skip_data = False
121
+
122
+ mark_to_old_commit: dict[str, str] = {}
123
+ current_mark = ""
124
+
125
+ print("Rewriting history...")
126
+
127
+ for line in export_proc.stdout:
128
+ if in_data:
129
+ data_buffer += line
130
+ data_remaining -= len(line)
131
+
132
+ if data_remaining <= 0:
133
+ in_data = False
134
+ if skip_data:
135
+ skip_data = False
136
+ data_buffer = b""
137
+ continue
138
+
139
+ try:
140
+ text = data_buffer.decode("utf-8")
141
+ new_text = replace_in_text(text, pattern, replacements)
142
+ new_data = new_text.encode("utf-8")
143
+ if new_data != data_buffer:
144
+ blob_count += 1
145
+ if verbose:
146
+ print(" Replaced content in blob")
147
+ except UnicodeDecodeError:
148
+ new_data = data_buffer
149
+
150
+ import_proc.stdin.write(f"data {len(new_data)}\n".encode())
151
+ import_proc.stdin.write(new_data)
152
+ data_buffer = b""
153
+ elif line.startswith(b"M "):
154
+ # File modification: M <mode> <dataref> <path>
155
+ # dataref is either :mark, "inline", or a SHA (for submodules)
156
+ parts = line.decode("utf-8", errors="replace").strip().split(" ", 3)
157
+ if len(parts) >= 4:
158
+ mode = parts[1]
159
+ dataref = parts[2]
160
+ filepath = parts[3]
161
+ if exclude_patterns and _matches_exclude_pattern(
162
+ filepath, exclude_patterns
163
+ ):
164
+ excluded_count += 1
165
+ if verbose:
166
+ print(f" Excluding file: {filepath}")
167
+ if dataref == "inline":
168
+ skip_data = True
169
+ continue
170
+ # Handle submodules (mode 160000) - remap commit SHA if mapping provided
171
+ if mode == "160000":
172
+ submodule_count += 1
173
+ if submodule_mapping and dataref in submodule_mapping:
174
+ new_sha = submodule_mapping[dataref]
175
+ new_line = f"M {mode} {new_sha} {filepath}\n"
176
+ if verbose:
177
+ print(
178
+ f" Remapped submodule {filepath}: {dataref} ->"
179
+ f" {new_sha}"
180
+ )
181
+ import_proc.stdin.write(new_line.encode("utf-8"))
182
+ continue
183
+ elif verbose:
184
+ print(f" Submodule {filepath}: {dataref} (unchanged)")
185
+ import_proc.stdin.write(line)
186
+ elif line.startswith(b"D "):
187
+ # File deletion - check if it's an excluded file
188
+ filepath = line[2:].decode("utf-8", errors="replace").strip()
189
+ if exclude_patterns and _matches_exclude_pattern(
190
+ filepath, exclude_patterns
191
+ ):
192
+ continue
193
+ import_proc.stdin.write(line)
194
+ elif line.startswith(b"data "):
195
+ size_str = line[5:].strip().decode("utf-8")
196
+ data_remaining = int(size_str)
197
+ in_data = True
198
+ data_buffer = b""
199
+ elif line.startswith(b"commit "):
200
+ commit_count += 1
201
+ if commit_count % 100 == 0:
202
+ print(f" Processed {commit_count} commits...")
203
+ import_proc.stdin.write(line)
204
+ elif line.startswith(b"mark :"):
205
+ current_mark = line.decode("utf-8").strip()
206
+ import_proc.stdin.write(line)
207
+ elif line.startswith(b"original-oid "):
208
+ current_original_commit = line[13:].strip().decode("utf-8")
209
+ if current_mark:
210
+ mark_to_old_commit[current_mark] = current_original_commit
211
+ import_proc.stdin.write(line)
212
+ elif line.startswith(b"author ") or line.startswith(b"committer "):
213
+ try:
214
+ text = line.decode("utf-8").rstrip("\n")
215
+ new_text = rewrite_author_line(
216
+ text, pattern, replacements, email_mapping
217
+ )
218
+ import_proc.stdin.write((new_text + "\n").encode("utf-8"))
219
+ except UnicodeDecodeError:
220
+ import_proc.stdin.write(line)
221
+ else:
222
+ import_proc.stdin.write(line)
223
+
224
+ import_proc.stdin.close()
225
+ export_proc.wait()
226
+ import_proc.wait()
227
+
228
+ if export_proc.returncode != 0:
229
+ stderr = export_proc.stderr.read().decode() if export_proc.stderr else ""
230
+ print(f"Error during export: {stderr}", file=sys.stderr)
231
+ sys.exit(1)
232
+
233
+ if import_proc.returncode != 0:
234
+ stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
235
+ print(f"Error during import: {stderr}", file=sys.stderr)
236
+ sys.exit(1)
237
+
238
+ print(f" Processed {commit_count} commits, rewrote {blob_count} blobs")
239
+ if submodule_count > 0:
240
+ print(f" Processed {submodule_count} submodule entries")
241
+ if excluded_count > 0:
242
+ print(f" Excluded {excluded_count} file entries")
243
+
244
+ # Checkout the default branch to clean up the working directory
245
+ # fast-import leaves things in a detached HEAD state with staged files
246
+ print("Checking out working tree...")
247
+ branches = get_all_branches(output_path)
248
+ if branches:
249
+ # Prefer main, then master, then first available
250
+ if "main" in branches:
251
+ default_branch = "main"
252
+ elif "master" in branches:
253
+ default_branch = "master"
254
+ else:
255
+ default_branch = branches[0]
256
+ git(output_path, "checkout", "-f", default_branch)
257
+
258
+ # Initialise submodules if any exist
259
+ if submodule_count > 0:
260
+ print("Copying submodule URLs from source repository...")
261
+ # Get submodule URLs from source repo and apply to output
262
+ result = subprocess.run(
263
+ ["git", "config", "--get-regexp", r"submodule\..*\.url"],
264
+ cwd=repo_path,
265
+ capture_output=True,
266
+ text=True,
267
+ )
268
+ if result.returncode == 0 and result.stdout.strip():
269
+ for line in result.stdout.strip().split("\n"):
270
+ parts = line.split(" ", 1)
271
+ if len(parts) == 2:
272
+ config_key = parts[0]
273
+ url = parts[1]
274
+ subprocess.run(
275
+ ["git", "config", config_key, url],
276
+ cwd=output_path,
277
+ capture_output=True,
278
+ )
279
+ if verbose:
280
+ print(f" Set {config_key} = {url}")
281
+
282
+ print("Initialising submodules...")
283
+ subprocess.run(
284
+ ["git", "submodule", "update", "--init", "--recursive"],
285
+ cwd=output_path,
286
+ capture_output=True,
287
+ )
288
+
289
+ # Clean up submodule staging areas
290
+ print("Cleaning submodule staging areas...")
291
+ subprocess.run(
292
+ ["git", "submodule", "foreach", "--recursive", "git reset --hard HEAD"],
293
+ cwd=output_path,
294
+ capture_output=True,
295
+ )
296
+
297
+ # Reset main repo again to clear any submodule changes from index
298
+ subprocess.run(
299
+ ["git", "reset", "--hard", "HEAD"],
300
+ cwd=output_path,
301
+ capture_output=True,
302
+ )
303
+
304
+ print("Building commit ID mapping...")
305
+ commit_mapping: dict[str, str] = {}
306
+
307
+ for mark, old_commit in mark_to_old_commit.items():
308
+ mark_num = mark.split(":")[1] if ":" in mark else mark
309
+ try:
310
+ result = subprocess.run(
311
+ ["git", "rev-parse", f":{mark_num}"],
312
+ cwd=output_path,
313
+ capture_output=True,
314
+ text=True,
315
+ )
316
+ if result.returncode == 0:
317
+ new_commit = result.stdout.strip()
318
+ commit_mapping[old_commit] = new_commit
319
+ except Exception:
320
+ pass
321
+
322
+ if not commit_mapping:
323
+ old_commits_result = subprocess.run(
324
+ ["git", "rev-list", "--all", "--reverse"],
325
+ cwd=repo_path,
326
+ capture_output=True,
327
+ text=True,
328
+ )
329
+ old_commits = (
330
+ old_commits_result.stdout.strip().split("\n")
331
+ if old_commits_result.stdout
332
+ else []
333
+ )
334
+
335
+ new_commits_result = subprocess.run(
336
+ ["git", "rev-list", "--all", "--reverse"],
337
+ cwd=output_path,
338
+ capture_output=True,
339
+ text=True,
340
+ )
341
+ new_commits = (
342
+ new_commits_result.stdout.strip().split("\n")
343
+ if new_commits_result.stdout
344
+ else []
345
+ )
346
+
347
+ for old, new in zip(old_commits, new_commits, strict=False):
348
+ if old and new:
349
+ commit_mapping[old] = new
350
+
351
+ if commit_map_path:
352
+ print(f"Writing commit mapping to {commit_map_path}...")
353
+ with open(commit_map_path, "w", encoding="utf-8") as f:
354
+ for old_id, new_id in sorted(commit_mapping.items()):
355
+ f.write(f"{old_id} {new_id}\n")
356
+
357
+ return commit_mapping