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