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,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
|