git-nested 1.0.3__tar.gz → 1.0.4__tar.gz
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-1.0.3 → git_nested-1.0.4}/PKG-INFO +1 -1
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/PKG-INFO +1 -1
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.py +68 -2
- {git_nested-1.0.3 → git_nested-1.0.4}/test/scenarios/test_filter.py +33 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.codecov.yml +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.fish.rc +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.gitattributes +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.github/workflows/publish.yml +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.github/workflows/test.yml +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.gitignore +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/.rc +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/CONTRIBUTING.md +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/LICENSE +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/README.md +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/docs/diagrams.md +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git-nested +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/SOURCES.txt +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/dependency_links.txt +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/entry_points.txt +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/requires.txt +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/lib/git_nested.egg-info/top_level.txt +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/pyproject.toml +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/setup.cfg +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/share/completion.bash +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/share/enable-completion.sh +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/share/git-nested.fish +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/share/zsh-completion/_git-nested +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/conftest.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/scenarios/test_extraction.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/scenarios/test_filemode.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_branch.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_clean.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_clone.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_completion.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_encode.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_error.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_fetch.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_gitignore.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_init.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_nested_in_nested.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_pull.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_push.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_rebase.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_reclone.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_status.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_submodule.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/test_version.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/test/unittest/test_logging.py +0 -0
- {git_nested-1.0.3 → git_nested-1.0.4}/uv.lock +0 -0
|
@@ -12,6 +12,7 @@ import subprocess
|
|
|
12
12
|
import argparse
|
|
13
13
|
import re
|
|
14
14
|
import shutil
|
|
15
|
+
import tempfile
|
|
15
16
|
import textwrap
|
|
16
17
|
import yaml
|
|
17
18
|
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
@@ -474,12 +475,22 @@ class GitNestedRepo:
|
|
|
474
475
|
method = flags.method or config.method
|
|
475
476
|
refs_fetch = f'refs/nested/{subref}/fetch'
|
|
476
477
|
|
|
478
|
+
merge_target = refs_fetch
|
|
479
|
+
if config.filter:
|
|
480
|
+
# The local nested branch only ever contained the filtered subset of
|
|
481
|
+
# files. Merging the raw (unfiltered) upstream fetch against it makes
|
|
482
|
+
# git see files that were "deleted" locally (because they were never
|
|
483
|
+
# pulled in) as delete/modify conflicts whenever upstream touches
|
|
484
|
+
# them. Build a filtered view of the fetched commit so only files
|
|
485
|
+
# that actually matter to the local checkout can conflict.
|
|
486
|
+
merge_target = self.build_filtered_commit(git, subdir_worktree, config, upstream_head_commit)
|
|
487
|
+
|
|
477
488
|
try:
|
|
478
489
|
if method == 'rebase':
|
|
479
|
-
git.run(['rebase',
|
|
490
|
+
git.run(['rebase', merge_target, branch], cwd=subdir_worktree, print_error=False)
|
|
480
491
|
else:
|
|
481
492
|
# If parent is empty, allow unrelated histories (for nested-in-nested repos)
|
|
482
|
-
merge_cmd = ['merge',
|
|
493
|
+
merge_cmd = ['merge', merge_target]
|
|
483
494
|
if not config.parent:
|
|
484
495
|
merge_cmd.append('--allow-unrelated-histories')
|
|
485
496
|
git.run(merge_cmd, cwd=subdir_worktree, print_error=False)
|
|
@@ -492,6 +503,61 @@ class GitNestedRepo:
|
|
|
492
503
|
|
|
493
504
|
return True, nested_commit_ref, subdir_worktree, None
|
|
494
505
|
|
|
506
|
+
def build_filtered_commit(self, git: GitRunner, cwd: Path, config: NestedConfig, commit: str) -> str:
|
|
507
|
+
"""Build a throwaway commit whose tree only contains the paths matched by
|
|
508
|
+
config.filter, applying the exact same literal/regex rules used when the
|
|
509
|
+
filtered content is written into the working tree.
|
|
510
|
+
|
|
511
|
+
The new commit keeps `commit` as its sole parent so its ancestry (and
|
|
512
|
+
thus merge-base detection against the local nested branch) is unaffected.
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
sha of the filtered commit
|
|
516
|
+
"""
|
|
517
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
518
|
+
index_file = Path(tmp) / 'index'
|
|
519
|
+
env = os.environ.copy()
|
|
520
|
+
env['GIT_INDEX_FILE'] = str(index_file)
|
|
521
|
+
|
|
522
|
+
git.run(['read-tree', '--empty'], cwd=cwd, env=env)
|
|
523
|
+
|
|
524
|
+
def already_indexed(path: str) -> bool:
|
|
525
|
+
return bool(git.check_output(['ls-files', '--stage', '--', path], cwd=cwd, env=env, may_fail=True))
|
|
526
|
+
|
|
527
|
+
regex_patterns = []
|
|
528
|
+
for p in config.filter:
|
|
529
|
+
obj_type = git.check_output(['cat-file', '-t', f'{commit}:{p}'], may_fail=True, cwd=cwd)
|
|
530
|
+
if obj_type == 'tree':
|
|
531
|
+
git.run(['read-tree', f'--prefix={p}', f'{commit}:{p}'], cwd=cwd, env=env)
|
|
532
|
+
elif obj_type == 'blob':
|
|
533
|
+
mode = git.check_output(['ls-tree', commit, '--', p]).split()[0]
|
|
534
|
+
blob_sha = git.check_output(['rev-parse', f'{commit}:{p}'], cwd=cwd)
|
|
535
|
+
git.run(['update-index', '--add', '--cacheinfo', f'{mode},{blob_sha},{p}'], cwd=cwd, env=env)
|
|
536
|
+
else:
|
|
537
|
+
try:
|
|
538
|
+
regex_patterns.append(re.compile(p))
|
|
539
|
+
except re.error as e:
|
|
540
|
+
raise GitNestedError(f"Invalid filter pattern '{p}': {e}")
|
|
541
|
+
|
|
542
|
+
if regex_patterns:
|
|
543
|
+
tree_listing = git.check_output(['ls-tree', '-r', commit], may_fail=True, cwd=cwd) or ''
|
|
544
|
+
for line in tree_listing.splitlines():
|
|
545
|
+
meta, blob_path = line.split('\t', 1)
|
|
546
|
+
if not any(pattern.fullmatch(blob_path) for pattern in regex_patterns):
|
|
547
|
+
continue
|
|
548
|
+
if already_indexed(blob_path):
|
|
549
|
+
continue
|
|
550
|
+
mode, _obj_type, blob_sha = meta.split()
|
|
551
|
+
git.run(
|
|
552
|
+
['update-index', '--add', '--cacheinfo', f'{mode},{blob_sha},{blob_path}'], cwd=cwd, env=env
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
filtered_tree = git.check_output(['write-tree'], cwd=cwd, env=env)
|
|
556
|
+
filtered_commit = git.check_output(
|
|
557
|
+
['commit-tree', '-p', commit, '-m', 'git-nested: filtered view for merge', filtered_tree], cwd=cwd
|
|
558
|
+
)
|
|
559
|
+
return filtered_commit
|
|
560
|
+
|
|
495
561
|
def do_push(
|
|
496
562
|
self,
|
|
497
563
|
git: GitRunner,
|
|
@@ -52,6 +52,39 @@ def test_consumer_with_filter(foo_bar_cloned):
|
|
|
52
52
|
assert (env.workspace / 'foo' / 'leg' / 'subdirC').exists()
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def test_pull_conflicts_on_change_to_filtered_out_file(foo_bar_cloned):
|
|
56
|
+
"""Reproduces bug: pulling upstream changes to a file excluded by --filter
|
|
57
|
+
causes a merge conflict, even though the file was never present locally.
|
|
58
|
+
"""
|
|
59
|
+
env = foo_bar_cloned
|
|
60
|
+
|
|
61
|
+
create_upstream_repo(env.upstream / 'leg')
|
|
62
|
+
clone_repo(str(env.upstream / 'leg'), env.workspace / 'leg')
|
|
63
|
+
|
|
64
|
+
env.add_new_files('subdirA/somefile', cwd=env.workspace / 'leg')
|
|
65
|
+
env.add_new_files('subdirB/somefile', cwd=env.workspace / 'leg')
|
|
66
|
+
env.run(['git', 'push'], cwd=env.workspace / 'leg')
|
|
67
|
+
|
|
68
|
+
# clone leg with a filter that excludes subdirB entirely
|
|
69
|
+
cmd_git_nested(f'clone {env.upstream}/leg leg --filter=subdirA', cwd=env.workspace / 'foo')
|
|
70
|
+
assert (env.workspace / 'foo' / 'leg' / 'subdirA').exists()
|
|
71
|
+
assert not (env.workspace / 'foo' / 'leg' / 'subdirB').exists()
|
|
72
|
+
|
|
73
|
+
# upstream modifies a file inside the *excluded* subdirB - the local clone
|
|
74
|
+
# never had this file, so this change should not conflict with anything
|
|
75
|
+
env.modify_files('subdirB/somefile', cwd=env.workspace / 'leg')
|
|
76
|
+
env.run(['git', 'push'], cwd=env.workspace / 'leg')
|
|
77
|
+
|
|
78
|
+
# pulling should succeed cleanly since subdirB is filtered out locally
|
|
79
|
+
result = cmd_git_nested('pull leg', cwd=env.workspace / 'foo', check=False)
|
|
80
|
+
assert result.returncode == 0, (
|
|
81
|
+
f"Expected pull of a change to a filtered-out file to succeed without conflicts, "
|
|
82
|
+
f"but it failed:\n{result.stdout}\n{result.stderr}"
|
|
83
|
+
)
|
|
84
|
+
assert (env.workspace / 'foo' / 'leg' / 'subdirA').exists()
|
|
85
|
+
assert not (env.workspace / 'foo' / 'leg' / 'subdirB').exists()
|
|
86
|
+
|
|
87
|
+
|
|
55
88
|
def test_filter_pull(foo_bar_cloned):
|
|
56
89
|
"""Test that a filter added to .gitnested is applied on the next pull"""
|
|
57
90
|
env = foo_bar_cloned
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|