git-nested 1.0.3__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_nested.py ADDED
@@ -0,0 +1,2001 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ git-nested - Git Submodule Alternative
4
+
5
+ Copyright 2026 - Thorsten Klein <thorsten.klein.git@gmail.com>
6
+ """
7
+
8
+ import contextlib
9
+ import sys
10
+ import os
11
+ import subprocess
12
+ import argparse
13
+ import re
14
+ import shutil
15
+ import textwrap
16
+ import yaml
17
+ from importlib.metadata import version as _pkg_version, PackageNotFoundError
18
+ from pathlib import Path
19
+ from typing import List
20
+ from dataclasses import dataclass
21
+ from urllib.parse import quote
22
+
23
+ try:
24
+ VERSION = _pkg_version("git-nested")
25
+ except PackageNotFoundError:
26
+ VERSION = "0.99.99"
27
+ REQUIRED_GIT_VERSION = "2.23.0"
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def chdir(path):
32
+ """
33
+ Backport of contextlib.chdir stdlib class added in Python 3.11.
34
+ The current working directory is temporarily changed to given path
35
+ for the duration of the `with` block. When the block exits, the
36
+ working directory is restored to its original value.
37
+ """
38
+ oldpwd = os.getcwd()
39
+ os.chdir(path)
40
+ try:
41
+ yield
42
+ finally:
43
+ os.chdir(oldpwd)
44
+
45
+
46
+ class GitNestedError(Exception):
47
+ """Base exception for git-nested errors"""
48
+
49
+ def __init__(self, message, print_to_stderr=True):
50
+ self.message = message
51
+ if print_to_stderr:
52
+ print(f"git-nested: {message}", file=sys.stderr)
53
+ super().__init__(self.message)
54
+
55
+
56
+ @dataclass
57
+ class Flags:
58
+ """Command-line flags"""
59
+
60
+ all: bool = False
61
+ ALL: bool = False
62
+ branch: None | str = None
63
+ commit: bool = False
64
+ filter: None | list[str] = None
65
+ force: bool = False
66
+ fetch: bool = False
67
+ message: None | str = None
68
+ message_file: None | str = None
69
+ method: None | str = None
70
+ remote: None | str = None
71
+ squash: bool = False
72
+ update: bool = False
73
+ quiet: bool = False
74
+ verbose: int = 0
75
+
76
+
77
+ @dataclass
78
+ class NestedConfig:
79
+ """Nested configuration from .gitnested file"""
80
+
81
+ remote: str = ''
82
+ branch: str = ''
83
+ commit: str = ''
84
+ filter: None | list[str] = None
85
+ parent: str = ''
86
+ method: str = 'merge'
87
+
88
+ @classmethod
89
+ def from_file(cls, filepath: str):
90
+ """Read config from .gitnested YAML file"""
91
+ path = Path(filepath)
92
+ if not path.is_file():
93
+ raise GitNestedError(f"No '{filepath}' file.")
94
+ with path.open('r') as f:
95
+ data = yaml.safe_load(f) or {}
96
+ nested_data = data.get('nested', {})
97
+
98
+ config = cls()
99
+ config.remote = nested_data.get('remote', '')
100
+ config.branch = nested_data.get('branch', '')
101
+ config.commit = nested_data.get('commit', '')
102
+ config.filter = nested_data.get('filter', None)
103
+ config.parent = nested_data.get('parent', '')
104
+ method = nested_data.get('method', 'merge')
105
+ config.method = 'rebase' if method == 'rebase' else 'merge'
106
+
107
+ if not config.remote:
108
+ raise GitNestedError(f"Missing required 'remote' in '{filepath}'.")
109
+ if not config.branch:
110
+ raise GitNestedError(f"Missing required 'branch' in '{filepath}'.")
111
+
112
+ return config
113
+
114
+
115
+ class GitRunner:
116
+ """Simplified git command execution"""
117
+
118
+ def __init__(self):
119
+ self.check()
120
+ self.version = self.get_version()
121
+
122
+ def run(self, args: List[str], may_fail=False, print_error=True, **kwargs) -> subprocess.CompletedProcess:
123
+ """Run git command"""
124
+ # Convert any Path objects to strings
125
+ cmd = ['git'] + [str(arg) for arg in args]
126
+ result = subprocess.run(cmd, capture_output=True, text=True, check=False, **kwargs)
127
+ if result.returncode != 0:
128
+ if not may_fail:
129
+ raise GitNestedError(
130
+ f"Command failed: '{' '.join(cmd)}'.\n{str(result.stderr)}", print_to_stderr=print_error
131
+ )
132
+
133
+ # Exception occurred but may_fail=True: Create a fake CompletedProcess for exception case
134
+ return subprocess.CompletedProcess(args=cmd, returncode=-1, stdout=result.stdout, stderr=result.stderr)
135
+
136
+ # Command succeeded
137
+ return result
138
+
139
+ def check_output(self, args: List[str], may_fail=False, **kwargs) -> str:
140
+ result = self.run(args=args, may_fail=may_fail, **kwargs)
141
+ return result.stdout.strip()
142
+
143
+ def is_tracked(self, path: Path) -> bool:
144
+ """Check if given path is tracked by git"""
145
+ result = self.run(['ls-files', '--', path], may_fail=True)
146
+ return result.returncode == 0
147
+
148
+ def rev_exists(self, rev: str) -> bool:
149
+ """Check if revision exists"""
150
+ result = self.run(['rev-list', rev, '-1'], may_fail=True)
151
+ return result.returncode == 0
152
+
153
+ def branch_exists(self, branch: str) -> bool:
154
+ """Check if branch exists"""
155
+ return self.rev_exists(f'refs/heads/{branch}')
156
+
157
+ def commit_in_rev_list(self, commit: str, list_head: str) -> bool:
158
+ """Check if commit is in rev-list (i.e., is an ancestor)"""
159
+ result = self.run(['merge-base', '--is-ancestor', commit, list_head], may_fail=True)
160
+ return result.returncode == 0
161
+
162
+ def check(self):
163
+ """Check that environment is suitable"""
164
+ if not shutil.which('git'):
165
+ raise GitNestedError("Can't find 'git' in PATH env variable.")
166
+ version = self.get_version()
167
+
168
+ def Version(v):
169
+ return tuple(map(int, (v.split("."))))
170
+
171
+ if not Version(version) >= Version(REQUIRED_GIT_VERSION):
172
+ raise GitNestedError(f"Requires git version {REQUIRED_GIT_VERSION} or higher; you have '{version}'.")
173
+
174
+ def get_version(self):
175
+ git_version = self.check_output(['--version'])
176
+ m = re.search(r'(\d+\.\d+\.\d+)', git_version)
177
+ if not m:
178
+ raise GitNestedError("Can't determine git version")
179
+ return m.group(1)
180
+
181
+
182
+ class GitNestedRepo:
183
+ """Handles repository operations and business logic"""
184
+
185
+ def __init__(self):
186
+ pass
187
+
188
+ # -------------------------------------
189
+ # Logging helpers (delegated to maintain separation)
190
+ # -------------------------------------
191
+
192
+ def verbose(self, msg: str, flags: Flags):
193
+ """Print verbose messages"""
194
+ if flags.verbose:
195
+ print(f"* {msg}")
196
+
197
+ def say(self, msg: str, flags: Flags):
198
+ """Print message unless quiet"""
199
+ if not flags.quiet:
200
+ print(msg)
201
+
202
+ # -------------------------------------
203
+ # Helper methods
204
+ # -------------------------------------
205
+
206
+ def _read_yaml_config(self, filepath: Path) -> dict:
207
+ """Read YAML configuration file"""
208
+ with filepath.open('r') as f:
209
+ return yaml.safe_load(f) or {}
210
+
211
+ def _write_yaml_config(self, filepath: Path, data: dict) -> str:
212
+ """Write YAML configuration file with header"""
213
+ GITREPO_HEADER = textwrap.dedent("""\
214
+ # This subdirectory is managed by "git nested".
215
+ # Refer to: https://github.com/thorsten-klein/git-nested#readme
216
+ #
217
+ """)
218
+ with filepath.open('w') as f:
219
+ f.write(GITREPO_HEADER)
220
+ return yaml.dump(data, f, default_flow_style=False, sort_keys=False)
221
+
222
+ def create_level_gitnested_files(
223
+ self, git: GitRunner, flags: Flags, subdir: Path, head_commit: str, level: int = None
224
+ ):
225
+ """Create .gitnested.levelN files for nested-in-nested repositories
226
+
227
+ This allows sub-nested repositories to be pulled/pushed independently
228
+ even when they are nested within another nested repository.
229
+
230
+ Args:
231
+ git: GitRunner instance
232
+ flags: Command flags
233
+ subdir: The subdirectory being cloned/pulled
234
+ head_commit: The parent commit (will be used as parent for level files)
235
+ level: The nesting level (auto-detected if None)
236
+ """
237
+ # Auto-detect the level based on existing .gitnested.level* files in subdir
238
+ if level is None:
239
+ # Check what level files exist in the subdir itself
240
+ existing_levels = []
241
+ all_files = git.check_output(['ls-files', '--', subdir], may_fail=True)
242
+ if all_files:
243
+ for line in all_files.splitlines():
244
+ if '.gitnested.level' in line and line.startswith(f'{subdir}/.gitnested.level'):
245
+ # Extract level number
246
+ parts = line.split('.gitnested.level')
247
+ if len(parts) == 2 and parts[1].isdigit():
248
+ existing_levels.append(int(parts[1]))
249
+
250
+ # Start at level 2 (first sub-nested), or one above the highest existing level
251
+ level = max(existing_levels) + 1 if existing_levels else 2
252
+
253
+ # Find all .gitnested files within the subdirectory (excluding the subdir's own .gitnested)
254
+ all_files = git.check_output(['ls-files', '--', subdir], may_fail=True)
255
+ if not all_files:
256
+ return
257
+
258
+ gitnested_files = [
259
+ line for line in all_files.splitlines() if line.endswith('.gitnested') and line != f'{subdir}/.gitnested'
260
+ ]
261
+
262
+ for gitnested_path in gitnested_files:
263
+ gitnested_file = Path(gitnested_path)
264
+ level_file = gitnested_file.parent / f'.gitnested.level{level}'
265
+
266
+ self.verbose(f"Creating {level_file} for sub-nested repository", flags)
267
+
268
+ # Copy the .gitnested content to .gitnested.levelN, but clear the parent field
269
+ # The parent field from the intermediate repo doesn't apply in this context
270
+ # It will be set correctly on the first pull/push operation
271
+ if gitnested_file.exists():
272
+ data = self._read_yaml_config(gitnested_file)
273
+ # Clear the parent field - it will be set on first pull/push
274
+ if 'nested' in data:
275
+ data['nested']['parent'] = ''
276
+
277
+ # Write the modified config to .gitnested.levelN
278
+ self._write_yaml_config(level_file, data)
279
+ # Add the level file to git
280
+ git.run(['add', '-f', '--', str(level_file)])
281
+
282
+ # Recursively check for deeper nesting with incremented level
283
+ sub_subdir = gitnested_file.parent
284
+ self.create_level_gitnested_files(git, flags, sub_subdir, head_commit, level + 1)
285
+
286
+ def update_level_gitnested_parents(self, git: GitRunner, flags: Flags, subdir: Path):
287
+ """Update parent field in .gitnested.levelN files after commit is created"""
288
+ self.verbose("Updating parent field in .gitnested.levelN files", flags)
289
+
290
+ # Get the current HEAD commit (the commit we just created)
291
+ new_commit = git.check_output(['rev-parse', 'HEAD'])
292
+
293
+ # Find all .gitnested.level* files within the subdirectory
294
+ all_files = git.check_output(['ls-files', '--', subdir], may_fail=True)
295
+ if not all_files:
296
+ return
297
+
298
+ level_files = [Path(line) for line in all_files.splitlines() if '.gitnested.level' in line]
299
+
300
+ for level_file in level_files:
301
+ if level_file.exists():
302
+ data = self._read_yaml_config(level_file)
303
+ if 'nested' in data:
304
+ data['nested']['parent'] = new_commit
305
+ self._write_yaml_config(level_file, data)
306
+ git.run(['add', '-f', '--', str(level_file)])
307
+
308
+ # Amend the commit if we modified any files
309
+ if level_files:
310
+ result = git.run(['diff', '--cached', '--quiet'], may_fail=True)
311
+ if result.returncode != 0:
312
+ # There are staged changes, amend the commit
313
+ git.run(['commit', '--amend', '--no-edit'])
314
+
315
+ def create_nested_ref(self, git: GitRunner, subref: str, ref_type: str, commit: str):
316
+ """Create a git ref pointing to commit"""
317
+ ref_name = f'refs/nested/{subref}/{ref_type}'
318
+ git.run(['update-ref', ref_name, commit])
319
+ return ref_name
320
+
321
+ # -------------------------------------
322
+ # Worker Functions
323
+ # -------------------------------------
324
+
325
+ def do_clone(
326
+ self,
327
+ git: GitRunner,
328
+ flags: Flags,
329
+ config: NestedConfig,
330
+ subdir: Path,
331
+ gitnested: Path,
332
+ subref: str,
333
+ ) -> tuple[bool, NestedConfig, str, str]:
334
+ """Clone implementation
335
+
336
+ Returns:
337
+ tuple: (up_to_date, updated_config, nested_commit_ref, upstream_head_commit)
338
+ """
339
+ # Check if we can clone (fail if HEAD doesn't exist)
340
+ if not git.rev_exists('HEAD'):
341
+ raise GitNestedError("You can't clone into an empty repository")
342
+
343
+ # Turn off force unless really a reclone
344
+ force = flags.force
345
+ if force and not gitnested.is_file():
346
+ force = False
347
+
348
+ branch = flags.branch
349
+ if force:
350
+ upstream_head_commit = self.do_fetch(git, flags, config, subref)
351
+ config = self.read_config(gitnested, flags)
352
+
353
+ self.verbose("Check if we already are up to date.", flags)
354
+ if upstream_head_commit == config.commit:
355
+ return True, config, None, upstream_head_commit
356
+
357
+ self.verbose("Remove the existing subdir.", flags)
358
+ git.run(['rm', '-r', '--', subdir])
359
+
360
+ if not branch:
361
+ self.verbose("Determine the upstream head branch.", flags)
362
+ config.branch = self.get_upstream_branch(git, config)
363
+ branch = config.branch
364
+ # Fetch again from the new branch
365
+ upstream_head_commit = self.do_fetch(git, flags, config, subref)
366
+ else:
367
+ if subdir.exists() and any(subdir.iterdir()):
368
+ raise GitNestedError(f"The subdir '{subdir}' exists and is not empty.")
369
+
370
+ if not config.branch:
371
+ self.verbose("Determine the upstream head branch.", flags)
372
+ config.branch = self.get_upstream_branch(git, config)
373
+
374
+ upstream_head_commit = self.do_fetch(git, flags, config, subref)
375
+
376
+ if flags.filter:
377
+ config.filter = flags.filter
378
+
379
+ self.verbose(f"Make the directory '{subdir}/' for the clone.", flags)
380
+ subdir.mkdir(parents=True, exist_ok=True)
381
+
382
+ nested_commit_ref = upstream_head_commit
383
+ return False, config, nested_commit_ref, upstream_head_commit
384
+
385
+ def do_init(
386
+ self,
387
+ git: GitRunner,
388
+ flags: Flags,
389
+ config: NestedConfig,
390
+ subdir: Path,
391
+ gitnested: Path,
392
+ head_commit: str,
393
+ subref: str,
394
+ ) -> str:
395
+ """Initialize a nested repository
396
+
397
+ Returns:
398
+ nested_commit_ref
399
+ """
400
+ self.check_subdir_for_init(git, subdir, gitnested)
401
+ nested_commit_ref = head_commit
402
+
403
+ self.verbose(f"Put info into '{subdir}/.gitnested' file.", flags)
404
+ self.update_gitrepo_file(
405
+ git=git,
406
+ flags=flags,
407
+ config=config,
408
+ gitnested=gitnested,
409
+ upstream_head_commit='', # No upstream for init
410
+ nested_commit_ref=nested_commit_ref,
411
+ head_commit=head_commit,
412
+ command='init',
413
+ )
414
+
415
+ self.verbose(f"Add the new '{subdir}/.gitnested' file.", flags)
416
+ git.run(['add', '-f', '--', gitnested])
417
+
418
+ self.verbose("Commit the changes.", flags)
419
+ msg = self.build_commit_message(
420
+ git=git,
421
+ config=config,
422
+ upstream_head_commit=head_commit,
423
+ nested_commit_ref=nested_commit_ref,
424
+ subdir=subdir,
425
+ command='init',
426
+ )
427
+ git.run(['commit', '-m', msg])
428
+
429
+ self.create_nested_ref(git, subref, 'commit', nested_commit_ref)
430
+
431
+ return nested_commit_ref
432
+
433
+ def do_pull(
434
+ self,
435
+ git: GitRunner,
436
+ flags: Flags,
437
+ config: NestedConfig,
438
+ subdir: Path,
439
+ gitnested: Path,
440
+ git_tmp: Path,
441
+ subref: str,
442
+ ) -> tuple[bool, str, Path, str]:
443
+ """Pull implementation
444
+
445
+ Returns:
446
+ tuple: (success, nested_commit_ref, subdir_worktree, error_msg)
447
+ """
448
+ upstream_head_commit = self.do_fetch(git, flags, config, subref)
449
+
450
+ if flags.force:
451
+ # Force reclone - handled by caller
452
+ return True, None, None, None
453
+
454
+ if upstream_head_commit == config.commit and not flags.update:
455
+ return False, None, None, None
456
+
457
+ branch = f'nested/{subref}'
458
+ self.delete_branch(git, branch, git_tmp)
459
+ nested_commit_ref = branch
460
+
461
+ self.verbose(f"Create nested branch '{branch}'.", flags)
462
+ subdir_worktree = self.create_nested_branch(
463
+ git=git,
464
+ flags=flags,
465
+ config=config,
466
+ branch=branch,
467
+ subdir=subdir,
468
+ gitnested=gitnested,
469
+ git_tmp=git_tmp,
470
+ subref=subref,
471
+ command='pull',
472
+ )
473
+
474
+ method = flags.method or config.method
475
+ refs_fetch = f'refs/nested/{subref}/fetch'
476
+
477
+ try:
478
+ if method == 'rebase':
479
+ git.run(['rebase', refs_fetch, branch], cwd=subdir_worktree, print_error=False)
480
+ else:
481
+ # If parent is empty, allow unrelated histories (for nested-in-nested repos)
482
+ merge_cmd = ['merge', refs_fetch]
483
+ if not config.parent:
484
+ merge_cmd.append('--allow-unrelated-histories')
485
+ git.run(merge_cmd, cwd=subdir_worktree, print_error=False)
486
+ except GitNestedError as e:
487
+ # Merge/rebase failed - return failure with error message
488
+ error_msg = f'The "git {method}" command failed:\n{e.message}'
489
+ return False, nested_commit_ref, subdir_worktree, error_msg
490
+
491
+ self.create_nested_ref(git, subref, 'branch', branch)
492
+
493
+ return True, nested_commit_ref, subdir_worktree, None
494
+
495
+ def do_push(
496
+ self,
497
+ git: GitRunner,
498
+ flags: Flags,
499
+ config: NestedConfig,
500
+ subdir: Path,
501
+ gitnested: Path,
502
+ git_tmp: Path,
503
+ subref: str,
504
+ branch: str = None,
505
+ ) -> tuple[bool, str, Path, bool, str]:
506
+ """Push implementation
507
+
508
+ Returns:
509
+ tuple: (success, branch_name, subdir_worktree, branch_created, new_commit)
510
+ """
511
+ new_upstream = False
512
+ branch_created = False
513
+
514
+ # Calculate the resulting branch name
515
+ branch_name = flags.branch
516
+ if not branch_name:
517
+ toplevel = git.check_output(['rev-parse', '--show-toplevel'])
518
+ repo_name = Path(toplevel).name
519
+ branch_name = f"{repo_name}-{config.branch}"
520
+
521
+ upstream_head_commit = None
522
+ if not branch:
523
+ # Fetch the resulting branch automatically first
524
+ result = git.run(['fetch', '--no-tags', '--quiet', config.remote, branch_name], may_fail=True)
525
+ if result.returncode != 0:
526
+ stderr = result.stderr
527
+ if re.search(r"(^|\n)fatal: couldn't find remote ref ", stderr.lower()):
528
+ new_upstream = True
529
+ else:
530
+ raise GitNestedError(f"Fetch for push failed: {stderr}")
531
+ else:
532
+ upstream = git.check_output(['rev-parse', 'FETCH_HEAD^0'])
533
+ if not flags.force:
534
+ if upstream != config.commit:
535
+ raise GitNestedError(f"There are new changes upstream ({branch_name}), you need to pull first.")
536
+ else:
537
+ # Force mode: fetch original branch to be based on correct commit
538
+ if upstream != config.commit:
539
+ git.run(['fetch', '--no-tags', '--quiet', config.remote, config.branch])
540
+ upstream = git.check_output(['rev-parse', 'FETCH_HEAD^0'])
541
+ upstream_head_commit = upstream
542
+
543
+ branch = f'nested/{subref}'
544
+ subdir_worktree = git_tmp / branch
545
+ self.delete_branch(git, branch, git_tmp)
546
+
547
+ updated_config = config
548
+ if flags.squash:
549
+ updated_config = NestedConfig(
550
+ remote=config.remote,
551
+ branch=config.branch,
552
+ commit=config.commit,
553
+ parent='HEAD^',
554
+ method=config.method,
555
+ )
556
+
557
+ subdir_worktree = self.create_nested_branch(
558
+ git=git,
559
+ flags=flags,
560
+ config=updated_config,
561
+ branch=branch,
562
+ subdir=subdir,
563
+ gitnested=gitnested,
564
+ git_tmp=git_tmp,
565
+ subref=subref,
566
+ command='push',
567
+ )
568
+
569
+ method = flags.method or config.method
570
+ if method == 'rebase':
571
+ refs_fetch = f'refs/nested/{subref}/fetch'
572
+ try:
573
+ git.run(['rebase', refs_fetch, branch], cwd=subdir_worktree)
574
+ except GitNestedError:
575
+ return False, branch_name, subdir_worktree, True
576
+
577
+ branch_created = True
578
+ else:
579
+ if flags.squash:
580
+ raise GitNestedError("Squash option (-s) can't be used with branch parameter")
581
+
582
+ if not git.branch_exists(branch):
583
+ raise GitNestedError(f"No nested branch '{branch}' to push.")
584
+
585
+ new_commit = git.check_output(['rev-parse', branch])
586
+ if not new_upstream and upstream_head_commit == new_commit:
587
+ if branch_created:
588
+ self.delete_branch(git, branch, git_tmp)
589
+ return False, branch_name, None, branch_created, new_commit
590
+
591
+ if not flags.force and not new_upstream:
592
+ if not git.commit_in_rev_list(upstream_head_commit, branch):
593
+ raise GitNestedError(f"Can't commit: '{branch}' doesn't contain upstream HEAD: {upstream_head_commit}")
594
+
595
+ cmd = ['push']
596
+ if flags.force:
597
+ cmd.append('--force')
598
+ cmd.extend([config.remote, f'{branch}:{branch_name}'])
599
+ git.run(cmd)
600
+
601
+ self.create_nested_ref(git, subref, 'push', branch)
602
+
603
+ return True, branch_name, subdir_worktree, branch_created, new_commit
604
+
605
+ def do_fetch(self, git: GitRunner, flags: Flags, config: NestedConfig, subref: str) -> str:
606
+ """Fetch upstream content
607
+
608
+ Returns:
609
+ upstream_head_commit
610
+ """
611
+ if config.remote == 'none':
612
+ raise GitNestedError("Can't fetch nested repository. Remote is 'none'.")
613
+
614
+ branch_info = f"({config.branch})" if config.branch else ""
615
+ self.verbose(f"Fetch the upstream: {config.remote} {branch_info}.", flags)
616
+
617
+ cmd = ['fetch', '--no-tags', '--quiet', config.remote]
618
+ if config.branch:
619
+ cmd.append(config.branch)
620
+
621
+ git.run(cmd)
622
+
623
+ self.verbose("Get the upstream nested HEAD commit.", flags)
624
+ upstream_head_commit = git.check_output(['rev-parse', 'FETCH_HEAD^0'])
625
+
626
+ self.create_nested_ref(git, subref, 'fetch', 'FETCH_HEAD^0')
627
+
628
+ return upstream_head_commit
629
+
630
+ def create_nested_branch(
631
+ self,
632
+ git: GitRunner,
633
+ flags: Flags,
634
+ config: NestedConfig,
635
+ branch: str,
636
+ subdir: Path,
637
+ gitnested: Path,
638
+ git_tmp: Path,
639
+ subref: str,
640
+ command: str,
641
+ ) -> Path:
642
+ """Create a nested branch
643
+
644
+ Returns:
645
+ subdir_worktree
646
+ """
647
+ if branch is None:
648
+ branch = f'nested/{subref}'
649
+
650
+ self.verbose(f"Check if the '{branch}' branch already exists.", flags)
651
+ if git.branch_exists(branch):
652
+ return git_tmp / branch
653
+
654
+ self.verbose(f"Nested repository parent: {config.parent}", flags)
655
+
656
+ first_gitrepo_commit = None
657
+ last_gitrepo_commit = None
658
+
659
+ if config.parent:
660
+ # Check if parent is ancestor
661
+ result = git.run(['merge-base', '--is-ancestor', config.parent, 'HEAD'], may_fail=True)
662
+ if result.returncode != 0:
663
+ prev = git.check_output(['log', '-1', '-G', 'commit =', '--format=%H', gitnested], may_fail=True)
664
+ if prev:
665
+ prev = git.check_output(['log', '-1', '--format=%H', f'{prev.strip()}^'])
666
+ raise GitNestedError(
667
+ textwrap.dedent(
668
+ f"""\
669
+ The last sync point (where upstream and the nested were equal) is not an ancestor.
670
+ This is usually caused by a rebase affecting that commit.
671
+ To recover set the nested parent in '{gitnested}'
672
+ to '{prev}'
673
+ and validate the nested by comparing with 'git nested branch {subdir}'"""
674
+ )
675
+ )
676
+
677
+ # Get commit list
678
+ commits = git.check_output([
679
+ 'rev-list',
680
+ '--reverse',
681
+ '--ancestry-path',
682
+ '--topo-order',
683
+ f'{config.parent}..HEAD',
684
+ ]).splitlines()
685
+
686
+ prev_commit = None
687
+ ancestor = None
688
+
689
+ for commit in commits:
690
+ # Get .gitnested commit from YAML
691
+ gitrepo_content = git.check_output(['cat-file', '-p', f'{commit}:{subdir}/.gitnested'], may_fail=True)
692
+
693
+ if not gitrepo_content:
694
+ continue
695
+
696
+ try:
697
+ gitrepo_data = yaml.safe_load(gitrepo_content) or {}
698
+ gitrepo_commit = gitrepo_data.get('nested', {}).get('commit', '')
699
+ except yaml.YAMLError:
700
+ continue
701
+
702
+ if not gitrepo_commit:
703
+ continue
704
+
705
+ gitrepo_commit = gitrepo_commit.strip()
706
+
707
+ # Check if direct child
708
+ if ancestor:
709
+ parents = git.check_output(['show', '-s', '--pretty=format:%P', commit])
710
+ if ancestor not in parents:
711
+ continue
712
+
713
+ ancestor = commit
714
+
715
+ # Check for rebase (only during pull operations)
716
+ refs_fetch = f'refs/nested/{subref}/fetch'
717
+ if git.rev_exists(refs_fetch) and command == 'pull':
718
+ # Check if gitrepo_commit is reachable from refs_fetch
719
+ result = git.run(['merge-base', '--is-ancestor', gitrepo_commit, refs_fetch], may_fail=True)
720
+ if result.returncode != 0:
721
+ if not git.rev_exists(gitrepo_commit):
722
+ raise GitNestedError(
723
+ f"Local repository does not contain {gitrepo_commit}. Try to 'git nested fetch {subref}' or add the '-F' flag."
724
+ )
725
+ else:
726
+ raise GitNestedError(
727
+ f"Upstream history has been rewritten. Commit {gitrepo_commit} is not in the upstream history. Try to 'git nested fetch {subref}' or add the '-F' flag."
728
+ )
729
+
730
+ # Find parents
731
+ first_parent = ['-p', prev_commit] if prev_commit else []
732
+
733
+ second_parent = []
734
+ if not first_gitrepo_commit:
735
+ first_gitrepo_commit = gitrepo_commit
736
+ second_parent = ['-p', gitrepo_commit]
737
+
738
+ method = flags.method or config.method
739
+ if method != 'rebase':
740
+ if gitrepo_commit != last_gitrepo_commit:
741
+ second_parent = ['-p', gitrepo_commit]
742
+ last_gitrepo_commit = gitrepo_commit
743
+
744
+ # Create new commit
745
+ result = git.run(['cat-file', '-e', f'{commit}:{subdir}'], may_fail=True)
746
+ has_content = True if (result.returncode == 0) else False
747
+
748
+ if has_content:
749
+ # Extract author and committer information
750
+ author_date = git.check_output(['log', '-1', '--date=default', '--format=%ad', commit])
751
+ author_email = git.check_output(['log', '-1', '--date=default', '--format=%ae', commit])
752
+ author_name = git.check_output(['log', '-1', '--date=default', '--format=%an', commit])
753
+ committer_date = git.check_output(['log', '-1', '--date=default', '--format=%cd', commit])
754
+ committer_email = git.check_output(['log', '-1', '--date=default', '--format=%ce', commit])
755
+ committer_name = git.check_output(['log', '-1', '--date=default', '--format=%cn', commit])
756
+ commit_msg = git.check_output(['log', '-1', '--date=default', '--format=%B', commit])
757
+
758
+ # Set author and committer info for deterministic commits
759
+ env = os.environ.copy()
760
+ env.update({
761
+ 'GIT_AUTHOR_DATE': author_date,
762
+ 'GIT_AUTHOR_EMAIL': author_email,
763
+ 'GIT_AUTHOR_NAME': author_name,
764
+ 'GIT_COMMITTER_DATE': committer_date,
765
+ 'GIT_COMMITTER_EMAIL': committer_email,
766
+ 'GIT_COMMITTER_NAME': committer_name,
767
+ })
768
+
769
+ tree_cmd = ['commit-tree', '-F', '-'] + first_parent + second_parent + [f'{commit}:{subdir}']
770
+ prev_commit = git.check_output(tree_cmd, input=commit_msg, env=env)
771
+
772
+ git.run(['branch', branch, prev_commit])
773
+ else:
774
+ git.run(['branch', branch, 'HEAD'])
775
+
776
+ # Filter branch
777
+ git.run(['filter-branch', '-f', '--subdirectory-filter', subref, branch], may_fail=True)
778
+
779
+ # Remove .gitnested file
780
+ filter_range = f'{first_gitrepo_commit}..{branch}' if first_gitrepo_commit else branch
781
+
782
+ git.check_output(
783
+ [
784
+ 'filter-branch',
785
+ '-f',
786
+ '--prune-empty',
787
+ '--tree-filter',
788
+ 'rm -f .gitnested',
789
+ '--',
790
+ filter_range,
791
+ '--first-parent',
792
+ ],
793
+ may_fail=True,
794
+ )
795
+
796
+ subdir_worktree = self.create_worktree(git, branch, git_tmp)
797
+
798
+ self.create_nested_ref(git, subref, 'branch', branch)
799
+
800
+ return subdir_worktree
801
+
802
+ def commit_nested_branch(
803
+ self,
804
+ git: GitRunner,
805
+ flags: Flags,
806
+ config: NestedConfig,
807
+ subdir: Path,
808
+ gitnested: Path,
809
+ nested_commit_ref: str,
810
+ upstream_head_commit: str,
811
+ head_commit: str,
812
+ subdir_worktree: Path,
813
+ command: str,
814
+ ):
815
+ """Commit a nested branch"""
816
+ self.verbose("Checking that the nested repository commit exists.", flags)
817
+ if not git.rev_exists(nested_commit_ref):
818
+ raise GitNestedError(f"Commit ref '{nested_commit_ref}' does not exist.")
819
+
820
+ if not flags.force:
821
+ self.verbose("Make sure that the commit contains the upstream HEAD.", flags)
822
+ if not git.commit_in_rev_list(upstream_head_commit, nested_commit_ref):
823
+ raise GitNestedError(f"Can't commit: '{nested_commit_ref}' doesn't contain upstream HEAD.")
824
+
825
+ has_files = git.check_output(['ls-files', '--', subdir], may_fail=True)
826
+ if has_files:
827
+ self.verbose("Remove old content of the subdir.", flags)
828
+ git.run(['rm', '-r', '--', subdir])
829
+
830
+ self.verbose(f"Put remote nested content into '{subdir}/'.", flags)
831
+
832
+ if not config.filter:
833
+ git.run(['read-tree', f'--prefix={subdir}', '-u', nested_commit_ref])
834
+ else:
835
+ regex_patterns = []
836
+ for p in config.filter:
837
+ obj_type = git.check_output(['cat-file', '-t', f'{nested_commit_ref}:{p}'], may_fail=True)
838
+ if obj_type == 'tree':
839
+ git.run(['read-tree', f'--prefix={subdir}/{p}', '-u', f'{nested_commit_ref}:{p}'])
840
+ elif obj_type == 'blob':
841
+ file_path = subdir / p
842
+ file_path.parent.mkdir(parents=True, exist_ok=True)
843
+ content = git.run(['cat-file', 'blob', f'{nested_commit_ref}:{p}']).stdout
844
+ file_path.write_text(content)
845
+ git.run(['add', '-f', '--', str(file_path)])
846
+ else:
847
+ try:
848
+ regex_patterns.append(re.compile(p))
849
+ except re.error as e:
850
+ raise GitNestedError(f"Invalid filter pattern '{p}': {e}")
851
+
852
+ if regex_patterns:
853
+ all_blobs = (
854
+ git.check_output(['ls-tree', '-r', '--name-only', nested_commit_ref], may_fail=True) or ''
855
+ ).splitlines()
856
+ for blob_path in all_blobs:
857
+ if not any(pattern.fullmatch(blob_path) for pattern in regex_patterns):
858
+ continue
859
+ file_path = subdir / blob_path
860
+ if file_path.exists():
861
+ continue
862
+ file_path.parent.mkdir(parents=True, exist_ok=True)
863
+ content = git.run(['cat-file', 'blob', f'{nested_commit_ref}:{blob_path}']).stdout
864
+ file_path.write_text(content)
865
+ git.run(['add', '-f', '--', str(file_path)])
866
+
867
+ # Create .gitnested.levelN files for nested-in-nested repositories
868
+ # Level will be auto-detected based on existing level files
869
+ self.create_level_gitnested_files(git, flags, subdir, head_commit)
870
+
871
+ # Finally, update .gitnested file
872
+ self.verbose(f"Put info into '{gitnested}' file.", flags)
873
+ self.update_gitrepo_file(
874
+ git=git,
875
+ flags=flags,
876
+ config=config,
877
+ gitnested=gitnested,
878
+ upstream_head_commit=upstream_head_commit,
879
+ nested_commit_ref=nested_commit_ref,
880
+ head_commit=head_commit,
881
+ command=command,
882
+ )
883
+ git.run(['add', '-f', '--', gitnested])
884
+
885
+ # If this is a .gitnested.levelN file, also update the regular .gitnested
886
+ # so that when the nested repo is operated on directly, it has current info
887
+ if '.gitnested.level' in str(gitnested):
888
+ regular_gitnested = gitnested.parent / '.gitnested'
889
+ if regular_gitnested.exists():
890
+ self.verbose(f"Also updating {regular_gitnested} for consistency", flags)
891
+ self.update_gitrepo_file(
892
+ git=git,
893
+ flags=flags,
894
+ config=config,
895
+ gitnested=regular_gitnested,
896
+ upstream_head_commit=upstream_head_commit,
897
+ nested_commit_ref=nested_commit_ref,
898
+ head_commit=head_commit,
899
+ command=command,
900
+ )
901
+ git.run(['add', '-f', '--', regular_gitnested])
902
+
903
+ # Check if there are changes to commit
904
+ result = git.run(['diff', '--cached', '--quiet'], may_fail=True)
905
+ has_changes = result.returncode != 0
906
+
907
+ if has_changes:
908
+ msg = flags.message or self.build_commit_message(
909
+ git=git,
910
+ config=config,
911
+ upstream_head_commit=upstream_head_commit,
912
+ nested_commit_ref=nested_commit_ref,
913
+ subdir=subdir,
914
+ command=command,
915
+ )
916
+
917
+ self.verbose("Commit .gitnested update to the current branch.", flags)
918
+ if head_commit != 'none':
919
+ if flags.message_file:
920
+ git.run(['commit', '--file', flags.message_file])
921
+ else:
922
+ git.run(['commit', '-m', msg])
923
+ else:
924
+ tree = git.check_output(['write-tree'])
925
+
926
+ if flags.message_file:
927
+ commit_sha = git.check_output(['commit-tree', '--file', flags.message_file, tree])
928
+ else:
929
+ commit_sha = git.check_output(['commit-tree', '-m', msg, tree])
930
+
931
+ git.run(['reset', '--hard', commit_sha])
932
+ else:
933
+ self.verbose("No changes to commit for .gitnested update", flags)
934
+
935
+ self.remove_worktree(git, subdir_worktree)
936
+
937
+ self.create_nested_ref(git, self.sanitize_subref(git, str(subdir)), 'commit', nested_commit_ref)
938
+
939
+ def get_status(self, git: GitRunner, flags: Flags, git_tmp: Path) -> tuple[str, List[tuple[Path, NestedConfig]]]:
940
+ """Get nested repository status
941
+
942
+ Returns:
943
+ tuple: (output_text, list of (subdir, config) tuples)
944
+ """
945
+ output = []
946
+
947
+ nesteds = self.find_all_nested_repositories(git, flags)
948
+ count = len(nesteds)
949
+ if not flags.quiet:
950
+ if count == 0:
951
+ return "No nested repositories.\n", []
952
+ ies = 'ies' if count != 1 else 'y'
953
+ output.append(f"{count} nested repositor{ies}:\n")
954
+
955
+ status_list = []
956
+ for subdir in nesteds:
957
+ subdir = subdir if isinstance(subdir, Path) else Path(subdir)
958
+ subref = self.sanitize_subref(git, str(subdir))
959
+
960
+ gitrepo = subdir / '.gitnested'
961
+ if not gitrepo.is_file():
962
+ output.append(f"'{subdir}' is not a nested repository\n")
963
+ continue
964
+
965
+ refs_fetch = f'refs/nested/{subref}/fetch'
966
+ upstream_head = git.check_output(['rev-parse', '--short', refs_fetch], may_fail=True)
967
+
968
+ config = self.read_config(gitrepo, flags)
969
+
970
+ if flags.fetch:
971
+ self.do_fetch(git, flags, config, subref)
972
+
973
+ status_list.append((subdir, config))
974
+
975
+ if flags.quiet:
976
+ output.append(f"{subdir}\n")
977
+ continue
978
+
979
+ output.append(f"Git nested repository '{subdir}':\n")
980
+
981
+ if git.branch_exists(f'nested/{subref}'):
982
+ output.append(f" Nested Branch: nested/{subref}\n")
983
+
984
+ remote = f'nested/{subref}'
985
+ url = git.check_output(['config', f'remote.{remote}.url'], may_fail=True)
986
+ if url:
987
+ output.append(f" Remote Name: nested/{subref}\n")
988
+
989
+ output.append(f" Remote URL: {config.remote}\n")
990
+ if upstream_head:
991
+ output.append(f" Upstream Ref: {upstream_head}\n")
992
+ output.append(f" Tracking Branch: {config.branch}\n")
993
+
994
+ if config.commit:
995
+ short = git.check_output(['rev-parse', '--short', config.commit])
996
+ output.append(f" Pulled Commit: {short}\n")
997
+
998
+ if config.parent:
999
+ short = git.check_output(['rev-parse', '--short', config.parent])
1000
+ output.append(f" Pull Parent: {short}\n")
1001
+
1002
+ worktree_list = git.check_output(['worktree', 'list'], may_fail=True) or ''
1003
+ for line in worktree_list.splitlines():
1004
+ if f'{git_tmp}/nested/{subdir}' in line:
1005
+ output.append(f" Worktree: {line}\n")
1006
+
1007
+ if flags.verbose:
1008
+ output.append(self.format_refs(git, subref))
1009
+
1010
+ output.append("\n")
1011
+
1012
+ return ''.join(output), status_list
1013
+
1014
+ def format_refs(self, git: GitRunner, subref: str) -> str:
1015
+ """Format refs for status"""
1016
+ output = []
1017
+ show_ref = git.check_output(['show-ref'], may_fail=True) or ''
1018
+
1019
+ for line in show_ref.splitlines():
1020
+ m = re.match(rf'^([0-9a-f]+)\s+refs/nested/{subref}/([a-z]+)', line)
1021
+ if m:
1022
+ sha_full = m.group(1)
1023
+ sha = git.check_output(['rev-parse', '--short', sha_full])
1024
+ ref_type = m.group(2)
1025
+ ref = f'refs/nested/{subref}/{ref_type}'
1026
+
1027
+ labels = {
1028
+ 'branch': 'Branch Ref',
1029
+ 'commit': 'Commit Ref',
1030
+ 'fetch': 'Fetch Ref',
1031
+ 'pull': 'Pull Ref',
1032
+ 'push': 'Push Ref',
1033
+ }
1034
+ if ref_type in labels:
1035
+ output.append(f" {labels[ref_type]:14} {sha} ({ref})\n")
1036
+
1037
+ if output:
1038
+ return " Refs:\n" + ''.join(output)
1039
+ return ""
1040
+
1041
+ def do_clean(self, git: GitRunner, flags: Flags, subdir: Path, git_tmp: Path) -> List[str]:
1042
+ """Clean nested branches and refs"""
1043
+ items = []
1044
+ subref = self.sanitize_subref(git, str(subdir))
1045
+ branch = f'nested/{subref}'
1046
+ ref = f'refs/heads/{branch}'
1047
+ subdir_worktree = git_tmp / branch
1048
+
1049
+ self.remove_worktree(git, subdir_worktree)
1050
+
1051
+ if git.branch_exists(branch):
1052
+ git.run(['update-ref', '-d', ref])
1053
+ items.append(f"branch '{branch}'")
1054
+
1055
+ if flags.force:
1056
+ suffix = '' if flags.all else f'{subref}/'
1057
+
1058
+ show_ref = git.check_output(['show-ref'], may_fail=True) or ''
1059
+ for line in show_ref.splitlines():
1060
+ if not line:
1061
+ continue
1062
+ parts = line.split()
1063
+ if len(parts) >= 2:
1064
+ ref = parts[1]
1065
+ if ref.startswith(f'refs/nested/{suffix}') or ref.startswith(
1066
+ f'refs/original/refs/heads/nested/{suffix}'
1067
+ ):
1068
+ git.run(['update-ref', '-d', ref])
1069
+
1070
+ return items
1071
+
1072
+ # -------------------------------------
1073
+ # Support Functions
1074
+ # -------------------------------------
1075
+
1076
+ def guess_subdir(self, remote: str) -> str:
1077
+ """Guess subdirectory name from remote URL"""
1078
+ if not remote:
1079
+ raise GitNestedError("No remote specified for guessing subdir")
1080
+ name = Path(remote).name
1081
+ if name.endswith('.git'):
1082
+ name = name[:-4]
1083
+ return name
1084
+
1085
+ def sanitize_subref(self, git: GitRunner, ref: str) -> str:
1086
+ """Sanitize subref to be a valid git ref"""
1087
+
1088
+ def is_valid_ref(ref):
1089
+ result = git.run(['check-ref-format', f'nested/{ref}'], may_fail=True)
1090
+ return result.returncode == 0
1091
+
1092
+ # Check if already valid (check-ref-format succeeds), so no encoding needed
1093
+ if is_valid_ref(ref):
1094
+ return ref
1095
+
1096
+ # URL encode the subdir
1097
+ sanitized = quote(ref, safe='/')
1098
+ # Remove forbidden characters
1099
+ for c in ['~', '..', ' ', '/']:
1100
+ sanitized = sanitized.replace(c, '_')
1101
+ # Remove forbidden leading characters
1102
+ if sanitized.startswith('.') or sanitized.startswith('-'):
1103
+ sanitized = '_' + sanitized[1:]
1104
+ if sanitized.endswith('.lock'): # .lock ending is not allowed
1105
+ sanitized = sanitized[:-5] + '_lock'
1106
+
1107
+ # Ref cannot end with a dot
1108
+ if sanitized.endswith('.'):
1109
+ sanitized = sanitized[:-1]
1110
+
1111
+ if not is_valid_ref(sanitized):
1112
+ raise GitNestedError(f"Can't determine valid subref from '{ref}'.")
1113
+ return sanitized
1114
+
1115
+ def read_config(self, gitnested: Path, flags: Flags) -> NestedConfig:
1116
+ """Read .gitnested file"""
1117
+ if not gitnested.is_file():
1118
+ raise GitNestedError(f"No '{gitnested}' file.")
1119
+
1120
+ config = NestedConfig.from_file(gitnested)
1121
+
1122
+ # Apply explicitly given flags
1123
+ if flags.remote:
1124
+ config.remote = flags.remote
1125
+ if flags.branch:
1126
+ config.branch = flags.branch
1127
+ if flags.method:
1128
+ config.method = flags.method
1129
+
1130
+ return config
1131
+
1132
+ def update_gitrepo_file(
1133
+ self,
1134
+ git: GitRunner,
1135
+ flags: Flags,
1136
+ config: NestedConfig,
1137
+ gitnested: Path,
1138
+ upstream_head_commit: str,
1139
+ nested_commit_ref: str,
1140
+ head_commit: str,
1141
+ command: str,
1142
+ ):
1143
+ """Update .gitnested YAML file"""
1144
+ initial = not gitnested.exists()
1145
+
1146
+ if initial:
1147
+ # Try to recreate from parent commit
1148
+ result = git.run(['cat-file', '-e', f'{head_commit}:{gitnested}'], may_fail=True)
1149
+ if result.returncode == 0:
1150
+ content = git.check_output(['cat-file', '-p', f'{head_commit}:{gitnested}'])
1151
+ gitnested.write_text(content)
1152
+ initial = False
1153
+
1154
+ # Load existing data or create new
1155
+ data = self._read_yaml_config(gitnested) if gitnested.exists() else {}
1156
+ nested = data.setdefault('nested', {})
1157
+
1158
+ def should_update(override_value):
1159
+ """Check if a config field should be updated"""
1160
+ return (flags.update and override_value) or (command in ['push', 'clone'] and override_value)
1161
+
1162
+ # Update fields
1163
+ nested['commit'] = upstream_head_commit
1164
+ nested['method'] = flags.method or config.method or 'merge'
1165
+ nested['cmdver'] = VERSION
1166
+ if flags.filter:
1167
+ nested['filter'] = flags.filter
1168
+ if initial or should_update(flags.remote):
1169
+ nested['remote'] = config.remote
1170
+ # For clone command, always update branch (including force reclone to different branch)
1171
+ if initial or command == 'clone' or should_update(flags.branch):
1172
+ nested['branch'] = config.branch
1173
+ if head_commit and nested_commit_ref:
1174
+ nested_commit = git.check_output(['rev-parse', nested_commit_ref])
1175
+ if upstream_head_commit == nested_commit:
1176
+ nested['parent'] = head_commit
1177
+
1178
+ # Write YAML file and stage it
1179
+ self._write_yaml_config(gitnested, data)
1180
+ git.run(['add', '-f', '--', gitnested])
1181
+
1182
+ # -------------------------------------
1183
+ # Checks and Validations
1184
+ # -------------------------------------
1185
+
1186
+ def check_repository(self, git: GitRunner, command: str) -> tuple[Path, str]:
1187
+ """Check that repository is ready
1188
+
1189
+ Returns:
1190
+ tuple: (git_tmp, head_commit)
1191
+ """
1192
+ if command in ['version']:
1193
+ return None, None
1194
+
1195
+ try:
1196
+ git.run(['rev-parse', '--git-dir'])
1197
+ except GitNestedError:
1198
+ raise GitNestedError("Not inside a git repository.")
1199
+
1200
+ git_common_dir = git.check_output(['rev-parse', '--git-common-dir'])
1201
+ git_tmp = Path(git_common_dir) / 'tmp'
1202
+
1203
+ # Get original branch
1204
+ current_branch = git.check_output(['symbolic-ref', '--short', '--quiet', 'HEAD'], may_fail=True)
1205
+ if current_branch.startswith('nested/'):
1206
+ raise GitNestedError(f"Can't '{command}' while a nested branch is checked out: {current_branch}")
1207
+
1208
+ if not current_branch or current_branch in ['HEAD']:
1209
+ raise GitNestedError("Must be on a branch to run this command.")
1210
+
1211
+ inside_worktree = git.check_output(['rev-parse', '--is-inside-work-tree'], may_fail=True)
1212
+ if inside_worktree != 'true':
1213
+ raise GitNestedError("Must run inside a git working tree.")
1214
+
1215
+ self.check_worktree_clean(git, command)
1216
+
1217
+ parents = git.check_output(['rev-parse', '--show-prefix'], may_fail=True)
1218
+ if parents:
1219
+ raise GitNestedError("Need to run nested command from top level directory of the repo.")
1220
+
1221
+ # Store the current HEAD (may fail in case of an empty repository)
1222
+ head_commit = git.check_output(['rev-parse', 'HEAD'], may_fail=True)
1223
+
1224
+ return git_tmp, head_commit
1225
+
1226
+ def check_worktree_clean(self, git: GitRunner, command: str):
1227
+ """Ensure working copy has no uncommitted changes"""
1228
+ if command not in ['clone', 'init', 'pull', 'push', 'branch', 'commit']:
1229
+ return
1230
+
1231
+ pwd = Path.cwd()
1232
+ git.run(['update-index', '-q', '--ignore-submodules', '--refresh'], may_fail=True)
1233
+
1234
+ # Check for unstaged changes
1235
+ result = git.run(['diff-files', '--quiet', '--ignore-submodules'], may_fail=True)
1236
+ if result.returncode != 0:
1237
+ raise GitNestedError(f"Can't {command} nested repository. Unstaged changes. ({pwd})")
1238
+
1239
+ if command == 'clone' and not git.rev_exists('HEAD'):
1240
+ # This may happen when cloning into an empty repository
1241
+ return
1242
+
1243
+ result = git.run(['rev-parse', '--verify', 'HEAD'])
1244
+ if result.returncode != 0:
1245
+ raise GitNestedError(f"HEAD cannot be verified ({pwd})")
1246
+
1247
+ result = git.run(['diff-index', '--quiet', '--ignore-submodules', 'HEAD'], may_fail=True)
1248
+ if result.returncode != 0:
1249
+ raise GitNestedError(f"Can't {command} nested repository. Working tree has changes. ({pwd})")
1250
+
1251
+ result = git.run(['diff-index', '--quiet', '--cached', '--ignore-submodules', 'HEAD'], may_fail=True)
1252
+ if result.returncode != 0:
1253
+ raise GitNestedError(f"Can't {command} nested repository. Index has changes. ({pwd})")
1254
+
1255
+ def check_subdir_for_init(self, git: GitRunner, subdir: Path, gitnested: Path):
1256
+ """Check subdir is ready for init"""
1257
+ if not subdir.exists():
1258
+ raise GitNestedError(f"'{subdir}' does not exist.")
1259
+
1260
+ if gitnested.exists():
1261
+ raise GitNestedError(f"'{subdir}' is already a nested repository.")
1262
+
1263
+ if not git.is_tracked(subdir):
1264
+ raise GitNestedError(f"'{subdir}' exists, but nothing is tracked by git.")
1265
+
1266
+ # -------------------------------------
1267
+ # Git Helpers
1268
+ # -------------------------------------
1269
+
1270
+ def create_worktree(self, git: GitRunner, branch: str, git_tmp: Path) -> Path:
1271
+ """Create a worktree for the given branch"""
1272
+ subdir_worktree = git_tmp / branch
1273
+ git.run(['worktree', 'add', subdir_worktree, branch])
1274
+ return subdir_worktree
1275
+
1276
+ def remove_worktree(self, git: GitRunner, worktree: Path):
1277
+ """Remove worktree"""
1278
+ if not worktree:
1279
+ return
1280
+
1281
+ worktree_path = Path(worktree)
1282
+ if not worktree_path.is_dir():
1283
+ return
1284
+
1285
+ with chdir(worktree):
1286
+ self.check_worktree_clean(git, 'clean')
1287
+
1288
+ shutil.rmtree(worktree)
1289
+ git.run(['worktree', 'prune'])
1290
+
1291
+ def delete_branch(self, git: GitRunner, branch: str, git_tmp: Path):
1292
+ """Delete a branch"""
1293
+ subdir_worktree = git_tmp / branch
1294
+ self.remove_worktree(git, subdir_worktree)
1295
+ git.run(['branch', '-D', branch], may_fail=True)
1296
+
1297
+ # -------------------------------------
1298
+ # Utility Functions
1299
+ # -------------------------------------
1300
+
1301
+ def find_all_nested_repositories(self, git: GitRunner, flags: Flags) -> list[Path]:
1302
+ """Find all nested repositories in repository"""
1303
+ tracked_files = git.check_output(['ls-files'])
1304
+ gitnesteds = sorted(Path(line).parent for line in tracked_files.splitlines() if line.endswith('.gitnested'))
1305
+ if not flags.ALL:
1306
+ # Filter the paths to contain only outermost nested repository paths
1307
+ gitnesteds = [
1308
+ p for p in gitnesteds if not any(p.is_relative_to(other) for other in gitnesteds if p != other)
1309
+ ]
1310
+ return gitnesteds
1311
+
1312
+ def get_upstream_branch(self, git: GitRunner, config: NestedConfig) -> str:
1313
+ """Determine upstream default branch"""
1314
+ remote_branches = git.check_output(['ls-remote', '--symref', config.remote], may_fail=True)
1315
+ if not remote_branches:
1316
+ raise GitNestedError(f"Command failed: 'git ls-remote --symref {config.remote}'.")
1317
+ upstream_branch = re.search(r"^ref:\s+refs/heads/(\S+)\s+HEAD", remote_branches, re.MULTILINE)
1318
+ if not upstream_branch:
1319
+ raise GitNestedError("Problem finding remote default head branch.")
1320
+ return upstream_branch.group(1)
1321
+
1322
+ def get_default_branch(self, git: GitRunner) -> str:
1323
+ """Get git's default branch name"""
1324
+ default_branch = git.check_output(['config', '--get', 'init.defaultbranch'], may_fail=True)
1325
+ if default_branch:
1326
+ return default_branch
1327
+ return "main"
1328
+
1329
+ def build_commit_message(
1330
+ self,
1331
+ git: GitRunner,
1332
+ config: NestedConfig,
1333
+ upstream_head_commit: str,
1334
+ nested_commit_ref: str,
1335
+ subdir: Path,
1336
+ command: str,
1337
+ ) -> str:
1338
+ """Generate commit message"""
1339
+ upstream_commit = 'none'
1340
+ if upstream_head_commit:
1341
+ upstream_commit = git.check_output(['rev-parse', '--short', upstream_head_commit])
1342
+ commit = git.check_output(['rev-parse', '--short', nested_commit_ref])
1343
+ return textwrap.dedent(
1344
+ f"""\
1345
+ git nested {command}
1346
+
1347
+ nested:
1348
+ subdir: "{subdir}"
1349
+ merged: "{commit}"
1350
+ upstream:
1351
+ remote: "{config.remote}"
1352
+ branch: "{config.branch}"
1353
+ commit: "{upstream_commit}"
1354
+ git-nested:
1355
+ version: "{VERSION}"
1356
+ """
1357
+ )
1358
+
1359
+
1360
+ class GitNestedCommand:
1361
+ """Handles command-line interface and user I/O"""
1362
+
1363
+ def __init__(self):
1364
+ self.git = GitRunner()
1365
+ self.repo = GitNestedRepo()
1366
+ # For backward compatibility with tests
1367
+ self.flags = Flags()
1368
+
1369
+ # -------------------------------------
1370
+ # User I/O methods
1371
+ # -------------------------------------
1372
+
1373
+ def verbose(self, msg: str, flags: Flags = None):
1374
+ """Print verbose messages"""
1375
+ flags = flags or self.flags
1376
+ if flags.verbose:
1377
+ self.say(f"* {msg}", flags)
1378
+
1379
+ def say(self, msg: str, flags: Flags = None):
1380
+ """Print message unless quiet"""
1381
+ flags = flags or self.flags
1382
+ if not flags.quiet:
1383
+ print(msg)
1384
+
1385
+ def error(self, msg: str):
1386
+ """Print error and exit"""
1387
+ print(f"git-nested: {msg}", file=sys.stderr)
1388
+ raise GitNestedError(msg, print_to_stderr=False)
1389
+
1390
+ def usage_error(self, msg: str):
1391
+ """Print usage error and exit"""
1392
+ print(f"git-nested: {msg}", file=sys.stderr)
1393
+ sys.exit(1)
1394
+
1395
+ # -------------------------------------
1396
+ # main
1397
+ # -------------------------------------
1398
+
1399
+ def main(self, args):
1400
+ """Main entry point"""
1401
+ command, flags, subdir, upstream, nested_commit_ref = self.parse_args(args)
1402
+ self.git.check()
1403
+ git_tmp, head_commit = self.repo.check_repository(self.git, command)
1404
+
1405
+ # Handle --all flag
1406
+ if flags.all and command not in ['status']:
1407
+ if flags.branch:
1408
+ self.error("options --branch and --all are not compatible")
1409
+
1410
+ nesteds = self.repo.find_all_nested_repositories(self.git, flags)
1411
+ for subdir_path in nesteds:
1412
+ self.dispatch_command(command, flags, subdir_path, upstream, nested_commit_ref, git_tmp, head_commit)
1413
+ else:
1414
+ self.dispatch_command(command, flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit)
1415
+
1416
+ def parse_args(self, args_list):
1417
+ """Parse command line arguments
1418
+
1419
+ Returns:
1420
+ tuple: (command, flags, subdir, upstream, nested_commit_ref)
1421
+ """
1422
+
1423
+ parser = argparse.ArgumentParser(prog='git nested')
1424
+ parser.add_argument('--version', action='store_true')
1425
+ parser.add_argument('-q', '--quiet', action='store_true')
1426
+ parser.add_argument('-v', '--verbose', action='count')
1427
+
1428
+ valid_command_options = {
1429
+ 'branch': ['all', 'fetch', 'force'],
1430
+ 'clean': ['ALL', 'all', 'force'],
1431
+ 'clone': ['branch', 'filter', 'force', 'message', 'method', 'filter'],
1432
+ 'commit': ['fetch', 'force', 'message', 'msg_file'],
1433
+ 'fetch': ['all', 'branch', 'force', 'remote'],
1434
+ 'init': ['branch', 'remote', 'method'],
1435
+ 'pull': ['all', 'branch', 'force', 'message', 'method', 'remote', 'update'],
1436
+ 'push': ['all', 'branch', 'commit', 'force', 'message', 'method', 'remote', 'squash', 'update'],
1437
+ 'status': ['ALL', 'all', 'fetch'],
1438
+ 'version': [],
1439
+ }
1440
+
1441
+ subparsers = parser.add_subparsers(dest='command')
1442
+
1443
+ command_subparsers = {command: subparsers.add_parser(command) for command in valid_command_options.keys()}
1444
+
1445
+ def add_subparser_args(command_subparser, command):
1446
+ opts = valid_command_options.get(command)
1447
+ if 'all' in opts:
1448
+ command_subparser.add_argument('-a', '--all', action='store_true', dest='all_flag')
1449
+ if 'ALL' in opts:
1450
+ command_subparser.add_argument('-A', '--ALL', action='store_true', dest='ALL_flag')
1451
+ if 'branch' in opts:
1452
+ command_subparser.add_argument('-b', '--branch', dest='branch')
1453
+ if 'commit' in opts:
1454
+ command_subparser.add_argument('-c', '--commit', action='store_true')
1455
+ if 'force' in opts:
1456
+ command_subparser.add_argument('-f', '--force', action='store_true')
1457
+ if 'filter' in opts:
1458
+ command_subparser.add_argument('--filter', action='append')
1459
+ if 'fetch' in opts:
1460
+ command_subparser.add_argument('-F', '--fetch', action='store_true', dest='fetch_flag')
1461
+ if 'method' in opts:
1462
+ command_subparser.add_argument('-M', '--method', dest='method')
1463
+ if 'message' in opts:
1464
+ command_subparser.add_argument('-m', '--message', dest='message')
1465
+ if 'msg_file' in opts:
1466
+ command_subparser.add_argument('--file', dest='msg_file')
1467
+ if 'remote' in opts:
1468
+ command_subparser.add_argument('-r', '--remote', dest='remote')
1469
+ if 'squash' in opts:
1470
+ command_subparser.add_argument('-s', '--squash', action='store_true')
1471
+ if 'update' in opts:
1472
+ command_subparser.add_argument('-u', '--update', action='store_true')
1473
+
1474
+ for command in valid_command_options.keys():
1475
+ command_subparser = command_subparsers.get(command)
1476
+
1477
+ add_subparser_args(command_subparser, command)
1478
+
1479
+ # Few commands also accept positional args
1480
+ if command in ['branch', 'commit', 'fetch', 'init', 'pull', 'push']:
1481
+ command_subparser.add_argument('subdir', nargs='?')
1482
+ if command in ['clean']:
1483
+ command_subparser.add_argument('subdir', nargs='?')
1484
+ if command in ['clone']:
1485
+ command_subparser.add_argument('upstream')
1486
+ command_subparser.add_argument('subdir', nargs='?')
1487
+ if command in ['commit']:
1488
+ command_subparser.add_argument('nested_commit_ref', nargs='?')
1489
+ if command in ['push']:
1490
+ command_subparser.add_argument('nested_branch', nargs='?')
1491
+
1492
+ # parse arguments
1493
+ # Note: subparsers handle positional and optional arguments for each command
1494
+ args = parser.parse_args(args_list)
1495
+
1496
+ if args.version:
1497
+ args.command = 'version'
1498
+
1499
+ if not args.command:
1500
+ self.usage_error("Missing command")
1501
+
1502
+ ###############################
1503
+ # Process the args
1504
+ ###############################
1505
+ upstream = getattr(args, 'upstream', None)
1506
+ subdir = getattr(args, 'subdir', None)
1507
+ nested_commit_ref = getattr(args, 'nested_commit_ref', None)
1508
+
1509
+ if upstream and not subdir:
1510
+ subdir = self.repo.guess_subdir(upstream)
1511
+
1512
+ # Create flags object
1513
+ flags = Flags()
1514
+ flags.all = getattr(args, 'all_flag', False)
1515
+ flags.ALL = getattr(args, 'ALL_flag', False)
1516
+ flags.commit = getattr(args, 'commit', False)
1517
+ flags.filter = getattr(args, 'filter', [])
1518
+ flags.force = getattr(args, 'force', False)
1519
+ flags.fetch = getattr(args, 'fetch_flag', False)
1520
+ flags.squash = getattr(args, 'squash', False)
1521
+ flags.update = getattr(args, 'update', False)
1522
+ flags.quiet = getattr(args, 'quiet', False)
1523
+ flags.verbose = getattr(args, 'verbose', 0)
1524
+
1525
+ if flags.ALL:
1526
+ flags.all = True
1527
+
1528
+ def supported(opt):
1529
+ return opt in valid_command_options.get(args.command)
1530
+
1531
+ def supported_and_set(opt):
1532
+ return supported(opt) and getattr(args, opt, None) is not None
1533
+
1534
+ if supported_and_set('branch'):
1535
+ flags.branch = args.branch
1536
+ if supported_and_set('remote'):
1537
+ flags.remote = args.remote
1538
+ if supported_and_set('method'):
1539
+ flags.method = args.method
1540
+ if supported_and_set('message'):
1541
+ flags.message = args.message
1542
+ if supported_and_set('msg_file'):
1543
+ flags.message_file = args.msg_file
1544
+
1545
+ # Check for invalid usage
1546
+ if supported_and_set('msg_file') and not Path(args.msg_file).is_file():
1547
+ self.error(f"Commit msg file at {args.msg_file} not found")
1548
+ if supported_and_set('message') and supported_and_set('msg_file'):
1549
+ self.error("fatal: options '-m' and '--file' cannot be used together")
1550
+
1551
+ # Set command
1552
+ command = args.command
1553
+
1554
+ if flags.update and not (flags.branch or flags.remote):
1555
+ self.usage_error("Can't use '--update' without '--branch' or '--remote'.")
1556
+
1557
+ return command, flags, subdir, upstream, nested_commit_ref
1558
+
1559
+ def dispatch_command(self, command, flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit):
1560
+ """Dispatch to command function"""
1561
+ commands = {
1562
+ 'clone': lambda: self.cmd_clone(flags, subdir, upstream, head_commit),
1563
+ 'init': lambda: self.cmd_init(flags, subdir, upstream, head_commit),
1564
+ 'pull': lambda: self.cmd_pull(flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit),
1565
+ 'push': lambda: self.cmd_push(flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit),
1566
+ 'fetch': lambda: self.cmd_fetch(flags, subdir, upstream),
1567
+ 'branch': lambda: self.cmd_branch(flags, subdir, upstream, git_tmp),
1568
+ 'commit': lambda: self.cmd_commit(flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit),
1569
+ 'status': lambda: self.cmd_status(flags, git_tmp),
1570
+ 'clean': lambda: self.cmd_clean(flags, subdir, upstream, git_tmp),
1571
+ 'version': lambda: self.cmd_version(),
1572
+ }
1573
+
1574
+ func = commands.get(command)
1575
+ if func:
1576
+ func()
1577
+ else:
1578
+ self.usage_error(f"Unknown command: {command}")
1579
+
1580
+ # -------------------------------------
1581
+ # Commands
1582
+ # -------------------------------------
1583
+
1584
+ def cmd_clone(self, flags, subdir, upstream, head_commit):
1585
+ """Clone a remote repository into a local subdirectory"""
1586
+ subdir, gitnested, subref, config = self.setup_command('clone', flags, subdir, upstream)
1587
+
1588
+ up_to_date, config, nested_commit_ref, upstream_head_commit = self.repo.do_clone(
1589
+ git=self.git,
1590
+ flags=flags,
1591
+ config=config,
1592
+ subdir=subdir,
1593
+ gitnested=gitnested,
1594
+ subref=subref,
1595
+ )
1596
+
1597
+ if not up_to_date:
1598
+ self.verbose(f"Commit the new '{subdir}/' content.", flags)
1599
+ self.repo.commit_nested_branch(
1600
+ git=self.git,
1601
+ flags=flags,
1602
+ config=config,
1603
+ subdir=subdir,
1604
+ gitnested=gitnested,
1605
+ nested_commit_ref=nested_commit_ref,
1606
+ upstream_head_commit=upstream_head_commit,
1607
+ head_commit=head_commit,
1608
+ subdir_worktree=None,
1609
+ command='clone',
1610
+ )
1611
+
1612
+ if up_to_date:
1613
+ self.say(f"Nested repository '{subdir}' is up to date with upstream branch '{config.branch}'.", flags)
1614
+ else:
1615
+ self.say(f"Nested repository '{config.remote}' ({config.branch}) cloned into '{subdir}'.", flags)
1616
+
1617
+ def cmd_init(self, flags, subdir, upstream, head_commit):
1618
+ """Initialize a subdirectory as a nested repo"""
1619
+ subdir, gitnested, subref, config = self.setup_command('init', flags, subdir, upstream)
1620
+
1621
+ # Set defaults
1622
+ config.remote = config.remote or 'none'
1623
+ config.branch = config.branch or self.repo.get_default_branch(self.git)
1624
+
1625
+ self.repo.do_init(
1626
+ git=self.git,
1627
+ flags=flags,
1628
+ config=config,
1629
+ subdir=subdir,
1630
+ gitnested=gitnested,
1631
+ head_commit=head_commit,
1632
+ subref=subref,
1633
+ )
1634
+
1635
+ remote_msg = (
1636
+ "(with no remote)." if config.remote == 'none' else f"with remote '{config.remote}' ({config.branch})."
1637
+ )
1638
+ self.say(f"Nested repository created from '{subdir}' {remote_msg}", flags)
1639
+
1640
+ def cmd_pull(self, flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit):
1641
+ """Pull upstream changes to the nested repo"""
1642
+ subdir, gitnested, subref, config = self.setup_command('pull', flags, subdir, upstream)
1643
+
1644
+ if flags.force:
1645
+ up_to_date, config, nested_commit_ref, upstream_head_commit = self.repo.do_clone(
1646
+ git=self.git,
1647
+ flags=flags,
1648
+ config=config,
1649
+ subdir=subdir,
1650
+ gitnested=gitnested,
1651
+ subref=subref,
1652
+ )
1653
+ if not up_to_date:
1654
+ self.repo.commit_nested_branch(
1655
+ git=self.git,
1656
+ flags=flags,
1657
+ config=config,
1658
+ subdir=subdir,
1659
+ gitnested=gitnested,
1660
+ nested_commit_ref=nested_commit_ref,
1661
+ upstream_head_commit=upstream_head_commit,
1662
+ head_commit=head_commit,
1663
+ subdir_worktree=None,
1664
+ command='clone',
1665
+ )
1666
+ self.say(f"Nested repository '{subdir}' pulled from '{config.remote}' ({config.branch}).", flags)
1667
+ return
1668
+
1669
+ success, nested_commit_ref, subdir_worktree, error_msg = self.repo.do_pull(
1670
+ git=self.git,
1671
+ flags=flags,
1672
+ config=config,
1673
+ subdir=subdir,
1674
+ gitnested=gitnested,
1675
+ git_tmp=git_tmp,
1676
+ subref=subref,
1677
+ )
1678
+
1679
+ if not success and nested_commit_ref is None:
1680
+ self.say(f"Nested repository '{subdir}' is up to date with upstream branch '{config.branch}'.", flags)
1681
+ return
1682
+
1683
+ if not success:
1684
+ # Print the error message to stdout
1685
+ self.say(error_msg, flags)
1686
+ # Merge/rebase failed
1687
+ method = flags.method or config.method
1688
+ branch_name = f'nested/{subref}'
1689
+ rebase_step = "git rebase --continue" if method == 'rebase' else "git commit"
1690
+ commit_cmd = (
1691
+ f"git nested commit --file={flags.message_file} {subdir}"
1692
+ if flags.message_file
1693
+ else f"git nested commit {subdir}"
1694
+ )
1695
+ rebase_note = ""
1696
+ if method == 'rebase':
1697
+ rebase_note = textwrap.dedent(
1698
+ f"""
1699
+
1700
+ After you have performed the steps above you can push your local changes
1701
+ without repeating the rebase by:
1702
+ 1. git nested push {subdir} {branch_name}
1703
+ """
1704
+ )
1705
+ msg = textwrap.dedent(
1706
+ f"""\
1707
+ You will need to finish the pull by hand. A new working tree has been
1708
+ created at {subdir_worktree} so that you can resolve the conflicts
1709
+ shown in the output above.
1710
+
1711
+ This is the common conflict resolution workflow:
1712
+
1713
+ 1. cd {subdir_worktree}
1714
+ 2. Resolve the conflicts (see "git status").
1715
+ 3. "git add" the resolved files.
1716
+ 4. {rebase_step}
1717
+ 5. If there are more conflicts, restart at step 2.
1718
+ 6. cd {Path.cwd()}
1719
+ 7. {commit_cmd}
1720
+ {rebase_note}
1721
+ See "git help {method}" for details.
1722
+
1723
+ Alternatively, you can abort the pull and reset back to where you started:
1724
+
1725
+ 1. git nested clean {subdir}
1726
+
1727
+ See "git help nested" for more help.
1728
+ """
1729
+ )
1730
+ print(msg, file=sys.stderr)
1731
+ sys.exit(1)
1732
+
1733
+ self.verbose(f"Commit the new '{nested_commit_ref}' content.", flags)
1734
+ upstream_head_commit = self.git.check_output(['rev-parse', f'refs/nested/{subref}/fetch'])
1735
+ self.repo.commit_nested_branch(
1736
+ git=self.git,
1737
+ flags=flags,
1738
+ config=config,
1739
+ subdir=subdir,
1740
+ gitnested=gitnested,
1741
+ nested_commit_ref=nested_commit_ref,
1742
+ upstream_head_commit=upstream_head_commit,
1743
+ head_commit=head_commit,
1744
+ subdir_worktree=subdir_worktree,
1745
+ command='pull',
1746
+ )
1747
+
1748
+ self.say(f"Nested repository '{subdir}' pulled from '{config.remote}' ({config.branch}).", flags)
1749
+
1750
+ def cmd_push(self, flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit):
1751
+ """Push local nested repo changes upstream"""
1752
+ subdir, gitnested, subref, config = self.setup_command('push', flags, subdir, upstream)
1753
+
1754
+ self.verbose(f"Pushing {subdir} to upstream", flags)
1755
+ success, branch_name, subdir_worktree, branch_created, new_commit = self.repo.do_push(
1756
+ git=self.git,
1757
+ flags=flags,
1758
+ config=config,
1759
+ subdir=subdir,
1760
+ gitnested=gitnested,
1761
+ git_tmp=git_tmp,
1762
+ subref=subref,
1763
+ branch=nested_commit_ref,
1764
+ )
1765
+
1766
+ if not success and subdir_worktree:
1767
+ # Rebase failed
1768
+ self.say('The "git rebase" command failed', flags)
1769
+ sys.exit(1)
1770
+
1771
+ if not success:
1772
+ self.say(f"Nested repository '{subdir}' has no new commits to push.", flags)
1773
+ return
1774
+
1775
+ if branch_created:
1776
+ self.verbose(f"Remove branch 'nested/{subref}'.", flags)
1777
+ self.repo.delete_branch(self.git, f'nested/{subref}', git_tmp)
1778
+
1779
+ # Update .gitnested if --commit or if --remote/--branch specified
1780
+ if flags.commit:
1781
+ self.verbose(f"Put updates into '{subdir}/.gitnested' file.", flags)
1782
+
1783
+ self.repo.update_gitrepo_file(
1784
+ git=self.git,
1785
+ flags=flags,
1786
+ config=config,
1787
+ gitnested=gitnested,
1788
+ upstream_head_commit=new_commit,
1789
+ nested_commit_ref=new_commit,
1790
+ head_commit=head_commit,
1791
+ command='push',
1792
+ )
1793
+
1794
+ msg = flags.message or self.repo.build_commit_message(
1795
+ git=self.git,
1796
+ config=config,
1797
+ upstream_head_commit=new_commit,
1798
+ nested_commit_ref=new_commit,
1799
+ subdir=subdir,
1800
+ command='push',
1801
+ )
1802
+
1803
+ if flags.message_file:
1804
+ self.git.run(['commit', '--file', flags.message_file])
1805
+ else:
1806
+ self.git.run(['commit', '-m', msg])
1807
+
1808
+ self.say(
1809
+ f"Nested repository '{subdir}' pushed to '{config.remote}' ({branch_name}).",
1810
+ flags,
1811
+ )
1812
+
1813
+ def cmd_fetch(self, flags, subdir, upstream):
1814
+ """Fetch a nested repo's remote branch"""
1815
+ subdir, _, subref, config = self.setup_command('fetch', flags, subdir, upstream)
1816
+
1817
+ if config.remote == 'none':
1818
+ self.say(f"Ignored '{subdir}', no remote.", flags)
1819
+ else:
1820
+ self.repo.do_fetch(self.git, flags, config, subref)
1821
+ self.say(f"Fetched '{subdir}' from '{config.remote}' ({config.branch}).", flags)
1822
+
1823
+ def cmd_branch(self, flags, subdir, upstream, git_tmp):
1824
+ """Create a branch containing the local nested repo commits"""
1825
+ subdir, gitnested, subref, config = self.setup_command('branch', flags, subdir, upstream)
1826
+
1827
+ if flags.fetch:
1828
+ self.repo.do_fetch(self.git, flags, config, subref)
1829
+
1830
+ branch = f'nested/{subref}'
1831
+ if flags.force:
1832
+ self.repo.delete_branch(self.git, branch, git_tmp)
1833
+ elif self.git.branch_exists(branch):
1834
+ self.error(f"Branch '{branch}' already exists. Use '--force' to override.")
1835
+
1836
+ subdir_worktree = self.repo.create_nested_branch(
1837
+ git=self.git,
1838
+ flags=flags,
1839
+ config=config,
1840
+ branch=branch,
1841
+ subdir=subdir,
1842
+ gitnested=gitnested,
1843
+ git_tmp=git_tmp,
1844
+ subref=subref,
1845
+ command='branch',
1846
+ )
1847
+ self.say(f"Created branch '{branch}' and worktree '{subdir_worktree}'.", flags)
1848
+
1849
+ def cmd_commit(self, flags, subdir, upstream, nested_commit_ref, git_tmp, head_commit):
1850
+ """Commit a merged nested branch"""
1851
+ subdir, gitnested, subref, config = self.setup_command('commit', flags, subdir, upstream)
1852
+
1853
+ if flags.fetch:
1854
+ self.repo.do_fetch(self.git, flags, config, subref)
1855
+
1856
+ refs_fetch = f'refs/nested/{subref}/fetch'
1857
+ if not self.git.rev_exists(refs_fetch):
1858
+ self.error(f"Can't find ref '{refs_fetch}'. Try using -F.")
1859
+
1860
+ upstream_head_commit = self.git.check_output(['rev-parse', refs_fetch])
1861
+ nested_commit_ref = nested_commit_ref or f'nested/{subref}'
1862
+
1863
+ self.repo.commit_nested_branch(
1864
+ git=self.git,
1865
+ flags=flags,
1866
+ config=config,
1867
+ subdir=subdir,
1868
+ gitnested=gitnested,
1869
+ nested_commit_ref=nested_commit_ref,
1870
+ upstream_head_commit=upstream_head_commit,
1871
+ head_commit=head_commit,
1872
+ subdir_worktree=git_tmp / f'nested/{subref}',
1873
+ command='commit',
1874
+ )
1875
+ self.say(f"Nested commit '{nested_commit_ref}' committed as subdir '{subdir}/' to current branch.", flags)
1876
+
1877
+ def cmd_status(self, flags, git_tmp):
1878
+ """Get status of a nested repo (or all of them)"""
1879
+ output, _ = self.repo.get_status(self.git, flags, git_tmp)
1880
+ self.say(output, flags)
1881
+
1882
+ def cmd_clean(self, flags, subdir, upstream, git_tmp):
1883
+ """Remove branches, remotes and refs for a nested repo"""
1884
+ subdir, _, _, _ = self.setup_command('clean', flags, subdir, upstream)
1885
+
1886
+ for item in self.repo.do_clean(self.git, flags, subdir, git_tmp):
1887
+ self.say(f"Removed {item}.", flags)
1888
+
1889
+ def cmd_version(self):
1890
+ """Print version information"""
1891
+ print(f"git-nested Version: {VERSION}")
1892
+ print("Copyright 2026 Thorsten Klein <thorsten.klein.git@gmail.com>")
1893
+ print("https://github.com/thorsten-klein/git-nested")
1894
+ print(Path(__file__).resolve())
1895
+ print(f"Git Version: {self.git.get_version()}")
1896
+
1897
+ # -------------------------------------
1898
+ # Setup
1899
+ # -------------------------------------
1900
+
1901
+ def setup_command(self, command, flags, subdir, upstream):
1902
+ """Setup command with parameters
1903
+
1904
+ Returns:
1905
+ tuple: (subdir, gitnested, subref, config)
1906
+ """
1907
+ if not subdir:
1908
+ self.error("subdir not set")
1909
+
1910
+ subdir = Path(subdir)
1911
+
1912
+ # Check for absolute path
1913
+ if subdir.is_absolute():
1914
+ self.usage_error(f"The subdir '{subdir}' should not be absolute path.")
1915
+
1916
+ subref = self.repo.sanitize_subref(self.git, str(subdir))
1917
+
1918
+ # Determine the appropriate .gitnested file to use by detecting existing level files
1919
+ gitnested = subdir / '.gitnested'
1920
+
1921
+ # Search for .gitnested.levelN files to determine the correct level
1922
+ level_files = sorted([
1923
+ f for f in subdir.glob('.gitnested.level*') if f.is_file() and f.name.startswith('.gitnested.level')
1924
+ ])
1925
+
1926
+ if level_files:
1927
+ # Use the highest level file found (for deeply nested repos)
1928
+ gitnested = level_files[-1]
1929
+ self.verbose(f"Using {gitnested} for nested repository (detected from existing level files)", flags)
1930
+
1931
+ # Check for existing worktree
1932
+ if not flags.force:
1933
+ self.verbose(f"Check for worktree with branch nested/{subdir}", flags)
1934
+ worktree_list = self.git.check_output(['worktree', 'list'], may_fail=True) or ''
1935
+
1936
+ has_worktree = False
1937
+ worktree_path = None
1938
+ for line in worktree_list.splitlines():
1939
+ if f'[nested/{subdir}]' in line:
1940
+ has_worktree = True
1941
+ worktree_path = line.split()[0]
1942
+ break
1943
+
1944
+ if command in ['commit'] and not has_worktree:
1945
+ self.error("There is no worktree available, use the branch command first")
1946
+ elif command not in ['branch', 'clean', 'commit', 'push'] and has_worktree:
1947
+ if gitnested.exists():
1948
+ self.error(
1949
+ textwrap.dedent(
1950
+ f"""\
1951
+ There is already a worktree with branch nested/{subdir}.
1952
+ Use the --force flag to override this check or perform a nested clean
1953
+ to remove the worktree."""
1954
+ )
1955
+ )
1956
+ else:
1957
+ self.error(
1958
+ textwrap.dedent(
1959
+ f"""\
1960
+ There is already a worktree with branch nested/{subdir}.
1961
+ Use the --force flag to override this check or remove the worktree with
1962
+ 1. rm -rf {worktree_path}
1963
+ 2. git worktree prune
1964
+ """
1965
+ )
1966
+ )
1967
+
1968
+ # Read .gitnested file if exists
1969
+ if command not in ['clone', 'init']:
1970
+ config = self.repo.read_config(gitnested, flags)
1971
+ else:
1972
+ config = NestedConfig()
1973
+ if upstream:
1974
+ config.remote = upstream
1975
+
1976
+ # Apply overrides (from command line flags)
1977
+ if flags.remote:
1978
+ config.remote = flags.remote
1979
+ if flags.branch:
1980
+ config.branch = flags.branch
1981
+
1982
+ return subdir, gitnested, subref, config
1983
+
1984
+
1985
+ def main():
1986
+ """Main entry point"""
1987
+ try:
1988
+ app = GitNestedCommand()
1989
+ app.main(sys.argv[1:])
1990
+ except GitNestedError:
1991
+ sys.exit(1)
1992
+ except KeyboardInterrupt:
1993
+ sys.exit(130)
1994
+
1995
+
1996
+ # Backward compatibility alias
1997
+ GitNested = GitNestedCommand
1998
+
1999
+
2000
+ if __name__ == '__main__':
2001
+ main()