From 20305aa73f1ce6991d4954ae64363d0da09f544f Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Sat, 7 Nov 2026 19:44:51 +0100 Subject: [PATCH 1/2] feat: add `verify` to check a nested repo against patches or diffs `git nested verify patches|diff [--] ...` applies the files with `git apply` to the upstream commit in a temporary worktree and compares the result with in HEAD. It fails if they differ. `patches` then writes the missing numbered patch; `diff` writes the whole diff to --output. Fixes #187 Co-Authored-By: Claude Opus 5.5 --- doc/architecture/diagrams.md | 56 ++++++ doc/cli/index.md | 1 + doc/cli/verify.md | 60 +++++++ doc/index.md | 2 + man/man1/git-nested.1 | 3 + src/git_nested/checks.py | 2 +- src/git_nested/cli/location.py | 7 + src/git_nested/cli/parser.py | 3 + src/git_nested/cli/setup.py | 2 +- src/git_nested/cli/spec.py | 36 +++- src/git_nested/commands/__init__.py | 2 + src/git_nested/commands/verify.py | 190 ++++++++++++++++++++ src/git_nested/constants.py | 3 + src/git_nested/models.py | 4 + tests/e2e/test_verify.py | 258 +++++++++++++++++++++++++++ tests/unit/test_completion_engine.py | 4 +- 16 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 doc/cli/verify.md create mode 100644 src/git_nested/commands/verify.py create mode 100644 tests/e2e/test_verify.py diff --git a/doc/architecture/diagrams.md b/doc/architecture/diagrams.md index 49bd3bf..d591f3e 100644 --- a/doc/architecture/diagrams.md +++ b/doc/architecture/diagrams.md @@ -770,6 +770,62 @@ sequenceDiagram --- +## verify + +```mermaid +sequenceDiagram + participant User + participant git-nested + participant Git + + User->>git-nested: git nested verify patches ... + git-nested->>Git: rev-parse --show-prefix + Note over git-nested: Below the top level: rev-parse --show-toplevel,
paths rebased, then run from there + git-nested->>Git: rev-parse --git-dir + git-nested->>Git: rev-parse --git-dir + git-nested->>Git: symbolic-ref --short --quiet HEAD + git-nested->>Git: rev-parse --is-inside-work-tree + git-nested->>Git: rev-parse HEAD + Note over git-nested: Read .gitnested config + + git-nested->>Git: rev-parse HEAD: + git-nested->>Git: rev-list ^{commit} -1 + opt upstream commit not here + git-nested->>Git: fetch --no-tags --quiet + git-nested->>Git: rev-parse FETCH_HEAD^0 + git-nested->>Git: update-ref refs/nested//fetch FETCH_HEAD^0 + end + opt path, filter or exclude configured + Note over git-nested: Build a commit with only the nested part of upstream + git-nested->>Git: commit-tree -p + end + + git-nested->>Git: worktree add --detach --no-checkout .git/tmp/verify/ + git-nested->>Git: reset -q --hard (in the worktree) + loop For each patch or diff + git-nested->>Git: apply --index (in the worktree) + end + git-nested->>Git: commit --allow-empty --no-verify (in the worktree) + git-nested->>Git: diff --quiet HEAD -- . ':(exclude,glob)**/.gitnested*' + + opt it differs + git-nested->>Git: diff --stat HEAD + alt patches + git-nested->>Git: diff --binary --full-index HEAD | apply --index + git-nested->>Git: commit -m 'TODO: title' -m 'TODO: description' + git-nested->>Git: format-patch -1 --start-number= -o HEAD + else diff with --output + git-nested->>Git: diff --binary + Note over git-nested: Written to the --output file + end + end + + git-nested->>Git: worktree prune + Note over git-nested: The worktree is removed, unless --keep +``` + +--- + ## clean ```mermaid diff --git a/doc/cli/index.md b/doc/cli/index.md index 6cb1faf..b5a000d 100644 --- a/doc/cli/index.md +++ b/doc/cli/index.md @@ -17,6 +17,7 @@ Each command has its own page. This page lists what they share. | [`fetch`](fetch.md) | fetch upstream content for a nested repository | | [`status`](status.md) | report on the nested repositories of this repository | | [`diff`](diff.md) | show a nested repository's local changes against upstream | +| [`verify`](verify.md) | check that a nested repository is its upstream commit plus patches or diffs | | [`branch`](branch.md) | create a branch holding the local nested commits | | [`run`](run.md) | run a git command in the worktree of a nested repository | | [`commit`](commit.md) | add a nested branch to the current history as one commit | diff --git a/doc/cli/verify.md b/doc/cli/verify.md new file mode 100644 index 0000000..47b10b8 --- /dev/null +++ b/doc/cli/verify.md @@ -0,0 +1,60 @@ +# `git nested verify` + +Check that a nested repository is its upstream commit plus patches or diffs. + +```text +git nested verify [] patches [--] [...] +git nested verify [] diff [--] [...] +``` + +## What it does + +1. Checks out the upstream commit from `.gitnested` in a temporary + worktree. Fetches it only if it is missing. +2. Applies the files there, in order, with `git apply`. +3. Compares the result with `` in `HEAD`. `.gitnested` files are + left out. + +Without files, it checks that `` has no local changes. + +The files can be git patches (`git format-patch`) or plain diffs +(`git diff`, `diff -u`). Paths are relative to ``. + +Your working tree and branches are not touched. + +### When it differs + +The command fails and lists the changed files. + +- `patches`: it writes the missing patch, numbered after the others, next + to the last patch or into `--output`. Fill in its TODO title and + description, rename it, and commit it. +- `diff`: with `--output`, it writes the whole diff to that file. + +## Arguments + +| Argument | Meaning | +|---|---| +| `` | the nested repository | +| `patches` or `diff` | the kind of files | +| ``, `` | the files to apply, in order | + +## Options + +| Option | Meaning | +|---|---| +| `-o`, `--output ` | `patches`: folder for the missing patch. `diff`: file for the whole diff | +| `-k`, `--keep` | keep the temporary worktree | + +## Examples + +```bash +git nested verify ext/lib patches -- patches/lib/*.patch +git nested verify -o patches/lib ext/lib patches +git nested verify ext/lib diff lib.diff +git nested verify -o lib.diff ext/lib diff lib.diff +``` + +## See also + +- [`diff`](diff.md) diff --git a/doc/index.md b/doc/index.md index c1b84c2..8f9f68d 100644 --- a/doc/index.md +++ b/doc/index.md @@ -75,6 +75,7 @@ The full reference of the command line. There is one page per command. | [`fetch`](cli/fetch.md) | Download upstream changes, change no file | | [`status`](cli/status.md) | Show all nested repositories | | [`diff`](cli/diff.md) | Compare a nested repository with upstream | +| [`verify`](cli/verify.md) | Check local changes against patch files | | [`branch`](cli/branch.md) | Build a branch with the nested history | | [`run`](cli/run.md) | Run git in that branch's worktree | | [`commit`](cli/commit.md) | Add that branch back as one commit | @@ -99,6 +100,7 @@ cli/push cli/fetch cli/status cli/diff +cli/verify cli/branch cli/run cli/commit diff --git a/man/man1/git-nested.1 b/man/man1/git-nested.1 index ef92993..6dbc28f 100644 --- a/man/man1/git-nested.1 +++ b/man/man1/git-nested.1 @@ -96,6 +96,9 @@ run a git command in the worktree of a nested repository .B status report on the nested repositories of this repository .TP +.B verify +check that a nested repository is its upstream commit plus patches or diffs +.TP .B version print version information .PP diff --git a/src/git_nested/checks.py b/src/git_nested/checks.py index 9607e0f..311ee88 100644 --- a/src/git_nested/checks.py +++ b/src/git_nested/checks.py @@ -76,7 +76,7 @@ def check_worktree_clean(git: GitRunner, command: str) -> None: def warn_uncommitted_subdir_changes(git: GitRunner, command: str, flags: Flags, subdir: Path) -> None: """Warn when `command` ignores uncommitted changes in `subdir`.""" - if command not in ['branch', 'diff', 'push'] or (command == 'push' and flags.commit): + if command not in ['branch', 'diff', 'push', 'verify'] or (command == 'push' and flags.commit): return git.run(['update-index', '-q', '--ignore-submodules', '--refresh'], may_fail=True) diff --git a/src/git_nested/cli/location.py b/src/git_nested/cli/location.py index a1deda2..a6c34df 100644 --- a/src/git_nested/cli/location.py +++ b/src/git_nested/cli/location.py @@ -49,6 +49,8 @@ def at_top_level(git: GitRunner, command: str, ctx: CommandContext) -> AbstractC # Path options are made absolute, so moving to the top level does not change what they name. ctx.flags.dir = _absolute(ctx.flags.dir) ctx.flags.message_file = _absolute(ctx.flags.message_file) + ctx.flags.output_dir = _absolute(ctx.flags.output_dir) + ctx.patches = _all_absolute(ctx.patches) # Remotes given as relative paths are rebased onto the top level instead. # Relative rather than absolute: a remote ends up in .gitnested, and a # relative one keeps working in every clone of the parent repository. @@ -73,6 +75,11 @@ def _absolute(path: str | None) -> str | None: return os.path.abspath(path) if path else None # noqa: PTH100 +def _all_absolute(paths: list[str] | None) -> list[str] | None: + """Each of `paths` made absolute.""" + return [os.path.abspath(path) for path in paths] if paths else paths # noqa: PTH100 + + def _remote_from(prefix: str, remote: str | None) -> str | None: """`remote`, typed below the top level at `prefix`, as seen from the top level. diff --git a/src/git_nested/cli/parser.py b/src/git_nested/cli/parser.py index 458fc76..22d3c89 100644 --- a/src/git_nested/cli/parser.py +++ b/src/git_nested/cli/parser.py @@ -79,6 +79,7 @@ def _flags_from_args(args: argparse.Namespace) -> Flags: no_squash=getattr(args, 'no_squash', False), squash=getattr(args, 'squash', False), update=getattr(args, 'update', False), + keep=getattr(args, 'keep', False), quiet=getattr(args, 'quiet', False), # argparse's `count` action leaves the attribute at None when the flag # is absent, and the output layer wants a real count. @@ -150,5 +151,7 @@ def parse_args(git: GitRunner, args_list: list[str]) -> tuple[str, CommandContex config_key=getattr(args, 'key', None), config_value=getattr(args, 'value', None), git_args=getattr(args, 'git_args', None), + patches=getattr(args, 'patches', None), + verify_mode=getattr(args, 'verify_mode', None), ) return args.command, context diff --git a/src/git_nested/cli/setup.py b/src/git_nested/cli/setup.py index d919747..e391c5d 100644 --- a/src/git_nested/cli/setup.py +++ b/src/git_nested/cli/setup.py @@ -48,7 +48,7 @@ def _check_existing_worktree(git: GitRunner, command: str, subdir: Path, gitnest output.error( f"{subdir.as_posix()}: no worktree to commit from; run 'git nested branch {subdir.as_posix()}' first" ) - elif command not in ['branch', 'clean', 'commit', 'push', 'run'] and worktree_path is not None: + elif command not in ['branch', 'clean', 'commit', 'push', 'run', 'verify'] and worktree_path is not None: output.error(messages.worktree_exists(subdir, worktree_path, prunable=gitnested.exists())) diff --git a/src/git_nested/cli/spec.py b/src/git_nested/cli/spec.py index 75df924..12bd60f 100644 --- a/src/git_nested/cli/spec.py +++ b/src/git_nested/cli/spec.py @@ -8,7 +8,7 @@ from __future__ import annotations -from ..constants import COMPLETION_SHELLS +from ..constants import COMPLETION_SHELLS, VERIFY_MODES # Which flags each command accepts. The keys are also the command list: # the subparsers, and the completion candidates, are built from them. @@ -76,6 +76,7 @@ 'update', ], 'status': ['ALL', 'all', 'fetch', 'short'], + 'verify': ['keep', 'output_dir'], 'version': [], } @@ -96,6 +97,7 @@ 'repair': "point a nested repository's sync point back into the current history", 'run': "run a git command in the worktree of a nested repository", 'status': "report on the nested repositories of this repository", + 'verify': "check that a nested repository is its upstream commit plus patches or diffs", 'version': "print version information", } @@ -136,6 +138,11 @@ {'action': 'store_true', 'help': "record the pushed commit in .gitnested; needs a push to the tracked branch"}, ), ('dry_run', ('--dry-run',), {'action': 'store_true', 'dest': 'dry_run', 'help': "show what would be pushed"}), + ( + 'keep', + ('-k', '--keep'), + {'action': 'store_true', 'help': "keep the temporary worktree"}, + ), ('force', ('-f', '--force'), {'action': 'store_true', 'help': "force the operation"}), ( 'from_submodule', @@ -190,6 +197,14 @@ ('pin', ('--pin',), {'dest': 'pin', 'help': "nest this commit or tag of the branch, not its newest commit"}), ('msg_file', ('--file',), {'dest': 'msg_file', 'help': "file to read the commit message from"}), ('tag', ('--tag',), {'dest': 'tag', 'help': "tag the pushed upstream commit with this name"}), + ( + 'output_dir', + ('-o', '--output'), + { + 'dest': 'output_dir', + 'help': "patches: folder for the missing patch; diff: file for the whole diff", + }, + ), ('remote', ('-r', '--remote'), {'dest': 'remote', 'help': "upstream remote to push/pull/fetch"}), ( 'short', @@ -226,6 +241,7 @@ ('path', 'path'), ('pin', 'pin'), ('tag', 'tag'), + ('output_dir', 'output_dir'), ] # Most commands take the same first positional, so it gets the same text. @@ -267,6 +283,11 @@ # everything after goes to git untouched, options included. ('git_args', {'nargs': '...', 'help': "the git command to run, with its arguments"}), ], + 'verify': [ + ('subdir', {'help': _SUBDIR_HELP}), + ('verify_mode', {'choices': list(VERIFY_MODES), 'help': "what the files are: numbered patches, or diffs"}), + ('patches', {'nargs': '*', 'help': "the patches or diffs to apply, in order"}), + ], } # The paragraph `git nested --help` opens with. COMMAND_HELP is @@ -348,6 +369,13 @@ "Report every nested repository in this repository: where it comes from, and whether it has local changes. " "With -q, just the paths, one per line." ), + 'verify': ( + "Apply the files to the upstream commit in .gitnested, in a temporary worktree, and compare " + "the result with . The files can be git patches or plain diffs. " + "Without files, check that has no local changes. " + "If it differs, the command fails. 'patches' then writes the missing patch next to the last one " + "(or into --output); 'diff' writes the whole diff to --output." + ), 'version': "Print the git-nested version, and the versions of git and Python it is running on.", } @@ -424,6 +452,12 @@ "git nested status", "git nested status -q", ], + 'verify': [ + "git nested verify ext/lib patches -- patches/lib/*.patch", + "git nested verify -o patches/lib ext/lib patches", + "git nested verify ext/lib diff lib.diff", + "git nested verify -o lib.diff ext/lib diff lib.diff", + ], 'version': [ "git nested version", ], diff --git a/src/git_nested/commands/__init__.py b/src/git_nested/commands/__init__.py index b330e2d..1a6cd80 100644 --- a/src/git_nested/commands/__init__.py +++ b/src/git_nested/commands/__init__.py @@ -31,6 +31,7 @@ repair, run, status, + verify, version, ) @@ -49,5 +50,6 @@ 'repair': repair.cmd_repair, 'run': run.cmd_run, 'status': status.cmd_status, + 'verify': verify.cmd_verify, 'version': version.cmd_version, } diff --git a/src/git_nested/commands/verify.py b/src/git_nested/commands/verify.py new file mode 100644 index 0000000..05131ae --- /dev/null +++ b/src/git_nested/commands/verify.py @@ -0,0 +1,190 @@ +"""Check a nested repository against its upstream commit plus patches or diffs.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from .. import filters, output, worktree +from ..cli import setup +from ..errors import GitNestedError +from ..git import GitRunner +from ..models import CommandContext, NestedConfig +from . import fetch + +# .gitnested files are not part of the upstream content. +GITNESTED_EXCLUDE = ':(exclude,glob)**/.gitnested*' + +# Placeholders in a written patch, for the user to fill in. +TODO_TITLE = "TODO: title" +TODO_DESCRIPTION = "TODO: description" + +# `git commit` needs an identity; CI often has none. +_FALLBACK_IDENTITY = ['-c', 'user.name=git-nested', '-c', 'user.email=git-nested@localhost'] + +_LEADING_NUMBER = re.compile(r'^(\d+)-') + + +def _identity(git: GitRunner) -> list[str]: + """A stand-in identity if git has none.""" + return [] if git.check_output(['config', 'user.email'], may_fail=True) else _FALLBACK_IDENTITY + + +def _commit(git: GitRunner, path: Path, *message: str) -> None: + """Commit in the worktree at `path`, without hooks or signing.""" + paragraphs = [arg for paragraph in message for arg in ('-m', paragraph)] + git.run( + [*_identity(git), 'commit', '--quiet', '--allow-empty', '--no-verify', '--no-gpg-sign', *paragraphs], cwd=path + ) + + +def _counted(count: int, mode: str | None) -> str: + """'1 patch', '2 patches', '1 diff', '2 diffs'.""" + if mode == 'diff': + return f"{count} diff{'' if count == 1 else 's'}" + return f"{count} patch{'' if count == 1 else 'es'}" + + +def _upstream_base(git: GitRunner, config: NestedConfig, subref: str, subdir: Path) -> str: + """The upstream commit from .gitnested, fetched if missing. + + With `path`, `filter` or `exclude`, only the nested part is kept. + """ + commit = config.commit + if not commit: + output.error(f"{subdir.as_posix()}: no upstream commit recorded in .gitnested") + if not git.rev_exists(f'{commit}^{{commit}}'): + _fetch_commit(git, config, subref, subdir) + if config.path: + tree = git.check_output(['rev-parse', f'{commit}:{config.path}']) + return git.check_output(['commit-tree', '-p', commit, '-m', f'{config.path} of {commit}', tree]) + if config.filter or config.exclude: + return filters.build_filtered_commit(git, Path.cwd(), config.filter or [], config.exclude or [], commit) + return commit + + +def _fetch_commit(git: GitRunner, config: NestedConfig, subref: str, subdir: Path) -> None: + """Fetch the upstream commit from .gitnested.""" + commit = config.commit + if config.remote == 'none': + output.error(f"{subdir.as_posix()}: upstream commit {commit} is not here, and there is no remote") + fetch.do_fetch(git, config, subref) + if not git.rev_exists(f'{commit}^{{commit}}'): + output.error(f"{subdir.as_posix()}: upstream commit {commit} is not on {config.remote} ({config.branch})") + + +def _apply(git: GitRunner, path: Path, files: list[Path]) -> None: + """Apply `files` in order in the worktree at `path`, then commit. + + `git apply`, not `git am`: it takes plain diffs too. + """ + for file in files: + output.say(f"applying {_shown(file)}") + result = git.run(['apply', '--index', '--whitespace=nowarn', file.resolve()], may_fail=True, cwd=path) + if result.returncode != 0: + raise GitNestedError(f"{_shown(file)}: does not apply\n{(result.stderr or '').strip()}") + _commit(git, path, "the upstream commit with the patches applied") + + +def _next_number(directory: Path) -> int: + """The next free patch number in `directory`.""" + numbers = [ + int(match.group(1)) for patch in directory.glob('*.patch') if (match := _LEADING_NUMBER.match(patch.name)) + ] + return max(numbers, default=0) + 1 + + +def _diff(git: GitRunner, path: Path, base: str, local_tree: str, *options: str) -> str: + """The diff from `base` to `local_tree`, without .gitnested files.""" + return git.run(['diff', *options, base, local_tree, '--', '.', GITNESTED_EXCLUDE], cwd=path).stdout + + +def _write_missing_patch(git: GitRunner, path: Path, local_tree: str, directory: Path) -> Path: + """Write the missing patch into `directory`.""" + git.run(['apply', '--index'], cwd=path, input=_diff(git, path, 'HEAD', local_tree, '--binary', '--full-index')) + _commit(git, path, TODO_TITLE, TODO_DESCRIPTION) + directory.mkdir(parents=True, exist_ok=True) + number = _next_number(directory) + return Path( + git.check_output( + ['format-patch', '-1', f'--start-number={number}', '-o', directory.resolve(), 'HEAD'], cwd=path + ) + ) + + +def _write_diff(git: GitRunner, path: Path, base: str, local_tree: str, file: Path) -> Path: + """Write the whole diff from upstream to `file`.""" + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(_diff(git, path, base, local_tree, '--binary'), encoding='utf-8', errors='surrogateescape') + return file + + +def _report_difference(ctx: CommandContext, path: Path, base: str, local_tree: str, subdir: Path) -> str: + """The error message when `subdir` differs; writes the missing file if it can.""" + git, files, out = ctx.git, [Path(file) for file in ctx.patches or []], ctx.flags.output_dir + stat = _diff(git, path, 'HEAD', local_tree, '--stat').rstrip() + message = ( + f"{subdir.as_posix()}: differs from its upstream commit with {_counted(len(files), ctx.verify_mode)}\n{stat}" + ) + + if ctx.verify_mode == 'diff': + return _diff_hint(git, path, base, local_tree, message, out) + + directory = Path(out) if out else files[-1].parent if files else None + if directory is None: + return f"{message}\nname the patches, or use --output to have the missing one written" + patch = _write_missing_patch(git, path, local_tree, directory) + return ( + f"{message}\nwrote the missing patch {_shown(patch)}\n" + f"replace '{TODO_TITLE}' and '{TODO_DESCRIPTION}' in it, rename it to match, and commit it" + ) + + +def _diff_hint(git: GitRunner, path: Path, base: str, local_tree: str, message: str, out: str | None) -> str: + """`message` for 'diff' mode, after writing the whole diff to `out` if given.""" + if not out: + return f"{message}\nuse --output to have the whole diff written" + return f"{message}\nwrote the whole diff to {_shown(_write_diff(git, path, base, local_tree, Path(out)))}" + + +def _shown(path: Path) -> str: + """`path`, relative to the current directory if inside it.""" + try: + return path.resolve().relative_to(Path.cwd().resolve()).as_posix() + except ValueError: + return path.as_posix() + + +def cmd_verify(ctx: CommandContext) -> None: + """Check that a nested repo is its upstream commit plus the given files.""" + git = ctx.git + subdir, _, subref, config = setup.setup_command(git, 'verify', ctx.flags, ctx.subdir, ctx.upstream) + files = [Path(file) for file in ctx.patches or []] + for file in files: + if not file.is_file(): + output.usage_error(f"{file.as_posix()}: no such file") + + local_tree = git.check_output(['rev-parse', f'HEAD:{subdir.as_posix()}']) + base = _upstream_base(git, config, subref, subdir) + + path = ctx.tmp / 'verify' / subref + worktree.remove_worktree(git, path) + # --no-checkout and reset: skip the parent's post-checkout hook + git.run(['worktree', 'add', '--quiet', '--detach', '--no-checkout', path, base]) + try: + git.run(['reset', '-q', '--hard'], cwd=path) + _apply(git, path, files) + differs = git.run( + ['diff', '--quiet', 'HEAD', local_tree, '--', '.', GITNESTED_EXCLUDE], cwd=path, may_fail=True + ) + if differs.returncode != 0: + raise GitNestedError(_report_difference(ctx, path, base, local_tree, subdir)) + finally: + if ctx.flags.keep: + output.say(f"kept the worktree at {path.as_posix()}") + else: + worktree.remove_worktree(git, path) + + output.say( + f"{subdir.as_posix()}: matches upstream {config.commit[:9]} with {_counted(len(files), ctx.verify_mode)}" + ) diff --git a/src/git_nested/constants.py b/src/git_nested/constants.py index afc5ce2..1fd4fcf 100644 --- a/src/git_nested/constants.py +++ b/src/git_nested/constants.py @@ -11,3 +11,6 @@ # The shells `git nested completion` can emit a script for. COMPLETION_SHELLS = ('bash', 'zsh', 'fish') + +# What `git nested verify` checks a subdir against: numbered patches, or diffs. +VERIFY_MODES = ('patches', 'diff') diff --git a/src/git_nested/models.py b/src/git_nested/models.py index 6e8f183..62d78c0 100644 --- a/src/git_nested/models.py +++ b/src/git_nested/models.py @@ -46,6 +46,8 @@ class Flags: upstream_commit: str | None = None no_squash: bool = False + keep: bool = False + output_dir: str | None = None tag: str | None = None update: bool = False quiet: bool = False @@ -118,6 +120,8 @@ class CommandContext: config_key: str | None = None config_value: str | None = None git_args: list[str] | None = None + patches: list[str] | None = None + verify_mode: str | None = None git_tmp: Path | None = None head_commit: str | None = None diff --git a/tests/e2e/test_verify.py b/tests/e2e/test_verify.py new file mode 100644 index 0000000..8a7cfa0 --- /dev/null +++ b/tests/e2e/test_verify.py @@ -0,0 +1,258 @@ +"""Tests for git nested verify command""" + +import re + +from conftest import clone_repo, cmd_git_nested, create_upstream_repo + + +def _patch_files(directory): + return sorted(patch.name for patch in directory.glob('*.patch')) + + +def _commit_local_change(env, foo, text): + env.modify_files('bar/Bar', text=text, cwd=foo) + + +def test_verify_without_local_changes(foo_bar_cloned_and_nested): + """A freshly cloned nested repository is its upstream commit with no patches""" + env = foo_bar_cloned_and_nested + result = cmd_git_nested('verify bar patches', cwd=env.workspace / 'foo') + assert result.output.strip().startswith("bar: matches upstream ") + assert result.output.strip().endswith(" with 0 patches") + + +def test_verify_without_patches_reports_the_difference(foo_bar_cloned_and_nested): + """With nowhere to write the missing patch, only the difference is reported""" + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + _commit_local_change(env, foo, 'local') + + result = cmd_git_nested('verify bar patches', cwd=foo, check=False) + assert result.returncode == 1 + assert "bar: differs from its upstream commit with 0 patches" in result.output + assert " Bar | 1 +" in result.output + assert "use --output to have the missing one written" in result.output + assert not (foo / '.git' / 'tmp' / 'verify').exists() + + +def test_verify_writes_the_missing_patch_and_accepts_it(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + patches = foo / 'patches' / 'bar' + _commit_local_change(env, foo, 'first') + + result = cmd_git_nested('verify -o patches/bar bar patches', cwd=foo, check=False) + assert result.returncode == 1 + assert "wrote the missing patch patches/bar/0001-TODO-title.patch" in result.output + assert _patch_files(patches) == ['0001-TODO-title.patch'] + patch = (patches / '0001-TODO-title.patch').read_text() + assert "Subject: [PATCH] TODO: title" in patch + assert "+first" in patch + assert ".gitnested" not in patch + + result = cmd_git_nested(['verify', 'bar', 'patches', '--', patches / '0001-TODO-title.patch'], cwd=foo) + assert "applying patches/bar/0001-TODO-title.patch" in result.output + assert result.output.strip().endswith(" with 1 patch") + + +def test_verify_writes_the_next_patch_next_to_the_last_one(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + patches = foo / 'patches' + _commit_local_change(env, foo, 'first') + cmd_git_nested('verify -o patches bar patches', cwd=foo, check=False) + (patches / '0001-TODO-title.patch').rename(patches / '0001-first.patch') + (patches / 'README').write_text("not a patch\n") + _commit_local_change(env, foo, 'second') + + result = cmd_git_nested('verify bar patches patches/0001-first.patch', cwd=foo, check=False) + assert result.returncode == 1 + assert "differs from its upstream commit with 1 patch\n" in result.output + assert _patch_files(patches) == ['0001-first.patch', '0002-TODO-title.patch'] + patch = (patches / '0002-TODO-title.patch').read_text() + assert "+second" in patch + assert "+first" not in patch + + cmd_git_nested('verify bar patches patches/0001-first.patch patches/0002-TODO-title.patch', cwd=foo) + + +def test_verify_a_patch_that_does_not_apply(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + (foo / 'broken.patch').write_text( + "From: x \nSubject: [PATCH] broken\n\n---\n--- a/none\n+++ b/none\n@@ -1 +1 @@\n-a\n+b\n" + ) + + result = cmd_git_nested('verify bar patches broken.patch', cwd=foo, check=False) + assert result.returncode == 1 + assert "broken.patch: does not apply" in result.output + assert not (foo / '.git' / 'tmp' / 'verify').exists() + + +def test_verify_a_missing_patch_file(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + result = cmd_git_nested('verify bar patches no-such.patch', cwd=env.workspace / 'foo', check=False) + assert result.returncode == 1 + assert "no-such.patch: no such file" in result.output + + +def test_verify_from_a_subdirectory(foo_bar_cloned_and_nested): + """The subdir and the patches are relative to where the command is started""" + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + _commit_local_change(env, foo, 'first') + cmd_git_nested('verify -o patches bar patches', cwd=foo, check=False) + + result = cmd_git_nested('verify . patches ../patches/0001-TODO-title.patch', cwd=foo / 'bar') + assert result.output.strip().endswith(" with 1 patch") + + +def test_verify_keep_leaves_the_worktree(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + result = cmd_git_nested('verify --keep bar patches', cwd=foo) + assert "kept the worktree at .git/tmp/verify/bar" in result.output + assert (foo / '.git' / 'tmp' / 'verify' / 'bar' / 'Bar').is_file() + + # a second run replaces it + cmd_git_nested('verify bar patches', cwd=foo) + assert not (foo / '.git' / 'tmp' / 'verify').exists() + + +def test_verify_ignores_a_nested_branch(foo_bar_cloned_and_nested): + """The patches go into a worktree of its own, so a branch in progress is no obstacle""" + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + cmd_git_nested('branch bar', cwd=foo) + cmd_git_nested('verify bar patches', cwd=foo) + assert (foo / '.git' / 'tmp' / 'nested' / 'bar').is_dir() + + +def test_verify_fetches_a_missing_upstream_commit(foo_bar_cloned_and_nested): + """A fresh clone of the parent repository does not have the upstream commit yet""" + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + env.run(['git', 'push'], cwd=foo) + clone_repo((env.upstream / 'foo').as_posix(), env.workspace / 'foo2') + + result = cmd_git_nested('verify bar patches', cwd=env.workspace / 'foo2') + assert result.output.strip().endswith(" with 0 patches") + + +def test_verify_an_upstream_commit_that_is_gone(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + gitnested = foo / 'bar' / '.gitnested' + gitnested.write_text(re.sub(r'(commit\W+)[0-9a-f]{40}', r'\g<1>' + 'deadbeef' * 5, gitnested.read_text())) + env.run(['git', 'commit', '-qam', 'bad commit'], cwd=foo) + + result = cmd_git_nested('verify bar patches', cwd=foo, check=False) + assert result.returncode == 1 + assert f"bar: upstream commit {'deadbeef' * 5} is not on" in result.output + + +def test_verify_without_an_upstream_commit(env): + env.clone_init() + init = env.workspace / 'init' + cmd_git_nested('init doc', cwd=init) + + result = cmd_git_nested('verify doc patches', cwd=init, check=False) + assert result.returncode == 1 + assert "doc: no upstream commit recorded in .gitnested" in result.output + + +def test_verify_a_missing_commit_without_a_remote(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + env.run(['git', 'push'], cwd=env.workspace / 'foo') + foo2 = env.workspace / 'foo2' + clone_repo((env.upstream / 'foo').as_posix(), foo2) + cmd_git_nested('config bar remote none', cwd=foo2) + env.run(['git', 'commit', '-qam', 'no remote'], cwd=foo2) + + result = cmd_git_nested('verify bar patches', cwd=foo2, check=False) + assert result.returncode == 1 + assert "is not here, and there is no remote" in result.output + + +def test_verify_with_a_path(foo_bar_cloned): + """Only the nested directory of upstream is the base""" + env = foo_bar_cloned + foo = env.workspace / 'foo' + env.add_new_files('sub/file', cwd=env.workspace / 'bar') + env.run(['git', 'push'], cwd=env.workspace / 'bar') + cmd_git_nested(['clone', '--path', 'sub', (env.upstream / 'bar').as_posix(), 'lib'], cwd=foo) + cmd_git_nested('verify lib patches', cwd=foo) + + env.modify_files('lib/file', text='local', cwd=foo) + result = cmd_git_nested('verify -o patches lib patches', cwd=foo, check=False) + assert result.returncode == 1 + assert "diff --git a/file b/file" in (foo / 'patches' / '0001-TODO-title.patch').read_text() + + +def test_verify_with_a_filter(foo_bar_cloned): + env = foo_bar_cloned + foo = env.workspace / 'foo' + create_upstream_repo(env.upstream / 'leg') + clone_repo((env.upstream / 'leg').as_posix(), env.workspace / 'leg') + env.add_new_files('subdirA/file1', cwd=env.workspace / 'leg') + env.add_new_files('subdirB/file1', cwd=env.workspace / 'leg') + env.run(['git', 'push'], cwd=env.workspace / 'leg') + cmd_git_nested(f'clone {env.upstream.as_posix()}/leg leg --filter=subdirA', cwd=foo) + + cmd_git_nested('verify leg patches', cwd=foo) + + +def test_verify_a_plain_diff(foo_bar_cloned_and_nested): + """A diff made with diff(1), not git, applies as well""" + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + original = (foo / 'bar' / 'Bar').read_text() + _commit_local_change(env, foo, 'local') + (foo / 'orig').mkdir() + (foo / 'orig' / 'Bar').write_text(original) + plain = env.run(['diff', '-u', 'orig/Bar', 'bar/Bar'], cwd=foo, check=False).stdout + (foo / 'orig').joinpath('Bar').unlink() + (foo / 'orig').rmdir() + (foo / 'bar.diff').write_text(plain) + + result = cmd_git_nested('verify bar diff bar.diff', cwd=foo) + assert result.output.strip().endswith(" with 1 diff") + + +def test_verify_diff_writes_the_whole_diff(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + _commit_local_change(env, foo, 'first') + _commit_local_change(env, foo, 'second') + + result = cmd_git_nested('verify bar diff', cwd=foo, check=False) + assert result.returncode == 1 + assert "differs from its upstream commit with 0 diffs" in result.output + assert "use --output to have the whole diff written" in result.output + + result = cmd_git_nested('verify -o out/bar.diff bar diff', cwd=foo, check=False) + assert result.returncode == 1 + assert "wrote the whole diff to out/bar.diff" in result.output + diff = (foo / 'out' / 'bar.diff').read_text() + assert "+first" in diff + assert "+second" in diff + assert ".gitnested" not in diff + + result = cmd_git_nested('verify bar diff out/bar.diff', cwd=foo) + assert result.output.strip().endswith(" with 1 diff") + _commit_local_change(env, foo, 'third') + cmd_git_nested('verify -o out/bar.diff bar diff out/bar.diff', cwd=foo, check=False) + assert "+third" in (foo / 'out' / 'bar.diff').read_text() + cmd_git_nested('verify bar diff out/bar.diff', cwd=foo) + + +def test_verify_a_patch_outside_the_repository(foo_bar_cloned_and_nested): + env = foo_bar_cloned_and_nested + foo = env.workspace / 'foo' + _commit_local_change(env, foo, 'first') + outside = env.tmp / 'outside' + + cmd_git_nested(['verify', '-o', str(outside), 'bar', 'patches'], cwd=foo, check=False) + patch = outside / '0001-TODO-title.patch' + result = cmd_git_nested(['verify', 'bar', 'patches', str(patch)], cwd=foo) + assert f"applying {patch.as_posix()}" in result.output diff --git a/tests/unit/test_completion_engine.py b/tests/unit/test_completion_engine.py index 6890d47..c02cd4a 100644 --- a/tests/unit/test_completion_engine.py +++ b/tests/unit/test_completion_engine.py @@ -200,12 +200,12 @@ def test_an_unreadable_prefix_offers_no_directories(git, tmp_path, monkeypatch): def test_dunder_complete_prints_one_candidate_per_line(git, capsys): - assert completion.handle_dunder_complete(git, ['__complete', 'git-nested', 'ver']) is True + assert completion.handle_dunder_complete(git, ['__complete', 'git-nested', 'vers']) is True assert capsys.readouterr().out == 'version\n' def test_dunder_complete_describes_when_asked(git, capsys): - completion.handle_dunder_complete(git, ['__complete', '--describe', 'git-nested', 'ver']) + completion.handle_dunder_complete(git, ['__complete', '--describe', 'git-nested', 'vers']) assert capsys.readouterr().out == "version\tprint version information\n" From 9d5ba3c7b8c6daff9eeb15dc473d2d51849c7d5b Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Sat, 7 Nov 2026 19:49:29 +0100 Subject: [PATCH 2/2] refactor: extract nested conditional in verify Co-Authored-By: Claude Opus 5.5 --- src/git_nested/commands/verify.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/git_nested/commands/verify.py b/src/git_nested/commands/verify.py index 05131ae..c8bcdc3 100644 --- a/src/git_nested/commands/verify.py +++ b/src/git_nested/commands/verify.py @@ -130,7 +130,7 @@ def _report_difference(ctx: CommandContext, path: Path, base: str, local_tree: s if ctx.verify_mode == 'diff': return _diff_hint(git, path, base, local_tree, message, out) - directory = Path(out) if out else files[-1].parent if files else None + directory = _patch_directory(out, files) if directory is None: return f"{message}\nname the patches, or use --output to have the missing one written" patch = _write_missing_patch(git, path, local_tree, directory) @@ -140,6 +140,13 @@ def _report_difference(ctx: CommandContext, path: Path, base: str, local_tree: s ) +def _patch_directory(out: str | None, files: list[Path]) -> Path | None: + """Where the missing patch goes: --output, else next to the last patch.""" + if out: + return Path(out) + return files[-1].parent if files else None + + def _diff_hint(git: GitRunner, path: Path, base: str, local_tree: str, message: str, out: str | None) -> str: """`message` for 'diff' mode, after writing the whole diff to `out` if given.""" if not out: