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()
|