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,213 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# tree.py
|
|
3
|
+
# ───────
|
|
4
|
+
#
|
|
5
|
+
# Tree manipulation and submodule expansion logic.
|
|
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
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
from ..common import GITLINK_MODE
|
|
25
|
+
from ..common import TREE_MODE
|
|
26
|
+
from ..common import get_gitmodules_at_commit
|
|
27
|
+
from ..common import get_tree_sha
|
|
28
|
+
from ..common import hash_object
|
|
29
|
+
from ..common import ls_tree
|
|
30
|
+
from ..common import mktree
|
|
31
|
+
from ..common import object_exists
|
|
32
|
+
from ..common import read_blob
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..common import SubmoduleInfo
|
|
36
|
+
from ..common import TreeEntry
|
|
37
|
+
|
|
38
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
# Types
|
|
40
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
SubmoduleRepos = dict[str, Path]
|
|
43
|
+
|
|
44
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
# Tree Expansion
|
|
46
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def expand_tree(
|
|
50
|
+
source_repo: Path,
|
|
51
|
+
output_repo: Path,
|
|
52
|
+
tree_sha: str,
|
|
53
|
+
submodule_info: dict[str, SubmoduleInfo],
|
|
54
|
+
submodule_repos: SubmoduleRepos,
|
|
55
|
+
current_path: str = "",
|
|
56
|
+
verbose: bool = False,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Recursively expand a tree, replacing submodule gitlinks with actual content.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
source_repo: Path to the source repository
|
|
63
|
+
output_repo: Path to the output repository (where we create new objects)
|
|
64
|
+
tree_sha: SHA of the tree to expand
|
|
65
|
+
submodule_info: Submodule configuration from .gitmodules
|
|
66
|
+
submodule_repos: Mapping of submodule paths to their cloned repository paths
|
|
67
|
+
current_path: Current path within the tree (for nested submodules)
|
|
68
|
+
verbose: Whether to print verbose output
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
SHA of the new expanded tree
|
|
72
|
+
"""
|
|
73
|
+
entries = ls_tree(source_repo, tree_sha)
|
|
74
|
+
new_entries: list[TreeEntry] = []
|
|
75
|
+
|
|
76
|
+
for entry in entries:
|
|
77
|
+
full_path = f"{current_path}/{entry['name']}" if current_path else entry["name"]
|
|
78
|
+
|
|
79
|
+
if entry["mode"] == GITLINK_MODE:
|
|
80
|
+
# This is a submodule - expand it
|
|
81
|
+
new_entry = expand_submodule(
|
|
82
|
+
output_repo=output_repo,
|
|
83
|
+
submodule_path=full_path,
|
|
84
|
+
submodule_commit=entry["sha"],
|
|
85
|
+
submodule_info=submodule_info,
|
|
86
|
+
submodule_repos=submodule_repos,
|
|
87
|
+
verbose=verbose,
|
|
88
|
+
)
|
|
89
|
+
if new_entry:
|
|
90
|
+
new_entries.append(new_entry)
|
|
91
|
+
else:
|
|
92
|
+
# Could not expand submodule, skip it
|
|
93
|
+
if verbose:
|
|
94
|
+
print(f" Warning: Could not expand submodule at {full_path}")
|
|
95
|
+
|
|
96
|
+
elif entry["type"] == "tree":
|
|
97
|
+
# Recurse into subdirectory
|
|
98
|
+
expanded_sha = expand_tree(
|
|
99
|
+
source_repo=source_repo,
|
|
100
|
+
output_repo=output_repo,
|
|
101
|
+
tree_sha=entry["sha"],
|
|
102
|
+
submodule_info=submodule_info,
|
|
103
|
+
submodule_repos=submodule_repos,
|
|
104
|
+
current_path=full_path,
|
|
105
|
+
verbose=verbose,
|
|
106
|
+
)
|
|
107
|
+
new_entries.append(
|
|
108
|
+
{
|
|
109
|
+
"mode": entry["mode"],
|
|
110
|
+
"type": "tree",
|
|
111
|
+
"sha": expanded_sha,
|
|
112
|
+
"name": entry["name"],
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
else:
|
|
117
|
+
# Blob or other - copy to output repo
|
|
118
|
+
copy_object(source_repo, output_repo, entry["sha"])
|
|
119
|
+
new_entries.append(entry)
|
|
120
|
+
|
|
121
|
+
return mktree(output_repo, new_entries)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
def expand_submodule(
|
|
126
|
+
output_repo: Path,
|
|
127
|
+
submodule_path: str,
|
|
128
|
+
submodule_commit: str,
|
|
129
|
+
submodule_info: dict[str, SubmoduleInfo],
|
|
130
|
+
submodule_repos: SubmoduleRepos,
|
|
131
|
+
verbose: bool = False,
|
|
132
|
+
) -> TreeEntry | None:
|
|
133
|
+
"""
|
|
134
|
+
Expand a submodule by fetching its content at the specified commit.
|
|
135
|
+
|
|
136
|
+
Returns a tree entry for the expanded submodule, or None if expansion fails.
|
|
137
|
+
"""
|
|
138
|
+
if submodule_path not in submodule_repos:
|
|
139
|
+
if verbose:
|
|
140
|
+
print(f" Submodule {submodule_path} not in cloned repos")
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
submodule_repo = submodule_repos[submodule_path]
|
|
144
|
+
|
|
145
|
+
# Check if the commit exists in the submodule repo
|
|
146
|
+
if not object_exists(submodule_repo, submodule_commit):
|
|
147
|
+
if verbose:
|
|
148
|
+
print(f" Commit {submodule_commit[:8]} not found in {submodule_path}")
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
# Get the tree SHA for this commit in the submodule
|
|
152
|
+
try:
|
|
153
|
+
submodule_tree = get_tree_sha(submodule_repo, submodule_commit)
|
|
154
|
+
except Exception:
|
|
155
|
+
if verbose:
|
|
156
|
+
print(f" Could not get tree for {submodule_path}@{submodule_commit[:8]}")
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
# Check if the submodule itself has submodules (nested submodules)
|
|
160
|
+
nested_submodule_info = get_gitmodules_at_commit(submodule_repo, submodule_commit)
|
|
161
|
+
|
|
162
|
+
# Get or create nested submodule repos if needed
|
|
163
|
+
nested_submodule_repos: SubmoduleRepos = {}
|
|
164
|
+
if nested_submodule_info:
|
|
165
|
+
# For nested submodules, we need to resolve them relative to the parent submodule
|
|
166
|
+
for nested_path in nested_submodule_info:
|
|
167
|
+
full_nested_path = f"{submodule_path}/{nested_path}"
|
|
168
|
+
if full_nested_path in submodule_repos:
|
|
169
|
+
nested_submodule_repos[nested_path] = submodule_repos[full_nested_path]
|
|
170
|
+
|
|
171
|
+
# Recursively expand the submodule's tree
|
|
172
|
+
expanded_sha = expand_tree(
|
|
173
|
+
source_repo=submodule_repo,
|
|
174
|
+
output_repo=output_repo,
|
|
175
|
+
tree_sha=submodule_tree,
|
|
176
|
+
submodule_info=nested_submodule_info,
|
|
177
|
+
submodule_repos=nested_submodule_repos,
|
|
178
|
+
verbose=verbose,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"mode": TREE_MODE,
|
|
183
|
+
"type": "tree",
|
|
184
|
+
"sha": expanded_sha,
|
|
185
|
+
"name": submodule_path.split("/")[-1],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
def copy_object(source_repo: Path, output_repo: Path, sha: str) -> None:
|
|
191
|
+
"""Copy a git object from source to output repository if it doesn't exist."""
|
|
192
|
+
if object_exists(output_repo, sha):
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
# Read object from source and write to output
|
|
196
|
+
try:
|
|
197
|
+
data = read_blob(source_repo, sha)
|
|
198
|
+
hash_object(output_repo, data, "blob")
|
|
199
|
+
except Exception:
|
|
200
|
+
# Object might be a tree or commit, which we handle differently
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
205
|
+
def copy_tree_objects(source_repo: Path, output_repo: Path, tree_sha: str) -> None:
|
|
206
|
+
"""Recursively copy all objects in a tree from source to output repository."""
|
|
207
|
+
entries = ls_tree(source_repo, tree_sha)
|
|
208
|
+
|
|
209
|
+
for entry in entries:
|
|
210
|
+
if entry["type"] == "blob":
|
|
211
|
+
copy_object(source_repo, output_repo, entry["sha"])
|
|
212
|
+
elif entry["type"] == "tree":
|
|
213
|
+
copy_tree_objects(source_repo, output_repo, entry["sha"])
|
git_rewrite/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# remap/__init__.py
|
|
3
|
+
# ──────────────────
|
|
4
|
+
#
|
|
5
|
+
# Remap subcommand - remap submodule commit references.
|
|
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 .remapper import remap_repository
|
|
19
|
+
from .run import run
|
|
20
|
+
|
|
21
|
+
__all__ = ["run", "remap_repository"]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# remapper.py
|
|
3
|
+
# ───────────
|
|
4
|
+
#
|
|
5
|
+
# Core history rewriting logic for submodule remapping.
|
|
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
|
+
import subprocess
|
|
24
|
+
from typing import TYPE_CHECKING
|
|
25
|
+
from ..common import GITLINK_MODE
|
|
26
|
+
from ..common import get_all_branches
|
|
27
|
+
from ..common import git
|
|
28
|
+
from ..common import init_repo
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Constants
|
|
35
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
M_SHA_PATTERN = re.compile(r"^M (\d+) ([0-9a-f]{40}) (.+)$")
|
|
38
|
+
M_ANY_PATTERN = re.compile(r"^M (\d+) (\S+) (.+)$")
|
|
39
|
+
|
|
40
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
# Mapping Loading
|
|
42
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
def load_mapping_file(filepath: Path) -> dict[str, str]:
|
|
47
|
+
"""
|
|
48
|
+
Load commit mapping from a file.
|
|
49
|
+
|
|
50
|
+
Format: old_sha new_sha (one per line, as output by sanitise)
|
|
51
|
+
"""
|
|
52
|
+
mapping: dict[str, str] = {}
|
|
53
|
+
with filepath.open(encoding="utf-8") as f:
|
|
54
|
+
for line in f:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
parts = line.split()
|
|
59
|
+
if len(parts) >= 2:
|
|
60
|
+
old_sha = parts[0]
|
|
61
|
+
new_sha = parts[1]
|
|
62
|
+
mapping[old_sha] = new_sha
|
|
63
|
+
return mapping
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
# Main Remap Function
|
|
68
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def remap_repository(
|
|
72
|
+
source_repo: Path,
|
|
73
|
+
output_repo: Path,
|
|
74
|
+
mapping_file: Path,
|
|
75
|
+
submodule_path: str | None = None,
|
|
76
|
+
url_rewrites: dict[str, str] | None = None,
|
|
77
|
+
verbose: bool = False,
|
|
78
|
+
) -> dict[str, int]:
|
|
79
|
+
"""
|
|
80
|
+
Remap submodule commit references in repository history.
|
|
81
|
+
|
|
82
|
+
Returns statistics about the remapping operation.
|
|
83
|
+
"""
|
|
84
|
+
if url_rewrites is None:
|
|
85
|
+
url_rewrites = {}
|
|
86
|
+
|
|
87
|
+
print(f"Source: {source_repo}")
|
|
88
|
+
print(f"Output: {output_repo}")
|
|
89
|
+
if submodule_path:
|
|
90
|
+
print(f"Submodule path: {submodule_path}")
|
|
91
|
+
else:
|
|
92
|
+
print("Submodule path: (all matching gitlinks)")
|
|
93
|
+
if url_rewrites:
|
|
94
|
+
print(f"URL rewrites: {len(url_rewrites)}")
|
|
95
|
+
for old_url, new_url in url_rewrites.items():
|
|
96
|
+
print(f" {old_url} -> {new_url}")
|
|
97
|
+
|
|
98
|
+
# Load mapping
|
|
99
|
+
print(f"Loading mapping from {mapping_file}...")
|
|
100
|
+
mapping = load_mapping_file(mapping_file)
|
|
101
|
+
print(f"Loaded {len(mapping)} commit mappings")
|
|
102
|
+
|
|
103
|
+
# Initialise output repository
|
|
104
|
+
print("Initialising output repository...")
|
|
105
|
+
init_repo(output_repo)
|
|
106
|
+
|
|
107
|
+
# Run the remapping
|
|
108
|
+
print("Processing history...")
|
|
109
|
+
stats = remap_history(
|
|
110
|
+
source_repo, output_repo, mapping, submodule_path, url_rewrites, verbose
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
print("Done!")
|
|
114
|
+
print(f"Processed {stats['commits']} commits")
|
|
115
|
+
print(f"Remapped {stats['remapped']} gitlink references")
|
|
116
|
+
if stats["unmapped"] > 0:
|
|
117
|
+
print(f"Warning: {stats['unmapped']} gitlinks had no mapping (kept original)")
|
|
118
|
+
if stats["gitmodules_updated"] > 0:
|
|
119
|
+
print(f"Updated .gitmodules URL in {stats['gitmodules_updated']} commits")
|
|
120
|
+
|
|
121
|
+
return stats
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
def remap_history(
|
|
126
|
+
source_repo: Path,
|
|
127
|
+
output_repo: Path,
|
|
128
|
+
mapping: dict[str, str],
|
|
129
|
+
submodule_path: str | None,
|
|
130
|
+
url_rewrites: dict[str, str],
|
|
131
|
+
verbose: bool,
|
|
132
|
+
) -> dict[str, int]:
|
|
133
|
+
"""Process history through fast-export/fast-import, remapping gitlinks."""
|
|
134
|
+
|
|
135
|
+
# Start fast-export
|
|
136
|
+
export_cmd = [
|
|
137
|
+
"git",
|
|
138
|
+
"-C",
|
|
139
|
+
str(source_repo),
|
|
140
|
+
"fast-export",
|
|
141
|
+
"--all",
|
|
142
|
+
"--signed-tags=strip",
|
|
143
|
+
"--tag-of-filtered-object=rewrite",
|
|
144
|
+
]
|
|
145
|
+
export_proc = subprocess.Popen(
|
|
146
|
+
export_cmd,
|
|
147
|
+
stdout=subprocess.PIPE,
|
|
148
|
+
stderr=subprocess.PIPE,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Start fast-import
|
|
152
|
+
import_cmd = [
|
|
153
|
+
"git",
|
|
154
|
+
"-C",
|
|
155
|
+
str(output_repo),
|
|
156
|
+
"fast-import",
|
|
157
|
+
"--quiet",
|
|
158
|
+
]
|
|
159
|
+
import_proc = subprocess.Popen(
|
|
160
|
+
import_cmd,
|
|
161
|
+
stdin=subprocess.PIPE,
|
|
162
|
+
stdout=subprocess.PIPE,
|
|
163
|
+
stderr=subprocess.PIPE,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assert export_proc.stdout is not None
|
|
167
|
+
assert import_proc.stdin is not None
|
|
168
|
+
|
|
169
|
+
# Statistics
|
|
170
|
+
stats = {
|
|
171
|
+
"commits": 0,
|
|
172
|
+
"remapped": 0,
|
|
173
|
+
"unmapped": 0,
|
|
174
|
+
"gitmodules_updated": 0,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# State tracking
|
|
178
|
+
in_data = False
|
|
179
|
+
in_blob_data = False
|
|
180
|
+
data_remaining = 0
|
|
181
|
+
current_blob_mark: str | None = None
|
|
182
|
+
current_blob_data = b""
|
|
183
|
+
blob_contents: dict[str, bytes] = {} # mark -> content
|
|
184
|
+
|
|
185
|
+
# Helper to write to import
|
|
186
|
+
def write_import(data: bytes) -> None:
|
|
187
|
+
assert import_proc.stdin is not None
|
|
188
|
+
try:
|
|
189
|
+
import_proc.stdin.write(data)
|
|
190
|
+
except BrokenPipeError as e:
|
|
191
|
+
import_proc.wait()
|
|
192
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
193
|
+
raise RuntimeError(f"fast-import failed: {stderr}") from e
|
|
194
|
+
|
|
195
|
+
# Process the stream
|
|
196
|
+
while True:
|
|
197
|
+
line = export_proc.stdout.readline()
|
|
198
|
+
if not line:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Handle blob data - capture content while passing through
|
|
202
|
+
if in_blob_data:
|
|
203
|
+
current_blob_data += line
|
|
204
|
+
write_import(line)
|
|
205
|
+
data_remaining -= len(line)
|
|
206
|
+
if data_remaining <= 0:
|
|
207
|
+
in_blob_data = False
|
|
208
|
+
if current_blob_mark:
|
|
209
|
+
blob_contents[current_blob_mark] = current_blob_data
|
|
210
|
+
current_blob_data = b""
|
|
211
|
+
continue
|
|
212
|
+
|
|
213
|
+
# Handle regular data blocks (commit messages, etc.)
|
|
214
|
+
if in_data:
|
|
215
|
+
write_import(line)
|
|
216
|
+
data_remaining -= len(line)
|
|
217
|
+
if data_remaining <= 0:
|
|
218
|
+
in_data = False
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
line_str = line.decode("utf-8", errors="replace")
|
|
222
|
+
|
|
223
|
+
# Check for blob start
|
|
224
|
+
if line_str.strip() == "blob":
|
|
225
|
+
current_blob_mark = None
|
|
226
|
+
current_blob_data = b""
|
|
227
|
+
write_import(line)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
# Check for mark (track for blob content)
|
|
231
|
+
if line_str.startswith("mark :"):
|
|
232
|
+
current_blob_mark = line_str.strip().split()[1] # e.g., ":3"
|
|
233
|
+
write_import(line)
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Check for data command
|
|
237
|
+
if line_str.startswith("data "):
|
|
238
|
+
data_size = int(line_str[5:].strip())
|
|
239
|
+
data_remaining = data_size
|
|
240
|
+
write_import(line)
|
|
241
|
+
|
|
242
|
+
# If we're tracking a blob, capture its data
|
|
243
|
+
if current_blob_mark is not None:
|
|
244
|
+
in_blob_data = data_size > 0
|
|
245
|
+
else:
|
|
246
|
+
in_data = data_size > 0
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
# Check for commit
|
|
250
|
+
if line_str.startswith("commit "):
|
|
251
|
+
stats["commits"] += 1
|
|
252
|
+
current_blob_mark = None # Reset blob tracking
|
|
253
|
+
if stats["commits"] % 50 == 0:
|
|
254
|
+
print(f" Processing commit {stats['commits']}...", flush=True)
|
|
255
|
+
write_import(line)
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Check for file modification with SHA (gitlinks)
|
|
259
|
+
m_match = M_SHA_PATTERN.match(line_str)
|
|
260
|
+
if m_match:
|
|
261
|
+
mode = m_match.group(1)
|
|
262
|
+
sha = m_match.group(2)
|
|
263
|
+
filepath = m_match.group(3)
|
|
264
|
+
|
|
265
|
+
# Check if this is a gitlink we should remap
|
|
266
|
+
if mode == GITLINK_MODE and (
|
|
267
|
+
submodule_path is None or filepath == submodule_path
|
|
268
|
+
):
|
|
269
|
+
if sha in mapping:
|
|
270
|
+
new_sha = mapping[sha]
|
|
271
|
+
write_import(f"M {mode} {new_sha} {filepath}\n".encode())
|
|
272
|
+
stats["remapped"] += 1
|
|
273
|
+
if verbose:
|
|
274
|
+
print(f" Remapped: {sha[:8]} -> {new_sha[:8]}")
|
|
275
|
+
else:
|
|
276
|
+
write_import(line)
|
|
277
|
+
stats["unmapped"] += 1
|
|
278
|
+
if verbose:
|
|
279
|
+
print(f" Warning: No mapping for {sha[:8]}")
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
write_import(line)
|
|
283
|
+
continue
|
|
284
|
+
|
|
285
|
+
# Check for file modification with mark (for .gitmodules)
|
|
286
|
+
m_any_match = M_ANY_PATTERN.match(line_str)
|
|
287
|
+
if m_any_match:
|
|
288
|
+
mode = m_any_match.group(1)
|
|
289
|
+
ref = m_any_match.group(2)
|
|
290
|
+
filepath = m_any_match.group(3)
|
|
291
|
+
|
|
292
|
+
# Check if this is .gitmodules - rewrite with modified content
|
|
293
|
+
if filepath == ".gitmodules" and url_rewrites and ref.startswith(":"):
|
|
294
|
+
if ref in blob_contents:
|
|
295
|
+
original = blob_contents[ref]
|
|
296
|
+
modified = modify_gitmodules(original, url_rewrites)
|
|
297
|
+
if modified != original:
|
|
298
|
+
stats["gitmodules_updated"] += 1
|
|
299
|
+
# Emit as inline data with modified content
|
|
300
|
+
write_import(f"M {mode} inline {filepath}\n".encode())
|
|
301
|
+
write_import(f"data {len(modified)}\n".encode())
|
|
302
|
+
write_import(modified)
|
|
303
|
+
continue
|
|
304
|
+
write_import(line)
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
# Pass through everything else
|
|
308
|
+
write_import(line)
|
|
309
|
+
|
|
310
|
+
# Close streams
|
|
311
|
+
import_proc.stdin.close()
|
|
312
|
+
export_proc.wait()
|
|
313
|
+
import_proc.wait()
|
|
314
|
+
|
|
315
|
+
if export_proc.returncode != 0:
|
|
316
|
+
stderr = export_proc.stderr.read().decode() if export_proc.stderr else ""
|
|
317
|
+
raise RuntimeError(f"fast-export failed: {stderr}")
|
|
318
|
+
|
|
319
|
+
if import_proc.returncode != 0:
|
|
320
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
321
|
+
raise RuntimeError(f"fast-import failed: {stderr}")
|
|
322
|
+
|
|
323
|
+
# Checkout the default branch to clean up the working directory
|
|
324
|
+
# fast-import leaves things in a detached HEAD state with staged files
|
|
325
|
+
branches = get_all_branches(output_repo)
|
|
326
|
+
if branches:
|
|
327
|
+
# Prefer main, then master, then first available
|
|
328
|
+
if "main" in branches:
|
|
329
|
+
default_branch = "main"
|
|
330
|
+
elif "master" in branches:
|
|
331
|
+
default_branch = "master"
|
|
332
|
+
else:
|
|
333
|
+
default_branch = branches[0]
|
|
334
|
+
git(output_repo, "checkout", "-f", default_branch)
|
|
335
|
+
|
|
336
|
+
return stats
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
340
|
+
def modify_gitmodules(content: bytes, url_rewrites: dict[str, str]) -> bytes:
|
|
341
|
+
"""Modify .gitmodules content to replace URLs based on url_rewrites mapping."""
|
|
342
|
+
try:
|
|
343
|
+
text = content.decode("utf-8")
|
|
344
|
+
except UnicodeDecodeError:
|
|
345
|
+
return content
|
|
346
|
+
|
|
347
|
+
lines = text.split("\n")
|
|
348
|
+
result_lines: list[str] = []
|
|
349
|
+
|
|
350
|
+
for line in lines:
|
|
351
|
+
# Replace matching URL lines
|
|
352
|
+
if line.strip().startswith("url = "):
|
|
353
|
+
current_url = line.strip()[6:].strip()
|
|
354
|
+
if current_url in url_rewrites:
|
|
355
|
+
result_lines.append(f"\turl = {url_rewrites[current_url]}")
|
|
356
|
+
else:
|
|
357
|
+
result_lines.append(line)
|
|
358
|
+
else:
|
|
359
|
+
result_lines.append(line)
|
|
360
|
+
|
|
361
|
+
return "\n".join(result_lines).encode("utf-8")
|
git_rewrite/remap/run.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# remap/run.py
|
|
3
|
+
# ────────────
|
|
4
|
+
#
|
|
5
|
+
# CLI runner for the remap 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 .remapper import remap_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 remap subcommand."""
|
|
39
|
+
# Validate paths
|
|
40
|
+
if not args.repo.exists():
|
|
41
|
+
print(f"Error: Source repository not found: {args.repo}", file=sys.stderr)
|
|
42
|
+
return 1
|
|
43
|
+
if not args.commit_map.exists():
|
|
44
|
+
print(f"Error: Mapping file not found: {args.commit_map}", file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
|
|
47
|
+
# Handle output directory
|
|
48
|
+
if args.output.exists():
|
|
49
|
+
if args.delete:
|
|
50
|
+
print(f"Removing existing output directory: {args.output}")
|
|
51
|
+
shutil.rmtree(args.output)
|
|
52
|
+
else:
|
|
53
|
+
print(
|
|
54
|
+
f"Error: Output directory already exists: {args.output}",
|
|
55
|
+
file=sys.stderr,
|
|
56
|
+
)
|
|
57
|
+
print("Use --delete to remove it first", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
# Build URL rewrite mapping
|
|
61
|
+
url_rewrites: dict[str, str] = {}
|
|
62
|
+
if args.url_rewrite:
|
|
63
|
+
for old_url, new_url in args.url_rewrite:
|
|
64
|
+
url_rewrites[old_url] = new_url
|
|
65
|
+
|
|
66
|
+
remap_repository(
|
|
67
|
+
source_repo=args.repo,
|
|
68
|
+
output_repo=args.output,
|
|
69
|
+
mapping_file=args.commit_map,
|
|
70
|
+
submodule_path=args.submodule_path,
|
|
71
|
+
url_rewrites=url_rewrites,
|
|
72
|
+
verbose=args.verbose,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Copy remotes if requested
|
|
76
|
+
if args.copy_remotes:
|
|
77
|
+
print("Copying remotes from source repository...")
|
|
78
|
+
count = copy_remotes(args.repo, args.output, verbose=args.verbose)
|
|
79
|
+
if count > 0:
|
|
80
|
+
print(f" Copied {count} remote(s)")
|
|
81
|
+
else:
|
|
82
|
+
print(" No remotes found in source repository")
|
|
83
|
+
|
|
84
|
+
print(f"\nOutput repository created at: {args.output}")
|
|
85
|
+
return 0
|