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,353 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# repo.py
|
|
3
|
+
# ───────
|
|
4
|
+
#
|
|
5
|
+
# Git repository operations using plumbing commands.
|
|
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 configparser
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from .config import CommitInfo
|
|
30
|
+
from .config import SubmoduleInfo
|
|
31
|
+
from .config import TreeEntry
|
|
32
|
+
|
|
33
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
# Git Command Execution
|
|
35
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def git(
|
|
39
|
+
repo_path: Path,
|
|
40
|
+
*args: str,
|
|
41
|
+
input_data: str | None = None,
|
|
42
|
+
env: dict[str, str] | None = None,
|
|
43
|
+
check: bool = True,
|
|
44
|
+
) -> str:
|
|
45
|
+
"""Execute a git command in the given repository."""
|
|
46
|
+
cmd = ["git", "-C", str(repo_path), *args]
|
|
47
|
+
full_env = os.environ.copy()
|
|
48
|
+
if env:
|
|
49
|
+
full_env.update(env)
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
input=input_data,
|
|
55
|
+
env=full_env,
|
|
56
|
+
check=check,
|
|
57
|
+
)
|
|
58
|
+
if check and result.returncode != 0:
|
|
59
|
+
raise subprocess.CalledProcessError(
|
|
60
|
+
result.returncode, cmd, result.stdout, result.stderr
|
|
61
|
+
)
|
|
62
|
+
return result.stdout
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
def git_bytes(
|
|
67
|
+
repo_path: Path,
|
|
68
|
+
*args: str,
|
|
69
|
+
) -> bytes:
|
|
70
|
+
"""Execute a git command and return raw bytes."""
|
|
71
|
+
cmd = ["git", "-C", str(repo_path), *args]
|
|
72
|
+
result = subprocess.run(cmd, capture_output=True, check=True)
|
|
73
|
+
return result.stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
# Repository Operations
|
|
78
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def init_repo(path: Path) -> None:
|
|
82
|
+
"""Initialise a new git repository."""
|
|
83
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
subprocess.run(["git", "init", str(path)], check=True, capture_output=True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
def clone_repo(url: str, path: Path, bare: bool = False) -> None:
|
|
89
|
+
"""Clone a git repository."""
|
|
90
|
+
cmd = ["git", "clone"]
|
|
91
|
+
if bare:
|
|
92
|
+
cmd.append("--bare")
|
|
93
|
+
cmd.extend([url, str(path)])
|
|
94
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
def get_all_branches(repo_path: Path) -> list[str]:
|
|
99
|
+
"""Get all branch names in the repository."""
|
|
100
|
+
output = git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads/")
|
|
101
|
+
return [line.strip() for line in output.strip().split("\n") if line.strip()]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
105
|
+
def get_all_tags(repo_path: Path) -> list[str]:
|
|
106
|
+
"""Get all tag names in the repository."""
|
|
107
|
+
output = git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/tags/")
|
|
108
|
+
return [line.strip() for line in output.strip().split("\n") if line.strip()]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
112
|
+
def get_tag_commit(repo_path: Path, tag: str) -> str:
|
|
113
|
+
"""Get the commit SHA that a tag points to (dereferencing annotated tags)."""
|
|
114
|
+
return git(repo_path, "rev-parse", f"{tag}^{{commit}}").strip()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
118
|
+
def get_all_commits(repo_path: Path, branches: list[str] | None = None) -> list[str]:
|
|
119
|
+
"""Get all commits in topological order (parents before children)."""
|
|
120
|
+
args = ["rev-list", "--topo-order", "--reverse"]
|
|
121
|
+
if branches:
|
|
122
|
+
args.extend(branches)
|
|
123
|
+
else:
|
|
124
|
+
args.append("--all")
|
|
125
|
+
output = git(repo_path, *args)
|
|
126
|
+
return [line.strip() for line in output.strip().split("\n") if line.strip()]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
def get_commit_info(repo_path: Path, commit_sha: str) -> CommitInfo:
|
|
131
|
+
"""Get detailed information about a commit."""
|
|
132
|
+
# Get commit data in a parseable format
|
|
133
|
+
format_str = "%T%n%P%n%an%n%ae%n%ai%n%cn%n%ce%n%ci"
|
|
134
|
+
output = git(repo_path, "log", "-1", f"--format={format_str}", commit_sha)
|
|
135
|
+
lines = output.strip().split("\n")
|
|
136
|
+
|
|
137
|
+
tree = lines[0]
|
|
138
|
+
parents = lines[1].split() if lines[1] else []
|
|
139
|
+
author_name = lines[2]
|
|
140
|
+
author_email = lines[3]
|
|
141
|
+
author_date = lines[4]
|
|
142
|
+
committer_name = lines[5]
|
|
143
|
+
committer_email = lines[6]
|
|
144
|
+
committer_date = lines[7]
|
|
145
|
+
|
|
146
|
+
# Get commit message separately (can be multiline)
|
|
147
|
+
message = git(repo_path, "log", "-1", "--format=%B", commit_sha).strip()
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"tree": tree,
|
|
151
|
+
"parents": parents,
|
|
152
|
+
"author_name": author_name,
|
|
153
|
+
"author_email": author_email,
|
|
154
|
+
"author_date": author_date,
|
|
155
|
+
"committer_name": committer_name,
|
|
156
|
+
"committer_email": committer_email,
|
|
157
|
+
"committer_date": committer_date,
|
|
158
|
+
"message": message,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
def get_branch_commit(repo_path: Path, branch: str) -> str:
|
|
164
|
+
"""Get the commit SHA that a branch points to."""
|
|
165
|
+
return git(repo_path, "rev-parse", branch).strip()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
169
|
+
# Tree Operations
|
|
170
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ls_tree(repo_path: Path, tree_sha: str) -> list[TreeEntry]:
|
|
174
|
+
"""List entries in a git tree."""
|
|
175
|
+
output = git(repo_path, "ls-tree", tree_sha)
|
|
176
|
+
entries: list[TreeEntry] = []
|
|
177
|
+
for line in output.strip().split("\n"):
|
|
178
|
+
if not line:
|
|
179
|
+
continue
|
|
180
|
+
# Format: <mode> <type> <sha>\t<name>
|
|
181
|
+
parts = line.split("\t", 1)
|
|
182
|
+
mode_type_sha = parts[0].split()
|
|
183
|
+
entries.append(
|
|
184
|
+
{
|
|
185
|
+
"mode": mode_type_sha[0],
|
|
186
|
+
"type": mode_type_sha[1],
|
|
187
|
+
"sha": mode_type_sha[2],
|
|
188
|
+
"name": parts[1],
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
return entries
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
def read_blob(repo_path: Path, blob_sha: str) -> bytes:
|
|
196
|
+
"""Read the contents of a blob."""
|
|
197
|
+
return git_bytes(repo_path, "cat-file", "blob", blob_sha)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
201
|
+
def get_tree_sha(repo_path: Path, commit_sha: str) -> str:
|
|
202
|
+
"""Get the tree SHA for a commit."""
|
|
203
|
+
return git(repo_path, "rev-parse", f"{commit_sha}^{{tree}}").strip()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
207
|
+
def mktree(repo_path: Path, entries: list[TreeEntry]) -> str:
|
|
208
|
+
"""Create a tree object from entries and return its SHA."""
|
|
209
|
+
lines: list[str] = []
|
|
210
|
+
for entry in entries:
|
|
211
|
+
lines.append(f"{entry['mode']} {entry['type']} {entry['sha']}\t{entry['name']}")
|
|
212
|
+
input_data = "\n".join(lines) + "\n" if lines else ""
|
|
213
|
+
return git(repo_path, "mktree", input_data=input_data).strip()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
217
|
+
def hash_object(repo_path: Path, data: bytes, obj_type: str = "blob") -> str:
|
|
218
|
+
"""Hash an object and store it in the repository."""
|
|
219
|
+
cmd = ["git", "-C", str(repo_path), "hash-object", "-w", "-t", obj_type, "--stdin"]
|
|
220
|
+
result = subprocess.run(cmd, capture_output=True, input=data, check=True)
|
|
221
|
+
return result.stdout.decode().strip()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
225
|
+
# Commit Creation
|
|
226
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def commit_tree(
|
|
230
|
+
repo_path: Path,
|
|
231
|
+
tree_sha: str,
|
|
232
|
+
parents: list[str],
|
|
233
|
+
message: str,
|
|
234
|
+
author_name: str,
|
|
235
|
+
author_email: str,
|
|
236
|
+
author_date: str,
|
|
237
|
+
committer_name: str,
|
|
238
|
+
committer_email: str,
|
|
239
|
+
committer_date: str,
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Create a commit object and return its SHA."""
|
|
242
|
+
args = ["commit-tree", tree_sha]
|
|
243
|
+
for parent in parents:
|
|
244
|
+
args.extend(["-p", parent])
|
|
245
|
+
|
|
246
|
+
env = {
|
|
247
|
+
"GIT_AUTHOR_NAME": author_name,
|
|
248
|
+
"GIT_AUTHOR_EMAIL": author_email,
|
|
249
|
+
"GIT_AUTHOR_DATE": author_date,
|
|
250
|
+
"GIT_COMMITTER_NAME": committer_name,
|
|
251
|
+
"GIT_COMMITTER_EMAIL": committer_email,
|
|
252
|
+
"GIT_COMMITTER_DATE": committer_date,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return git(repo_path, *args, input_data=message, env=env).strip()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
def update_ref(repo_path: Path, ref: str, commit_sha: str) -> None:
|
|
260
|
+
"""Update a reference to point to a commit."""
|
|
261
|
+
git(repo_path, "update-ref", ref, commit_sha)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
265
|
+
# Submodule Operations
|
|
266
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_gitmodules_at_commit(
|
|
270
|
+
repo_path: Path, commit_sha: str
|
|
271
|
+
) -> dict[str, SubmoduleInfo]:
|
|
272
|
+
"""Parse .gitmodules file at a specific commit."""
|
|
273
|
+
# Check if .gitmodules exists in this commit
|
|
274
|
+
try:
|
|
275
|
+
tree_sha = get_tree_sha(repo_path, commit_sha)
|
|
276
|
+
entries = ls_tree(repo_path, tree_sha)
|
|
277
|
+
gitmodules_entry = next(
|
|
278
|
+
(e for e in entries if e["name"] == ".gitmodules"), None
|
|
279
|
+
)
|
|
280
|
+
if not gitmodules_entry:
|
|
281
|
+
return {}
|
|
282
|
+
except subprocess.CalledProcessError:
|
|
283
|
+
return {}
|
|
284
|
+
|
|
285
|
+
# Read and parse .gitmodules
|
|
286
|
+
try:
|
|
287
|
+
content = read_blob(repo_path, gitmodules_entry["sha"]).decode("utf-8")
|
|
288
|
+
except subprocess.CalledProcessError:
|
|
289
|
+
return {}
|
|
290
|
+
|
|
291
|
+
return parse_gitmodules(content)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
295
|
+
def parse_gitmodules(content: str) -> dict[str, SubmoduleInfo]:
|
|
296
|
+
"""Parse .gitmodules content and return submodule info by path."""
|
|
297
|
+
parser = configparser.ConfigParser()
|
|
298
|
+
parser.read_string(content)
|
|
299
|
+
|
|
300
|
+
submodules: dict[str, SubmoduleInfo] = {}
|
|
301
|
+
for section in parser.sections():
|
|
302
|
+
if section.startswith('submodule "'):
|
|
303
|
+
path = parser.get(section, "path", fallback=None)
|
|
304
|
+
url = parser.get(section, "url", fallback=None)
|
|
305
|
+
if path and url:
|
|
306
|
+
submodules[path] = {"path": path, "url": url}
|
|
307
|
+
|
|
308
|
+
return submodules
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
def object_exists(repo_path: Path, sha: str) -> bool:
|
|
313
|
+
"""Check if an object exists in the repository."""
|
|
314
|
+
result = subprocess.run(
|
|
315
|
+
["git", "-C", str(repo_path), "cat-file", "-e", sha],
|
|
316
|
+
capture_output=True,
|
|
317
|
+
)
|
|
318
|
+
return result.returncode == 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
322
|
+
# Remote Operations
|
|
323
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
327
|
+
def get_remotes(repo_path: Path) -> dict[str, str]:
|
|
328
|
+
"""Get all remotes and their URLs from a repository."""
|
|
329
|
+
output = git(repo_path, "remote", "-v", check=False)
|
|
330
|
+
remotes: dict[str, str] = {}
|
|
331
|
+
for line in output.strip().split("\n"):
|
|
332
|
+
if not line or "(push)" in line:
|
|
333
|
+
# Skip empty lines and push URLs (we only need fetch URLs)
|
|
334
|
+
continue
|
|
335
|
+
parts = line.split()
|
|
336
|
+
if len(parts) >= 2:
|
|
337
|
+
name = parts[0]
|
|
338
|
+
url = parts[1]
|
|
339
|
+
remotes[name] = url
|
|
340
|
+
return remotes
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
344
|
+
def copy_remotes(source_repo: Path, dest_repo: Path, verbose: bool = False) -> int:
|
|
345
|
+
"""Copy all remotes from source repository to destination repository."""
|
|
346
|
+
remotes = get_remotes(source_repo)
|
|
347
|
+
count = 0
|
|
348
|
+
for name, url in remotes.items():
|
|
349
|
+
git(dest_repo, "remote", "add", name, url, check=False)
|
|
350
|
+
count += 1
|
|
351
|
+
if verbose:
|
|
352
|
+
print(f" Added remote: {name} -> {url}")
|
|
353
|
+
return count
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# stream.py
|
|
3
|
+
# ─────────
|
|
4
|
+
#
|
|
5
|
+
# Git fast-export/fast-import streaming utilities.
|
|
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 . import repo as git_repo
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
# Constants - Regex patterns for fast-export stream parsing
|
|
33
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
MARK_PATTERN = re.compile(r"^mark :(\d+)$")
|
|
36
|
+
COMMIT_PATTERN = re.compile(r"^commit (.+)$")
|
|
37
|
+
FROM_PATTERN = re.compile(r"^from :(\d+)$")
|
|
38
|
+
MERGE_PATTERN = re.compile(r"^merge :(\d+)$")
|
|
39
|
+
M_PATTERN = re.compile(r"^M (\d+) (?::(\d+)|([0-9a-f]{40})) (.+)$")
|
|
40
|
+
D_PATTERN = re.compile(r"^D (.+)$")
|
|
41
|
+
|
|
42
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
# Stream Functions
|
|
44
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
48
|
+
def start_fast_export(repo_path: Path) -> subprocess.Popen[bytes]:
|
|
49
|
+
"""Start git fast-export process."""
|
|
50
|
+
return subprocess.Popen(
|
|
51
|
+
[
|
|
52
|
+
"git",
|
|
53
|
+
"-C",
|
|
54
|
+
str(repo_path),
|
|
55
|
+
"fast-export",
|
|
56
|
+
"--all",
|
|
57
|
+
"--signed-tags=strip",
|
|
58
|
+
"--tag-of-filtered-object=rewrite",
|
|
59
|
+
],
|
|
60
|
+
stdout=subprocess.PIPE,
|
|
61
|
+
stderr=subprocess.PIPE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
66
|
+
def start_fast_import(repo_path: Path) -> subprocess.Popen[bytes]:
|
|
67
|
+
"""Start git fast-import process."""
|
|
68
|
+
return subprocess.Popen(
|
|
69
|
+
["git", "-C", str(repo_path), "fast-import", "--quiet"],
|
|
70
|
+
stdin=subprocess.PIPE,
|
|
71
|
+
stdout=subprocess.PIPE,
|
|
72
|
+
stderr=subprocess.PIPE,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
def create_stream_writer(
|
|
78
|
+
import_proc: subprocess.Popen[bytes],
|
|
79
|
+
) -> Callable[[bytes], None]:
|
|
80
|
+
"""Create a safe writer function for fast-import stdin."""
|
|
81
|
+
|
|
82
|
+
def write_import(data: bytes) -> None:
|
|
83
|
+
assert import_proc.stdin is not None
|
|
84
|
+
try:
|
|
85
|
+
import_proc.stdin.write(data)
|
|
86
|
+
except BrokenPipeError as e:
|
|
87
|
+
import_proc.wait()
|
|
88
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
89
|
+
raise RuntimeError(f"fast-import failed: {stderr}") from e
|
|
90
|
+
|
|
91
|
+
return write_import
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
def finalise_stream(
|
|
96
|
+
export_proc: subprocess.Popen[bytes],
|
|
97
|
+
import_proc: subprocess.Popen[bytes],
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Close streams and check for errors."""
|
|
100
|
+
if import_proc.stdin:
|
|
101
|
+
import_proc.stdin.close()
|
|
102
|
+
export_proc.wait()
|
|
103
|
+
import_proc.wait()
|
|
104
|
+
|
|
105
|
+
if export_proc.returncode != 0:
|
|
106
|
+
stderr = export_proc.stderr.read().decode() if export_proc.stderr else ""
|
|
107
|
+
raise RuntimeError(f"fast-export failed: {stderr}")
|
|
108
|
+
|
|
109
|
+
if import_proc.returncode != 0:
|
|
110
|
+
stderr = import_proc.stderr.read().decode() if import_proc.stderr else ""
|
|
111
|
+
raise RuntimeError(f"fast-import failed: {stderr}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
def checkout_default_branch(repo_path: Path) -> None:
|
|
116
|
+
"""Checkout the default branch after fast-import."""
|
|
117
|
+
branches = git_repo.get_all_branches(repo_path)
|
|
118
|
+
if branches:
|
|
119
|
+
if "main" in branches:
|
|
120
|
+
default_branch = "main"
|
|
121
|
+
elif "master" in branches:
|
|
122
|
+
default_branch = "master"
|
|
123
|
+
else:
|
|
124
|
+
default_branch = branches[0]
|
|
125
|
+
git_repo.git(repo_path, "checkout", "-f", default_branch)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# __init__.py
|
|
3
|
+
# ───────────
|
|
4
|
+
#
|
|
5
|
+
# Compose subcommand - chain multiple commit mapping files.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
from .run import run
|
|
19
|
+
|
|
20
|
+
__all__ = ["run"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# run.py
|
|
3
|
+
# ──────
|
|
4
|
+
#
|
|
5
|
+
# Runner for the compose subcommand.
|
|
6
|
+
#
|
|
7
|
+
# (c) 2026 Cyber Assessment Labs — MIT License; see LICENSE in the project root.
|
|
8
|
+
#
|
|
9
|
+
# Authors
|
|
10
|
+
# ───────
|
|
11
|
+
# bena (via Claude)
|
|
12
|
+
#
|
|
13
|
+
# Version History
|
|
14
|
+
# ───────────────
|
|
15
|
+
# Jan 2026 - Created
|
|
16
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
# Imports
|
|
20
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
import argparse
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
30
|
+
# Functions
|
|
31
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
def load_mapping(filepath: Path) -> dict[str, str]:
|
|
36
|
+
"""Load a commit mapping file (old_sha new_sha per line)."""
|
|
37
|
+
mapping: dict[str, str] = {}
|
|
38
|
+
with filepath.open(encoding="utf-8") as f:
|
|
39
|
+
for line in f:
|
|
40
|
+
line = line.strip()
|
|
41
|
+
if not line:
|
|
42
|
+
continue
|
|
43
|
+
parts = line.split()
|
|
44
|
+
if len(parts) >= 2:
|
|
45
|
+
mapping[parts[0]] = parts[1]
|
|
46
|
+
return mapping
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
50
|
+
def compose_mappings(mappings: list[dict[str, str]]) -> dict[str, str]:
|
|
51
|
+
"""
|
|
52
|
+
Compose multiple mappings into one.
|
|
53
|
+
|
|
54
|
+
Given mappings [A->B, B->C, C->D], produces A->D.
|
|
55
|
+
Each mapping is applied in order, following the chain.
|
|
56
|
+
"""
|
|
57
|
+
if not mappings:
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
result = mappings[0].copy()
|
|
61
|
+
|
|
62
|
+
for next_mapping in mappings[1:]:
|
|
63
|
+
# For each key in result, follow the chain through next_mapping
|
|
64
|
+
new_result: dict[str, str] = {}
|
|
65
|
+
for original, intermediate in result.items():
|
|
66
|
+
if intermediate in next_mapping:
|
|
67
|
+
new_result[original] = next_mapping[intermediate]
|
|
68
|
+
else:
|
|
69
|
+
# No mapping found, keep intermediate as final
|
|
70
|
+
new_result[original] = intermediate
|
|
71
|
+
result = new_result
|
|
72
|
+
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
def run(args: argparse.Namespace) -> int:
|
|
78
|
+
"""Run the compose subcommand."""
|
|
79
|
+
# Validate input files exist
|
|
80
|
+
for mapping_file in args.mappings:
|
|
81
|
+
if not mapping_file.exists():
|
|
82
|
+
print(f"Error: Mapping file not found: {mapping_file}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
# Load all mappings
|
|
86
|
+
mappings: list[dict[str, str]] = []
|
|
87
|
+
for mapping_file in args.mappings:
|
|
88
|
+
mapping = load_mapping(mapping_file)
|
|
89
|
+
if args.verbose:
|
|
90
|
+
print(f"Loaded {len(mapping)} entries from {mapping_file}")
|
|
91
|
+
mappings.append(mapping)
|
|
92
|
+
|
|
93
|
+
# Compose them
|
|
94
|
+
result = compose_mappings(mappings)
|
|
95
|
+
|
|
96
|
+
# Write output
|
|
97
|
+
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
with args.output.open("w", encoding="utf-8") as f:
|
|
99
|
+
for old_sha, new_sha in sorted(result.items()):
|
|
100
|
+
f.write(f"{old_sha} {new_sha}\n")
|
|
101
|
+
|
|
102
|
+
print(f"Composed {len(args.mappings)} mappings -> {len(result)} entries")
|
|
103
|
+
print(f"Output written to: {args.output}")
|
|
104
|
+
|
|
105
|
+
return 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# ────────────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
# flatten/__init__.py
|
|
3
|
+
# ────────────────────
|
|
4
|
+
#
|
|
5
|
+
# Flatten subcommand - flatten submodules into main repository.
|
|
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 .rewriter import collect_submodules_from_gitmodules
|
|
19
|
+
from .rewriter import flatten_repository
|
|
20
|
+
from .run import run
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"run",
|
|
24
|
+
"flatten_repository",
|
|
25
|
+
"collect_submodules_from_gitmodules",
|
|
26
|
+
]
|