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