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,524 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# rewriter.py
|
|
3
|
+
# ───────────
|
|
4
|
+
#
|
|
5
|
+
# Fast history rewriting using git fast-export/fast-import to flatten submodules.
|
|
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 tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
from ..common import GITLINK_MODE
|
|
27
|
+
from ..common import SubmoduleInfo
|
|
28
|
+
from ..common import clone_repo
|
|
29
|
+
from ..common import get_all_branches
|
|
30
|
+
from ..common import get_all_commits
|
|
31
|
+
from ..common import git
|
|
32
|
+
from ..common import init_repo
|
|
33
|
+
from ..common import object_exists
|
|
34
|
+
from ..common import read_blob
|
|
35
|
+
from ..common import repo as git_repo
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from collections.abc import Callable
|
|
39
|
+
|
|
40
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
# Main Flatten Function
|
|
42
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def flatten_repository(
|
|
46
|
+
source_repo: Path,
|
|
47
|
+
output_repo: Path,
|
|
48
|
+
mapping_file: Path,
|
|
49
|
+
verbose: bool = False,
|
|
50
|
+
) -> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Rewrite repository history using fast-export/fast-import to flatten submodules.
|
|
53
|
+
|
|
54
|
+
Returns a mapping of old commit SHAs to new commit SHAs.
|
|
55
|
+
"""
|
|
56
|
+
print(f"Source: {source_repo}")
|
|
57
|
+
print(f"Output: {output_repo}")
|
|
58
|
+
|
|
59
|
+
# Initialise output repository
|
|
60
|
+
print("Initialising output repository...")
|
|
61
|
+
init_repo(output_repo)
|
|
62
|
+
|
|
63
|
+
# Collect submodule info
|
|
64
|
+
print("Scanning for submodules...")
|
|
65
|
+
all_submodules = collect_submodules_from_gitmodules(source_repo)
|
|
66
|
+
print(f"Found {len(all_submodules)} unique submodule paths")
|
|
67
|
+
|
|
68
|
+
# Clone submodule repos if needed
|
|
69
|
+
submodule_repos: dict[str, Path] = {}
|
|
70
|
+
if all_submodules:
|
|
71
|
+
print("Cloning submodule repositories...")
|
|
72
|
+
submodule_repos = clone_submodules_fast(source_repo, all_submodules, verbose)
|
|
73
|
+
|
|
74
|
+
# Export, transform, and import
|
|
75
|
+
print("Exporting and transforming history...")
|
|
76
|
+
commit_map = export_transform_import(
|
|
77
|
+
source_repo, output_repo, submodule_repos, verbose
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Write mapping file (text format: old_sha new_sha per line)
|
|
81
|
+
print(f"Writing mapping to {mapping_file}...")
|
|
82
|
+
mapping_file.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
with open(mapping_file, "w", encoding="utf-8") as f:
|
|
84
|
+
for old_sha, new_sha in sorted(commit_map.items()):
|
|
85
|
+
f.write(f"{old_sha} {new_sha}\n")
|
|
86
|
+
|
|
87
|
+
print("Done!")
|
|
88
|
+
return commit_map
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
def collect_submodules_from_gitmodules(source_repo: Path) -> dict[str, SubmoduleInfo]:
|
|
93
|
+
"""Collect submodule info by finding all commits that touched .gitmodules."""
|
|
94
|
+
all_submodules: dict[str, SubmoduleInfo] = {}
|
|
95
|
+
|
|
96
|
+
# Find all commits that modified .gitmodules (much faster than checking every commit)
|
|
97
|
+
try:
|
|
98
|
+
output = git(source_repo, "log", "--all", "--format=%H", "--", ".gitmodules")
|
|
99
|
+
commits_with_gitmodules = [
|
|
100
|
+
line.strip() for line in output.strip().split("\n") if line.strip()
|
|
101
|
+
]
|
|
102
|
+
except Exception:
|
|
103
|
+
commits_with_gitmodules = []
|
|
104
|
+
|
|
105
|
+
# Also check all branch tips in case .gitmodules exists but was never modified
|
|
106
|
+
refs_to_check = ["HEAD"]
|
|
107
|
+
refs_to_check.extend(get_all_branches(source_repo))
|
|
108
|
+
for ref in refs_to_check:
|
|
109
|
+
try:
|
|
110
|
+
commit = git(source_repo, "rev-parse", ref).strip()
|
|
111
|
+
if commit not in commits_with_gitmodules:
|
|
112
|
+
commits_with_gitmodules.append(commit)
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
print(f" Checking {len(commits_with_gitmodules)} commits with .gitmodules...")
|
|
117
|
+
|
|
118
|
+
for commit in commits_with_gitmodules:
|
|
119
|
+
try:
|
|
120
|
+
submodules = git_repo.get_gitmodules_at_commit(source_repo, commit)
|
|
121
|
+
for path, info in submodules.items():
|
|
122
|
+
if path not in all_submodules:
|
|
123
|
+
all_submodules[path] = info
|
|
124
|
+
print(f" Found submodule: {path} -> {info['url']}")
|
|
125
|
+
except Exception:
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
return all_submodules
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
132
|
+
def clone_submodules_fast(
|
|
133
|
+
source_repo: Path,
|
|
134
|
+
submodules: dict[str, SubmoduleInfo],
|
|
135
|
+
verbose: bool,
|
|
136
|
+
) -> dict[str, Path]:
|
|
137
|
+
"""Clone submodule repositories, or use existing checkouts if available."""
|
|
138
|
+
submodule_repos: dict[str, Path] = {}
|
|
139
|
+
temp_dir = Path(tempfile.mkdtemp(prefix="git-rewrite-flatten-"))
|
|
140
|
+
|
|
141
|
+
for path, info in submodules.items():
|
|
142
|
+
# First, check if submodule is already checked out in source repo
|
|
143
|
+
existing_path = source_repo / path
|
|
144
|
+
if existing_path.exists() and (existing_path / ".git").exists():
|
|
145
|
+
print(f" Using existing checkout: {path}")
|
|
146
|
+
submodule_repos[path] = existing_path
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
# Also check for .git file (submodule worktree reference)
|
|
150
|
+
git_file = existing_path / ".git"
|
|
151
|
+
if existing_path.exists() and git_file.exists():
|
|
152
|
+
print(f" Using existing checkout: {path}")
|
|
153
|
+
submodule_repos[path] = existing_path
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
# Try to clone
|
|
157
|
+
clone_path = temp_dir / path.replace("/", "_")
|
|
158
|
+
url = info["url"]
|
|
159
|
+
|
|
160
|
+
# Resolve relative URLs against source repo's remote
|
|
161
|
+
if url.startswith("../") or url.startswith("./"):
|
|
162
|
+
url = resolve_relative_url(source_repo, url)
|
|
163
|
+
if verbose:
|
|
164
|
+
print(f" Resolved URL: {url}")
|
|
165
|
+
|
|
166
|
+
if verbose:
|
|
167
|
+
print(f" Cloning {path} from {url}...")
|
|
168
|
+
else:
|
|
169
|
+
print(f" Cloning {path}...")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
clone_repo(url, clone_path)
|
|
173
|
+
submodule_repos[path] = clone_path
|
|
174
|
+
except Exception as e:
|
|
175
|
+
print(f" Warning: Failed to clone {path}: {e}")
|
|
176
|
+
print(f" URL: {url}")
|
|
177
|
+
print(" Try: git submodule update --init in the source repo first")
|
|
178
|
+
|
|
179
|
+
return submodule_repos
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
183
|
+
def resolve_relative_url(source_repo: Path, relative_url: str) -> str:
|
|
184
|
+
"""Resolve a relative submodule URL against the source repo's remote."""
|
|
185
|
+
try:
|
|
186
|
+
# Get the origin remote URL
|
|
187
|
+
remote_url = git(source_repo, "remote", "get-url", "origin").strip()
|
|
188
|
+
|
|
189
|
+
# Handle SSH URLs like git@host:path/to/repo.git
|
|
190
|
+
if "@" in remote_url and ":" in remote_url and "://" not in remote_url:
|
|
191
|
+
# SSH URL format: git@host:path/to/repo.git
|
|
192
|
+
host_part, path_part = remote_url.split(":", 1)
|
|
193
|
+
|
|
194
|
+
# Remove .git suffix for path manipulation
|
|
195
|
+
if path_part.endswith(".git"):
|
|
196
|
+
path_part = path_part[:-4]
|
|
197
|
+
|
|
198
|
+
# Split path and apply relative navigation
|
|
199
|
+
path_parts = path_part.split("/")
|
|
200
|
+
|
|
201
|
+
for part in relative_url.split("/"):
|
|
202
|
+
if part == "..":
|
|
203
|
+
if path_parts:
|
|
204
|
+
path_parts.pop()
|
|
205
|
+
elif part != ".":
|
|
206
|
+
path_parts.append(part)
|
|
207
|
+
|
|
208
|
+
resolved_path = "/".join(path_parts)
|
|
209
|
+
return f"{host_part}:{resolved_path}"
|
|
210
|
+
|
|
211
|
+
# Handle HTTPS URLs like https://host/path/to/repo.git
|
|
212
|
+
if remote_url.endswith(".git"):
|
|
213
|
+
base = remote_url[:-4]
|
|
214
|
+
else:
|
|
215
|
+
base = remote_url
|
|
216
|
+
|
|
217
|
+
# Remove the last path component and apply relative path
|
|
218
|
+
parts = relative_url.split("/")
|
|
219
|
+
base_parts = base.rstrip("/").split("/")
|
|
220
|
+
|
|
221
|
+
for part in parts:
|
|
222
|
+
if part == "..":
|
|
223
|
+
base_parts = base_parts[:-1]
|
|
224
|
+
elif part != ".":
|
|
225
|
+
base_parts.append(part)
|
|
226
|
+
|
|
227
|
+
resolved = "/".join(base_parts)
|
|
228
|
+
if not resolved.endswith(".git") and relative_url.endswith(".git"):
|
|
229
|
+
resolved += ".git"
|
|
230
|
+
|
|
231
|
+
return resolved
|
|
232
|
+
except Exception:
|
|
233
|
+
# Fall back to original URL if resolution fails
|
|
234
|
+
return relative_url
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
238
|
+
def export_transform_import(
|
|
239
|
+
source_repo: Path,
|
|
240
|
+
output_repo: Path,
|
|
241
|
+
submodule_repos: dict[str, Path],
|
|
242
|
+
verbose: bool,
|
|
243
|
+
) -> dict[str, str]:
|
|
244
|
+
"""Export from source, transform to expand submodules, import to output."""
|
|
245
|
+
|
|
246
|
+
# Start fast-export
|
|
247
|
+
export_cmd = [
|
|
248
|
+
"git",
|
|
249
|
+
"-C",
|
|
250
|
+
str(source_repo),
|
|
251
|
+
"fast-export",
|
|
252
|
+
"--all",
|
|
253
|
+
"--signed-tags=strip",
|
|
254
|
+
"--tag-of-filtered-object=rewrite",
|
|
255
|
+
]
|
|
256
|
+
export_proc = subprocess.Popen(
|
|
257
|
+
export_cmd,
|
|
258
|
+
stdout=subprocess.PIPE,
|
|
259
|
+
stderr=subprocess.PIPE,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Start fast-import
|
|
263
|
+
import_cmd = [
|
|
264
|
+
"git",
|
|
265
|
+
"-C",
|
|
266
|
+
str(output_repo),
|
|
267
|
+
"fast-import",
|
|
268
|
+
"--quiet",
|
|
269
|
+
]
|
|
270
|
+
import_proc = subprocess.Popen(
|
|
271
|
+
import_cmd,
|
|
272
|
+
stdin=subprocess.PIPE,
|
|
273
|
+
stdout=subprocess.PIPE,
|
|
274
|
+
stderr=subprocess.PIPE,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
assert export_proc.stdout is not None
|
|
278
|
+
assert import_proc.stdin is not None
|
|
279
|
+
|
|
280
|
+
# Track marks to commits for the mapping
|
|
281
|
+
mark_to_original_commit: dict[int, str] = {}
|
|
282
|
+
current_commit_sha: str | None = None
|
|
283
|
+
current_mark: int | None = None
|
|
284
|
+
next_mark = 1000000 # Start high to avoid conflicts
|
|
285
|
+
commit_count = 0
|
|
286
|
+
in_commit = False
|
|
287
|
+
in_data = False
|
|
288
|
+
data_remaining = 0
|
|
289
|
+
|
|
290
|
+
# Regex patterns
|
|
291
|
+
import re
|
|
292
|
+
|
|
293
|
+
mark_pattern = re.compile(r"^mark :(\d+)$")
|
|
294
|
+
commit_pattern = re.compile(r"^commit (.+)$")
|
|
295
|
+
m_pattern = re.compile(r"^M (\d+) (?::(\d+)|([0-9a-f]{40})|inline) (.+)$")
|
|
296
|
+
skip_next_data = False
|
|
297
|
+
|
|
298
|
+
# Helper to write to import and check for errors
|
|
299
|
+
def write_import(data: bytes) -> None:
|
|
300
|
+
assert import_proc.stdin is not None
|
|
301
|
+
try:
|
|
302
|
+
import_proc.stdin.write(data)
|
|
303
|
+
except BrokenPipeError as e:
|
|
304
|
+
# fast-import died - get the error message
|
|
305
|
+
import_proc.wait()
|
|
306
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
307
|
+
raise RuntimeError(f"fast-import failed: {stderr}") from e
|
|
308
|
+
|
|
309
|
+
# Process the stream
|
|
310
|
+
while True:
|
|
311
|
+
line = export_proc.stdout.readline()
|
|
312
|
+
if not line:
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
# Handle data blocks (pass through unchanged, unless skipping)
|
|
316
|
+
if in_data:
|
|
317
|
+
if not skip_next_data:
|
|
318
|
+
write_import(line)
|
|
319
|
+
data_remaining -= len(line)
|
|
320
|
+
if data_remaining <= 0:
|
|
321
|
+
in_data = False
|
|
322
|
+
skip_next_data = False
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
line_str = line.decode("utf-8", errors="replace")
|
|
326
|
+
|
|
327
|
+
# Check for data command
|
|
328
|
+
if line_str.startswith("data "):
|
|
329
|
+
data_size = int(line_str[5:].strip())
|
|
330
|
+
data_remaining = data_size
|
|
331
|
+
in_data = data_size > 0
|
|
332
|
+
if not skip_next_data:
|
|
333
|
+
write_import(line)
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# Check for commit
|
|
337
|
+
commit_match = commit_pattern.match(line_str)
|
|
338
|
+
if commit_match:
|
|
339
|
+
in_commit = True
|
|
340
|
+
commit_count += 1
|
|
341
|
+
if commit_count % 50 == 0:
|
|
342
|
+
print(f" Processing commit {commit_count}...", flush=True)
|
|
343
|
+
write_import(line)
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# Track mark to commit mapping
|
|
347
|
+
mark_match = mark_pattern.match(line_str)
|
|
348
|
+
if mark_match and in_commit:
|
|
349
|
+
current_mark = int(mark_match.group(1))
|
|
350
|
+
write_import(line)
|
|
351
|
+
continue
|
|
352
|
+
|
|
353
|
+
# Check for original-oid (gives us the original SHA)
|
|
354
|
+
if line_str.startswith("original-oid "):
|
|
355
|
+
current_commit_sha = line_str[13:].strip()
|
|
356
|
+
if current_mark is not None:
|
|
357
|
+
mark_to_original_commit[current_mark] = current_commit_sha
|
|
358
|
+
write_import(line)
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Check for file modification with gitlink (submodule)
|
|
362
|
+
m_match = m_pattern.match(line_str)
|
|
363
|
+
if m_match:
|
|
364
|
+
mode = m_match.group(1)
|
|
365
|
+
_mark_ref = m_match.group(2)
|
|
366
|
+
sha_ref = m_match.group(3)
|
|
367
|
+
filepath = m_match.group(4)
|
|
368
|
+
|
|
369
|
+
# Skip .gitmodules - no longer needed after flattening
|
|
370
|
+
if filepath == ".gitmodules":
|
|
371
|
+
# If this is inline data, we need to skip the following data block
|
|
372
|
+
if "inline" in line_str:
|
|
373
|
+
skip_next_data = True
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
if mode == GITLINK_MODE:
|
|
377
|
+
# This is a submodule - expand it
|
|
378
|
+
submodule_sha = sha_ref # Gitlinks always have SHA, not mark
|
|
379
|
+
next_mark = expand_submodule_to_stream(
|
|
380
|
+
submodule_repos,
|
|
381
|
+
filepath,
|
|
382
|
+
submodule_sha,
|
|
383
|
+
next_mark,
|
|
384
|
+
verbose,
|
|
385
|
+
write_import,
|
|
386
|
+
)
|
|
387
|
+
else:
|
|
388
|
+
# Regular file - pass through
|
|
389
|
+
write_import(line)
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
# Reset state at end of commit
|
|
393
|
+
if line_str.strip() == "" and in_commit:
|
|
394
|
+
in_commit = False
|
|
395
|
+
current_commit_sha = None
|
|
396
|
+
current_mark = None
|
|
397
|
+
|
|
398
|
+
# Pass through everything else
|
|
399
|
+
write_import(line)
|
|
400
|
+
|
|
401
|
+
# Close streams
|
|
402
|
+
import_proc.stdin.close()
|
|
403
|
+
export_proc.wait()
|
|
404
|
+
import_proc.wait()
|
|
405
|
+
|
|
406
|
+
if export_proc.returncode != 0:
|
|
407
|
+
stderr = export_proc.stderr.read().decode() if export_proc.stderr else ""
|
|
408
|
+
raise RuntimeError(f"fast-export failed: {stderr}")
|
|
409
|
+
|
|
410
|
+
if import_proc.returncode != 0:
|
|
411
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
412
|
+
raise RuntimeError(f"fast-import failed: {stderr}")
|
|
413
|
+
|
|
414
|
+
print(f" Processed {commit_count} commits")
|
|
415
|
+
|
|
416
|
+
# Checkout the default branch to clean up the working directory
|
|
417
|
+
# fast-import leaves things in a detached HEAD state with staged files
|
|
418
|
+
branches = get_all_branches(output_repo)
|
|
419
|
+
if branches:
|
|
420
|
+
default_branch = (
|
|
421
|
+
"main"
|
|
422
|
+
if "main" in branches
|
|
423
|
+
else ("master" if "master" in branches else branches[0])
|
|
424
|
+
)
|
|
425
|
+
git(output_repo, "checkout", "-f", default_branch)
|
|
426
|
+
|
|
427
|
+
# Build commit map by reading marks
|
|
428
|
+
return build_commit_map(source_repo, output_repo, mark_to_original_commit)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
432
|
+
def expand_submodule_to_stream(
|
|
433
|
+
submodule_repos: dict[str, Path],
|
|
434
|
+
submodule_path: str,
|
|
435
|
+
submodule_commit: str,
|
|
436
|
+
next_mark: int,
|
|
437
|
+
verbose: bool,
|
|
438
|
+
write_fn: Callable[[bytes], None],
|
|
439
|
+
) -> int:
|
|
440
|
+
"""Expand a submodule into file entries in the fast-import stream."""
|
|
441
|
+
if submodule_path not in submodule_repos:
|
|
442
|
+
if verbose:
|
|
443
|
+
print(f" Warning: No repo for submodule {submodule_path}")
|
|
444
|
+
return next_mark
|
|
445
|
+
|
|
446
|
+
submodule_repo = submodule_repos[submodule_path]
|
|
447
|
+
|
|
448
|
+
# Check if commit exists
|
|
449
|
+
if not object_exists(submodule_repo, submodule_commit):
|
|
450
|
+
if verbose:
|
|
451
|
+
print(f" Warning: Commit {submodule_commit[:8]} not in {submodule_path}")
|
|
452
|
+
return next_mark
|
|
453
|
+
|
|
454
|
+
# Get all files in the submodule at this commit
|
|
455
|
+
try:
|
|
456
|
+
files = get_files_recursive(submodule_repo, submodule_commit)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
if verbose:
|
|
459
|
+
print(f" Warning: Could not list files in {submodule_path}: {e}")
|
|
460
|
+
return next_mark
|
|
461
|
+
|
|
462
|
+
# Add each file to the stream using inline data format
|
|
463
|
+
# (can't use marks here because blobs must be defined before commits)
|
|
464
|
+
for file_mode, file_sha, file_path in files:
|
|
465
|
+
full_path = f"{submodule_path}/{file_path}"
|
|
466
|
+
|
|
467
|
+
# Get file content
|
|
468
|
+
try:
|
|
469
|
+
content = read_blob(submodule_repo, file_sha)
|
|
470
|
+
except Exception:
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
# Write file entry with inline data
|
|
474
|
+
write_fn(f"M {file_mode} inline {full_path}\n".encode())
|
|
475
|
+
write_fn(f"data {len(content)}\n".encode())
|
|
476
|
+
write_fn(content)
|
|
477
|
+
write_fn(b"\n")
|
|
478
|
+
|
|
479
|
+
return next_mark
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
483
|
+
def get_files_recursive(repo_path: Path, commit: str) -> list[tuple[str, str, str]]:
|
|
484
|
+
"""Get all files in a commit recursively. Returns (mode, sha, path) tuples."""
|
|
485
|
+
output = git(repo_path, "ls-tree", "-r", commit)
|
|
486
|
+
files: list[tuple[str, str, str]] = []
|
|
487
|
+
for line in output.strip().split("\n"):
|
|
488
|
+
if not line:
|
|
489
|
+
continue
|
|
490
|
+
# Format: <mode> <type> <sha>\t<path>
|
|
491
|
+
parts = line.split("\t", 1)
|
|
492
|
+
mode_type_sha = parts[0].split()
|
|
493
|
+
mode = mode_type_sha[0]
|
|
494
|
+
sha = mode_type_sha[2]
|
|
495
|
+
path = parts[1]
|
|
496
|
+
# Skip submodules within submodules for now
|
|
497
|
+
if mode != GITLINK_MODE:
|
|
498
|
+
files.append((mode, sha, path))
|
|
499
|
+
return files
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
503
|
+
def build_commit_map(
|
|
504
|
+
source_repo: Path,
|
|
505
|
+
output_repo: Path,
|
|
506
|
+
_mark_to_original: dict[int, str],
|
|
507
|
+
) -> dict[str, str]:
|
|
508
|
+
"""Build mapping from original commits to new commits."""
|
|
509
|
+
# Get all commits from both repos and match by tree content or message
|
|
510
|
+
# This is a simplified approach - for exact mapping we'd need to track marks better
|
|
511
|
+
|
|
512
|
+
commit_map: dict[str, str] = {}
|
|
513
|
+
|
|
514
|
+
# Get commits from source
|
|
515
|
+
source_commits = get_all_commits(source_repo)
|
|
516
|
+
|
|
517
|
+
# Get commits from output
|
|
518
|
+
output_commits = get_all_commits(output_repo)
|
|
519
|
+
|
|
520
|
+
# Match by position (since we preserve order)
|
|
521
|
+
for src, dst in zip(source_commits, output_commits, strict=False):
|
|
522
|
+
commit_map[src] = dst
|
|
523
|
+
|
|
524
|
+
return commit_map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# flatten/run.py
|
|
3
|
+
# ───────────────
|
|
4
|
+
#
|
|
5
|
+
# CLI runner for the flatten 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 .rewriter import collect_submodules_from_gitmodules
|
|
27
|
+
from .rewriter import flatten_repository
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import argparse
|
|
31
|
+
|
|
32
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
33
|
+
# Runner
|
|
34
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
38
|
+
def run(args: argparse.Namespace) -> int:
|
|
39
|
+
"""Run the flatten subcommand."""
|
|
40
|
+
# Validate paths
|
|
41
|
+
if not args.repo.exists():
|
|
42
|
+
print(f"Error: Source repository not found: {args.repo}", file=sys.stderr)
|
|
43
|
+
return 1
|
|
44
|
+
|
|
45
|
+
# Scan-only mode
|
|
46
|
+
if args.scan_only:
|
|
47
|
+
print("Scanning for submodules...")
|
|
48
|
+
submodules = collect_submodules_from_gitmodules(args.repo)
|
|
49
|
+
if submodules:
|
|
50
|
+
print(f"\nFound {len(submodules)} submodules:")
|
|
51
|
+
for path, info in submodules.items():
|
|
52
|
+
print(f" {path} -> {info['url']}")
|
|
53
|
+
else:
|
|
54
|
+
print("\nNo submodules found.")
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
# Require output for non-scan mode
|
|
58
|
+
if not args.output:
|
|
59
|
+
print("Error: --output is required (unless using --scan-only)", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
# Default mapping file
|
|
63
|
+
mapping_file = args.commit_map or (
|
|
64
|
+
args.output.parent / f"{args.output.name}-mapping.txt"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Handle output directory
|
|
68
|
+
if args.output.exists():
|
|
69
|
+
if args.delete:
|
|
70
|
+
print(f"Removing existing output directory: {args.output}")
|
|
71
|
+
shutil.rmtree(args.output)
|
|
72
|
+
else:
|
|
73
|
+
print(
|
|
74
|
+
f"Error: Output directory already exists: {args.output}",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
print("Use --delete to remove it first", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
flatten_repository(
|
|
81
|
+
source_repo=args.repo,
|
|
82
|
+
output_repo=args.output,
|
|
83
|
+
mapping_file=mapping_file,
|
|
84
|
+
verbose=args.verbose,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Copy remotes if requested
|
|
88
|
+
if args.copy_remotes:
|
|
89
|
+
print("Copying remotes from source repository...")
|
|
90
|
+
count = copy_remotes(args.repo, args.output, verbose=args.verbose)
|
|
91
|
+
if count > 0:
|
|
92
|
+
print(f" Copied {count} remote(s)")
|
|
93
|
+
else:
|
|
94
|
+
print(" No remotes found in source repository")
|
|
95
|
+
|
|
96
|
+
print(f"\nOutput repository created at: {args.output}")
|
|
97
|
+
print(f"Commit mapping written to: {mapping_file}")
|
|
98
|
+
return 0
|