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,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")
@@ -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