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,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"]
@@ -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