git-nested 1.2.0__py3-none-any.whl → 1.2.2__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.
Files changed (42) hide show
  1. git_nested/checks.py +21 -8
  2. git_nested/cli/location.py +17 -1
  3. git_nested/cli/parser.py +69 -2
  4. git_nested/cli/setup.py +8 -3
  5. git_nested/cli/spec.py +190 -4
  6. git_nested/commands/__init__.py +6 -0
  7. git_nested/commands/branch.py +34 -2
  8. git_nested/commands/clean.py +1 -1
  9. git_nested/commands/clone.py +53 -15
  10. git_nested/commands/commit.py +8 -4
  11. git_nested/commands/config.py +5 -4
  12. git_nested/commands/diff.py +3 -1
  13. git_nested/commands/init.py +7 -4
  14. git_nested/commands/patchset.py +222 -0
  15. git_nested/commands/pull.py +132 -9
  16. git_nested/commands/push.py +55 -5
  17. git_nested/commands/status.py +4 -2
  18. git_nested/commands/submodules.py +885 -0
  19. git_nested/commands/verify.py +153 -0
  20. git_nested/configio.py +91 -0
  21. git_nested/content.py +147 -5
  22. git_nested/discovery.py +1 -1
  23. git_nested/filters.py +48 -0
  24. git_nested/git.py +19 -2
  25. git_nested/gitfile.py +23 -8
  26. git_nested/history.py +83 -12
  27. git_nested/messages.py +9 -7
  28. git_nested/models.py +63 -4
  29. git_nested/patchbuild.py +128 -0
  30. git_nested/patches.py +300 -0
  31. git_nested/patchpick.py +55 -0
  32. git_nested/refs.py +11 -6
  33. git_nested/submodules.py +29 -7
  34. git_nested/worktree.py +15 -0
  35. {git_nested-1.2.0.dist-info → git_nested-1.2.2.dist-info}/METADATA +13 -2
  36. git_nested-1.2.2.dist-info/RECORD +58 -0
  37. {git_nested-1.2.0.dist-info → git_nested-1.2.2.dist-info}/WHEEL +1 -1
  38. git_nested/yamlio.py +0 -25
  39. git_nested-1.2.0.dist-info/RECORD +0 -52
  40. {git_nested-1.2.0.dist-info → git_nested-1.2.2.dist-info}/entry_points.txt +0 -0
  41. {git_nested-1.2.0.dist-info → git_nested-1.2.2.dist-info}/licenses/LICENSE +0 -0
  42. {git_nested-1.2.0.dist-info → git_nested-1.2.2.dist-info}/top_level.txt +0 -0
git_nested/checks.py CHANGED
@@ -20,7 +20,7 @@ def check_repository(git: GitRunner, command: str, flags: Flags) -> tuple[Path |
20
20
  return None, None
21
21
 
22
22
  try:
23
- git.run(['rev-parse', '--git-dir'])
23
+ git_dir, inside_work_tree = git.check_output(['rev-parse', '--git-dir', '--is-inside-work-tree']).splitlines()
24
24
  except GitNestedError:
25
25
  # A deliberate re-interpretation of git's own message, not an
26
26
  # incidental failure, so the chain is suppressed -- and since
@@ -29,14 +29,14 @@ def check_repository(git: GitRunner, command: str, flags: Flags) -> tuple[Path |
29
29
  raise GitNestedError("not inside a git repository") from None
30
30
 
31
31
  # Per worktree of this repository, so they do not block each other.
32
- git_tmp = Path(git.check_output(['rev-parse', '--git-dir'])) / 'tmp'
32
+ git_tmp = Path(git_dir) / 'tmp'
33
33
 
34
34
  # Not a nested branch. A detached HEAD is fine: commits go there, as with git commit.
35
35
  current_branch = git.check_output(['symbolic-ref', '--short', '--quiet', 'HEAD'], may_fail=True)
36
36
  if current_branch.startswith('nested/'):
37
37
  raise GitNestedError(f"can't {command} while the nested branch {current_branch} is checked out")
38
38
 
39
- if git.check_output(['rev-parse', '--is-inside-work-tree'], may_fail=True) != 'true':
39
+ if inside_work_tree != 'true':
40
40
  raise GitNestedError("not inside a git working tree")
41
41
 
42
42
  if _needs_clean_worktree(command, flags):
@@ -55,7 +55,18 @@ def _needs_clean_worktree(command: str, flags: Flags) -> bool:
55
55
 
56
56
  def check_worktree_clean(git: GitRunner, command: str) -> None:
57
57
  """Ensure working copy has no uncommitted changes."""
58
- if command not in ['clone', 'init', 'pull', 'push', 'branch', 'commit', 'diff', 'repair']:
58
+ if command not in [
59
+ 'clone',
60
+ 'init',
61
+ 'pull',
62
+ 'push',
63
+ 'branch',
64
+ 'commit',
65
+ 'diff',
66
+ 'repair',
67
+ 'drop a patch',
68
+ 'cherry-pick a patch',
69
+ ]:
59
70
  return
60
71
 
61
72
  pwd = Path.cwd().as_posix()
@@ -74,13 +85,15 @@ def check_worktree_clean(git: GitRunner, command: str) -> None:
74
85
  raise GitNestedError(f"{pwd}: can't {command}, the index has changes")
75
86
 
76
87
 
77
- def warn_uncommitted_subdir_changes(git: GitRunner, command: str, flags: Flags, subdir: Path) -> None:
78
- """Warn when `command` ignores uncommitted changes in `subdir`."""
79
- if command not in ['branch', 'diff', 'push'] or (command == 'push' and flags.commit):
88
+ def warn_uncommitted_subdir_changes(
89
+ git: GitRunner, command: str, flags: Flags, subdir: Path, skip: str | None = None
90
+ ) -> None:
91
+ """Warn when `command` ignores uncommitted changes in `subdir`, but not in `skip` inside it."""
92
+ if command not in ['branch', 'diff', 'patches', 'push', 'verify'] or (command == 'push' and flags.commit):
80
93
  return
81
94
 
82
95
  git.run(['update-index', '-q', '--ignore-submodules', '--refresh'], may_fail=True)
83
- pathspec = ['--', subdir.as_posix()]
96
+ pathspec = ['--', subdir.as_posix(), *([f':(exclude,literal){subdir.as_posix()}/{skip}'] if skip else [])]
84
97
  if git.run(['diff-files', '--quiet', '--ignore-submodules', *pathspec], may_fail=True).returncode != 0:
85
98
  output.warn(f"{subdir.as_posix()}: has uncommitted changes; {command} uses only committed content")
86
99
  return
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
29
29
  # Commands whose missing <subdir> is not filled in from the current directory:
30
30
  # status reports on all of them, clone guesses one from the upstream, and init
31
31
  # is what makes a directory nested in the first place.
32
- _NO_IMPLIED_SUBDIR = ('clone', 'completion', 'init', 'status', 'version')
32
+ _NO_IMPLIED_SUBDIR = ('clone', 'completion', 'init', 'status', 'submodules', 'version')
33
33
 
34
34
 
35
35
  def at_top_level(git: GitRunner, command: str, ctx: CommandContext) -> AbstractContextManager[object]:
@@ -49,14 +49,20 @@ def at_top_level(git: GitRunner, command: str, ctx: CommandContext) -> AbstractC
49
49
  # Path options are made absolute, so moving to the top level does not change what they name.
50
50
  ctx.flags.dir = _absolute(ctx.flags.dir)
51
51
  ctx.flags.message_file = _absolute(ctx.flags.message_file)
52
+ ctx.flags.output_dir = _absolute(ctx.flags.output_dir)
53
+ ctx.patches = _all_absolute(ctx.patches)
52
54
  # Remotes given as relative paths are rebased onto the top level instead.
53
55
  # Relative rather than absolute: a remote ends up in .gitnested, and a
54
56
  # relative one keeps working in every clone of the parent repository.
55
57
  ctx.upstream = _remote_from(prefix, ctx.upstream)
56
58
  ctx.flags.remote = _remote_from(prefix, ctx.flags.remote)
59
+ ctx.flags.resolve_relative = _remote_from(prefix, ctx.flags.resolve_relative)
57
60
  if ctx.config_key == 'remote':
58
61
  ctx.config_value = _remote_from(prefix, ctx.config_value)
59
62
 
63
+ # --ignore and --only paths are given like <subdir>.
64
+ ctx.flags.ignore = _all_joined(prefix, ctx.flags.ignore)
65
+ ctx.flags.only = _all_joined(prefix, ctx.flags.only)
60
66
  if ctx.subdir is not None:
61
67
  ctx.subdir = _joined(prefix, Path(ctx.subdir))
62
68
  elif command not in _NO_IMPLIED_SUBDIR and not ctx.flags.all:
@@ -73,6 +79,11 @@ def _absolute(path: str | None) -> str | None:
73
79
  return os.path.abspath(path) if path else None # noqa: PTH100
74
80
 
75
81
 
82
+ def _all_absolute(paths: list[str] | None) -> list[str] | None:
83
+ """Each of `paths` made absolute."""
84
+ return [os.path.abspath(path) for path in paths] if paths else paths # noqa: PTH100
85
+
86
+
76
87
  def _remote_from(prefix: str, remote: str | None) -> str | None:
77
88
  """`remote`, typed below the top level at `prefix`, as seen from the top level.
78
89
 
@@ -90,6 +101,11 @@ def _remote_from(prefix: str, remote: str | None) -> str | None:
90
101
  return joined if joined.startswith('.') else f'./{joined}'
91
102
 
92
103
 
104
+ def _all_joined(prefix: str, paths: list[str] | None) -> list[str] | None:
105
+ """Each of `paths` joined like a <subdir>. None stays None."""
106
+ return [_joined(prefix, Path(path)) for path in paths] if paths else paths
107
+
108
+
93
109
  def _joined(prefix: str, subdir: Path) -> str:
94
110
  """`subdir`, typed below the top level at `prefix`, as a path from the top level."""
95
111
  if subdir.anchor:
git_nested/cli/parser.py CHANGED
@@ -69,7 +69,13 @@ def _flags_from_args(args: argparse.Namespace) -> Flags:
69
69
  dry_run=getattr(args, 'dry_run', False),
70
70
  filter=getattr(args, 'filter', []),
71
71
  force=getattr(args, 'force', False),
72
+ ignore=getattr(args, 'ignore', None),
73
+ only=getattr(args, 'only', None),
74
+ resolve_relative=getattr(args, 'resolve_relative', None),
75
+ recursive=getattr(args, 'recursive', False),
72
76
  from_submodule=getattr(args, 'from_submodule', False),
77
+ from_patches=getattr(args, 'from_patches', False),
78
+ local=getattr(args, 'local', False),
73
79
  recurse_submodules=getattr(args, 'recurse_submodules', False),
74
80
  gpg_sign=getattr(args, 'gpg_sign', False),
75
81
  signoff=getattr(args, 'signoff', False),
@@ -79,6 +85,9 @@ def _flags_from_args(args: argparse.Namespace) -> Flags:
79
85
  no_squash=getattr(args, 'no_squash', False),
80
86
  squash=getattr(args, 'squash', False),
81
87
  update=getattr(args, 'update', False),
88
+ keep=getattr(args, 'keep', False),
89
+ resume=getattr(args, 'resume', False),
90
+ abort=getattr(args, 'abort', False),
82
91
  quiet=getattr(args, 'quiet', False),
83
92
  # argparse's `count` action leaves the attribute at None when the flag
84
93
  # is absent, and the output layer wants a real count.
@@ -102,6 +111,7 @@ _EXCLUSIVE_FLAGS = (
102
111
  ('all', 'dir', "--dir names one worktree, so it can't be used with --all"),
103
112
  ('all', 'name', "--name names one branch, so it can't be used with --all"),
104
113
  ('dry_run', 'commit', "--dry-run can't be combined with --commit"),
114
+ ('from_patches', 'gpg_sign', "--from-patches can't sign the commits, so it can't be used with -S"),
105
115
  )
106
116
 
107
117
 
@@ -114,6 +124,56 @@ def _check_flags(flags: Flags) -> None:
114
124
  output.usage_error("--update needs --branch or --remote to have something to update")
115
125
 
116
126
 
127
+ def _verify_files(args: argparse.Namespace) -> tuple[list[str] | None, str]:
128
+ """The files of --patches or --diffs, and which of the two it is."""
129
+ patches, diffs = getattr(args, 'patches', None), getattr(args, 'diffs', None)
130
+ if patches is not None and diffs is not None:
131
+ output.usage_error("--patches and --diffs can't be used together")
132
+ if diffs is not None:
133
+ return diffs, 'diff'
134
+ return patches, 'patches'
135
+
136
+
137
+ def _options_last(args_list: list[str], command: str) -> list[str]:
138
+ """For `command`, its options after its positionals: `patches cherry-pick -r X bar c` as `... bar c -r X`.
139
+
140
+ Before Python 3.12, argparse can't take a positional after an option
141
+ once an earlier positional is parsed. Moving the options to the end
142
+ keeps each option with its value.
143
+ """
144
+ start = next((index for index, arg in enumerate(args_list) if not arg.startswith('-')), None)
145
+ if start is None or args_list[start] != command:
146
+ return args_list
147
+ positionals, options = _split_options(args_list[start + 1 :], _taking_a_value(command))
148
+ return [*args_list[: start + 1], *positionals, *options]
149
+
150
+
151
+ def _taking_a_value(command: str) -> set[str]:
152
+ """The option names of `command` that take a value."""
153
+ return {
154
+ name
155
+ for opt, names, kwargs in SUBPARSER_ARG_SPECS
156
+ if opt in VALID_COMMAND_OPTIONS[command] and kwargs.get('action') not in ('store_true', 'store_const')
157
+ for name in names
158
+ }
159
+
160
+
161
+ def _split_options(args: list[str], takes_value: set[str]) -> tuple[list[str], list[str]]:
162
+ """`args` as positionals and options, each option with its value."""
163
+ positionals: list[str] = []
164
+ options: list[str] = []
165
+ rest = iter(args)
166
+ for arg in rest:
167
+ if not arg.startswith('-'):
168
+ positionals.append(arg)
169
+ continue
170
+ options.append(arg)
171
+ value = next(rest, None) if arg in takes_value else None
172
+ if value is not None:
173
+ options.append(value)
174
+ return positionals, options
175
+
176
+
117
177
  def parse_args(git: GitRunner, args_list: list[str]) -> tuple[str, CommandContext]:
118
178
  """Parse command line arguments into a command name and the context to run it with.
119
179
 
@@ -121,7 +181,7 @@ def parse_args(git: GitRunner, args_list: list[str]) -> tuple[str, CommandContex
121
181
  tuple: (command, context). The context's git_tmp/head_commit are left
122
182
  unset -- only the caller knows whether the command runs in a repository.
123
183
  """
124
- args = build_arg_parser().parse_args(args_list)
184
+ args = build_arg_parser().parse_args(_options_last(_options_last(args_list, 'patches'), 'submodules'))
125
185
 
126
186
  if args.version:
127
187
  args.command = 'version'
@@ -130,9 +190,10 @@ def parse_args(git: GitRunner, args_list: list[str]) -> tuple[str, CommandContex
130
190
 
131
191
  flags = _flags_from_args(args)
132
192
  _check_flags(flags)
133
- if flags.single_commit and not flags.all:
193
+ if flags.single_commit and not flags.all and args.command != 'submodules':
134
194
  output.usage_error("--single-commit needs --all")
135
195
 
196
+ patches, verify_mode = _verify_files(args)
136
197
  upstream = getattr(args, 'upstream', None)
137
198
  subdir = getattr(args, 'subdir', None)
138
199
  if upstream and not subdir:
@@ -150,5 +211,11 @@ def parse_args(git: GitRunner, args_list: list[str]) -> tuple[str, CommandContex
150
211
  config_key=getattr(args, 'key', None),
151
212
  config_value=getattr(args, 'value', None),
152
213
  git_args=getattr(args, 'git_args', None),
214
+ patches_action=getattr(args, 'action', None),
215
+ patch_names=getattr(args, 'patch_names', None),
216
+ patches=patches,
217
+ verify_mode=verify_mode,
218
+ submodules_action=getattr(args, 'submodules_action', None),
219
+ submodules_args=getattr(args, 'submodules_args', None),
153
220
  )
154
221
  return args.command, context
git_nested/cli/setup.py CHANGED
@@ -48,7 +48,7 @@ def _check_existing_worktree(git: GitRunner, command: str, subdir: Path, gitnest
48
48
  output.error(
49
49
  f"{subdir.as_posix()}: no worktree to commit from; run 'git nested branch {subdir.as_posix()}' first"
50
50
  )
51
- elif command not in ['branch', 'clean', 'commit', 'push', 'run'] and worktree_path is not None:
51
+ elif command not in ['branch', 'clean', 'commit', 'patches', 'push', 'run', 'verify'] and worktree_path is not None:
52
52
  output.error(messages.worktree_exists(subdir, worktree_path, prunable=gitnested.exists()))
53
53
 
54
54
 
@@ -61,7 +61,7 @@ def setup_command(
61
61
  tuple: (subdir, gitnested, subref, config)
62
62
  """
63
63
  subdir = resolve_subdir(subdir)
64
- subref = refs.sanitize_subref(git, subdir.as_posix())
64
+ subref = refs.sanitize_subref(subdir.as_posix())
65
65
  gitnested = resolve_gitnested_file(subdir)
66
66
 
67
67
  if not flags.force:
@@ -69,9 +69,14 @@ def setup_command(
69
69
 
70
70
  # clone and init have no .gitnested yet. clean does not need it.
71
71
  if command in ['clone', 'init', 'clean']:
72
+ # Check nested.format now, not after the content is in place.
73
+ if command != 'clean':
74
+ gitfile.new_file_is_toml(git)
72
75
  config = NestedConfig(remote=flags.remote or upstream or '', branch=flags.branch or '', pin=flags.pin or '')
73
76
  else:
74
77
  config = gitfile.read_config(gitnested, flags)
75
78
 
76
- checks.warn_uncommitted_subdir_changes(git, command, flags, subdir)
79
+ # `patches` reads the patches folder as it is, committed or not
80
+ skip = config.patches_inside(subdir) if command == 'patches' else None
81
+ checks.warn_uncommitted_subdir_changes(git, command, flags, subdir, skip)
77
82
  return subdir, gitnested, subref, config
git_nested/cli/spec.py CHANGED
@@ -13,7 +13,7 @@ from ..constants import COMPLETION_SHELLS
13
13
  # Which flags each command accepts. The keys are also the command list:
14
14
  # the subparsers, and the completion candidates, are built from them.
15
15
  VALID_COMMAND_OPTIONS: dict[str, list[str]] = {
16
- 'branch': ['all', 'dir', 'fetch', 'force', 'gpg_sign', 'name'],
16
+ 'branch': ['all', 'dir', 'fetch', 'force', 'from_patches', 'gpg_sign', 'name'],
17
17
  'clean': ['ALL', 'all', 'force'],
18
18
  'clone': [
19
19
  'adopt',
@@ -31,17 +31,19 @@ VALID_COMMAND_OPTIONS: dict[str, list[str]] = {
31
31
  'recurse_submodules',
32
32
  'signoff',
33
33
  ],
34
- 'commit': ['fetch', 'force', 'gpg_sign', 'message', 'msg_file', 'signoff'],
34
+ 'commit': ['fetch', 'force', 'gpg_sign', 'message', 'msg_file', 'pin', 'signoff'],
35
35
  'completion': [],
36
36
  'config': [],
37
37
  'diff': ['all', 'branch', 'remote'],
38
38
  'fetch': ['all', 'branch', 'force', 'remote'],
39
39
  'init': ['branch', 'from_submodule', 'gpg_sign', 'remote', 'method', 'signoff', 'upstream_commit'],
40
+ 'patches': ['all', 'local', 'remote'],
40
41
  'pull': [
41
42
  'all',
42
43
  'branch',
43
44
  'force',
44
45
  'gpg_sign',
46
+ 'ignore',
45
47
  'merge',
46
48
  'message',
47
49
  'method',
@@ -63,6 +65,7 @@ VALID_COMMAND_OPTIONS: dict[str, list[str]] = {
63
65
  'dry_run',
64
66
  'force',
65
67
  'gpg_sign',
68
+ 'ignore',
66
69
  'merge',
67
70
  'message',
68
71
  'method',
@@ -76,6 +79,19 @@ VALID_COMMAND_OPTIONS: dict[str, list[str]] = {
76
79
  'update',
77
80
  ],
78
81
  'status': ['ALL', 'all', 'fetch', 'short'],
82
+ 'submodules': [
83
+ 'abort',
84
+ 'dry_run',
85
+ 'gpg_sign',
86
+ 'ignore',
87
+ 'only',
88
+ 'recursive',
89
+ 'resolve_relative',
90
+ 'resume',
91
+ 'signoff',
92
+ 'single_commit',
93
+ ],
94
+ 'verify': ['diffs', 'ignore', 'keep', 'output_dir', 'patches'],
79
95
  'version': [],
80
96
  }
81
97
 
@@ -91,11 +107,14 @@ COMMAND_HELP: dict[str, str] = {
91
107
  'diff': "show a nested repository's local changes against upstream",
92
108
  'fetch': "fetch upstream content for a nested repository",
93
109
  'init': "turn an existing subdirectory into a nested repository",
110
+ 'patches': "write, check, drop or cherry-pick the patches of a nested repository",
94
111
  'pull': "update a nested repository from its upstream",
95
112
  'push': "push a nested repository's local changes upstream",
96
113
  'repair': "point a nested repository's sync point back into the current history",
97
114
  'run': "run a git command in the worktree of a nested repository",
98
115
  'status': "report on the nested repositories of this repository",
116
+ 'submodules': "turn all submodules into nested repositories and keep them up to date",
117
+ 'verify': "check that a nested repository is its upstream commit plus patches or diffs",
99
118
  'version': "print version information",
100
119
  }
101
120
 
@@ -135,18 +154,70 @@ SUBPARSER_ARG_SPECS: list[tuple[str, tuple[str, ...], dict]] = [
135
154
  ('-c', '--commit'),
136
155
  {'action': 'store_true', 'help': "record the pushed commit in .gitnested; needs a push to the tracked branch"},
137
156
  ),
138
- ('dry_run', ('--dry-run',), {'action': 'store_true', 'dest': 'dry_run', 'help': "show what would be pushed"}),
157
+ (
158
+ 'dry_run',
159
+ ('--dry-run',),
160
+ {'action': 'store_true', 'dest': 'dry_run', 'help': "show what would happen; change nothing"},
161
+ ),
162
+ (
163
+ 'diffs',
164
+ ('--diffs',),
165
+ {'nargs': '*', 'metavar': 'DIFF', 'help': "check against these diffs, in order"},
166
+ ),
167
+ (
168
+ 'keep',
169
+ ('-k', '--keep'),
170
+ {'action': 'store_true', 'help': "keep the temporary worktree"},
171
+ ),
139
172
  ('force', ('-f', '--force'), {'action': 'store_true', 'help': "force the operation"}),
140
173
  (
141
174
  'from_submodule',
142
175
  ('--from-submodule',),
143
176
  {'action': 'store_true', 'help': "turn the submodule at <subdir> into a nested repository"},
144
177
  ),
178
+ (
179
+ 'local',
180
+ ('--local',),
181
+ {'action': 'store_true', 'help': "cherry-pick: add the new patches to patches-local"},
182
+ ),
183
+ (
184
+ 'from_patches',
185
+ ('--from-patches',),
186
+ {'action': 'store_true', 'help': "build the branch from upstream and the patches; check it gives <subdir>"},
187
+ ),
145
188
  (
146
189
  'recurse_submodules',
147
190
  ('--recurse-submodules',),
148
191
  {'action': 'store_true', 'help': "nest the submodules of upstream too, at the commits it pins"},
149
192
  ),
193
+ (
194
+ 'ignore',
195
+ ('--ignore',),
196
+ {
197
+ 'action': 'append',
198
+ 'metavar': 'PATH',
199
+ 'help': "skip this path of <subdir>, just this time; submodules init: do not nest the submodules there",
200
+ },
201
+ ),
202
+ (
203
+ 'only',
204
+ ('--only',),
205
+ {
206
+ 'action': 'append',
207
+ 'metavar': 'PATH',
208
+ 'help': "init: nest only the submodules at or below this path; give it more than once for more",
209
+ },
210
+ ),
211
+ (
212
+ 'recursive',
213
+ ('--recursive',),
214
+ {'action': 'store_true', 'help': "init: nest the submodules of the submodules too, at any depth"},
215
+ ),
216
+ (
217
+ 'resolve_relative',
218
+ ('--resolve-relative',),
219
+ {'metavar': 'URL', 'help': "init: resolve relative submodule urls against this url"},
220
+ ),
150
221
  ('exclude', ('--exclude',), {'action': 'append', 'help': "leave this path of the upstream repository out"}),
151
222
  (
152
223
  'gpg_sign',
@@ -186,10 +257,33 @@ SUBPARSER_ARG_SPECS: list[tuple[str, tuple[str, ...], dict]] = [
186
257
  {'dest': 'name', 'help': "name of the nested branch; defaults to nested/<subdir>"},
187
258
  ),
188
259
  ('message', ('-m', '--message'), {'dest': 'message', 'help': "commit message"}),
260
+ (
261
+ 'patches',
262
+ ('--patches',),
263
+ {'nargs': '*', 'metavar': 'PATCH', 'help': "check against these patches, in order (the default)"},
264
+ ),
189
265
  ('path', ('--path',), {'dest': 'path', 'help': "nest only this directory of upstream, at the root of <subdir>"}),
190
266
  ('pin', ('--pin',), {'dest': 'pin', 'help': "nest this commit or tag of the branch, not its newest commit"}),
191
267
  ('msg_file', ('--file',), {'dest': 'msg_file', 'help': "file to read the commit message from"}),
192
268
  ('tag', ('--tag',), {'dest': 'tag', 'help': "tag the pushed upstream commit with this name"}),
269
+ (
270
+ 'output_dir',
271
+ ('-o', '--output'),
272
+ {
273
+ 'dest': 'output_dir',
274
+ 'help': "patches: folder for the missing patch; diff: file for the whole diff",
275
+ },
276
+ ),
277
+ (
278
+ 'abort',
279
+ ('--abort',),
280
+ {'action': 'store_true', 'help': "throw away a 'submodules update' that stopped on conflicts"},
281
+ ),
282
+ (
283
+ 'resume',
284
+ ('--continue',),
285
+ {'action': 'store_true', 'dest': 'resume', 'help': "finish a 'submodules update' that stopped on conflicts"},
286
+ ),
193
287
  ('remote', ('-r', '--remote'), {'dest': 'remote', 'help': "upstream remote to push/pull/fetch"}),
194
288
  (
195
289
  'short',
@@ -199,7 +293,7 @@ SUBPARSER_ARG_SPECS: list[tuple[str, tuple[str, ...], dict]] = [
199
293
  (
200
294
  'single_commit',
201
295
  ('--single-commit',),
202
- {'action': 'store_true', 'help': "with --all: one commit for all nested repositories"},
296
+ {'action': 'store_true', 'help': "one commit for all nested repositories"},
203
297
  ),
204
298
  (
205
299
  'squash',
@@ -226,6 +320,7 @@ SUPPORTED_OPT_ATTRS: list[tuple[str, str]] = [
226
320
  ('path', 'path'),
227
321
  ('pin', 'pin'),
228
322
  ('tag', 'tag'),
323
+ ('output_dir', 'output_dir'),
229
324
  ]
230
325
 
231
326
  # Most commands take the same first positional, so it gets the same text.
@@ -255,6 +350,24 @@ POSITIONALS: dict[str, list[tuple[str, dict]]] = {
255
350
  'diff': [('subdir', {'nargs': '?', 'help': _SUBDIR_HELP})],
256
351
  'fetch': [('subdir', {'nargs': '?', 'help': _SUBDIR_HELP})],
257
352
  'init': [('subdir', {'nargs': '?', 'help': "subdirectory to turn into a nested repository"})],
353
+ 'patches': [
354
+ (
355
+ 'action',
356
+ {
357
+ 'choices': ['create', 'verify', 'drop', 'cherry-pick'],
358
+ 'help': "create: write; verify: check; drop: remove patches; cherry-pick: add commits or patch files",
359
+ },
360
+ ),
361
+ ('subdir', {'nargs': '?', 'help': _SUBDIR_HELP}),
362
+ (
363
+ 'patch_names',
364
+ {
365
+ 'nargs': '*',
366
+ 'metavar': 'patch',
367
+ 'help': "drop: the patches, by path or file name; cherry-pick: commits or patch files",
368
+ },
369
+ ),
370
+ ],
258
371
  'pull': [('subdir', {'nargs': '?', 'help': _SUBDIR_HELP})],
259
372
  'push': [
260
373
  ('subdir', {'nargs': '?', 'help': _SUBDIR_HELP}),
@@ -267,6 +380,21 @@ POSITIONALS: dict[str, list[tuple[str, dict]]] = {
267
380
  # everything after <subdir> goes to git untouched, options included.
268
381
  ('git_args', {'nargs': '...', 'help': "the git command to run, with its arguments"}),
269
382
  ],
383
+ 'submodules': [
384
+ (
385
+ 'submodules_action',
386
+ {'metavar': 'action', 'choices': ['init', 'update'], 'help': "init or update"},
387
+ ),
388
+ (
389
+ 'submodules_args',
390
+ {
391
+ 'metavar': 'arg',
392
+ 'nargs': '*',
393
+ 'help': "update: the commit to merge",
394
+ },
395
+ ),
396
+ ],
397
+ 'verify': [('subdir', {'help': _SUBDIR_HELP})],
270
398
  }
271
399
 
272
400
  # The paragraph `git nested <command> --help` opens with. COMMAND_HELP is
@@ -321,6 +449,14 @@ COMMAND_DESCRIPTION: dict[str, str] = {
321
449
  "The files are left as they are. Without --remote the subdirectory has no upstream yet, "
322
450
  "which you can add later with 'git nested config'."
323
451
  ),
452
+ 'patches': (
453
+ "Keep the local changes of <subdir> as patch files, in the patches folder of .gitnested. "
454
+ "create writes the changes that are not in a patch yet as a new patch, with a TODO title, "
455
+ "and writes all patches again in the one form git-nested uses. "
456
+ "verify only checks: it fails if a change is not in a patch, or if create would change a patch. "
457
+ "drop removes patches and their changes from <subdir>, writes the other patches again and commits. "
458
+ "cherry-pick adds commits (fetched from the remote if needed) or patch files as patches, and commits."
459
+ ),
324
460
  'pull': (
325
461
  "Fetch the upstream of <subdir> and join the new commits into it, by merge or by rebase. "
326
462
  "By default the result is one parent commit; --no-squash keeps the upstream commits as a merged second-parent history. "
@@ -348,6 +484,28 @@ COMMAND_DESCRIPTION: dict[str, str] = {
348
484
  "Report every nested repository in this repository: where it comes from, and whether it has local changes. "
349
485
  "With -q, just the paths, one per line."
350
486
  ),
487
+ 'submodules': (
488
+ "Turn the submodules of a repository into plain files. "
489
+ "Each submodule becomes a nested repository. "
490
+ "The repository itself stays a normal git clone of its upstream. "
491
+ "'init' nests all submodules at the commits they pin, one commit each. "
492
+ "With --recursive, their submodules too. "
493
+ "--only and --ignore pick some of them. "
494
+ "A relative submodule url needs --resolve-relative. "
495
+ "'update' merges an upstream commit and keeps its commits. It does not fetch. "
496
+ "Then it changes the nested repositories the way upstream changed its submodules. "
497
+ "Each changed submodule gets one commit. "
498
+ "On conflicts, fix them and run --continue. Or give up with --abort. "
499
+ "--dry-run shows the changes and makes none."
500
+ ),
501
+ 'verify': (
502
+ "Apply the files to the upstream commit in .gitnested, in a temporary worktree, and compare "
503
+ "the result with <subdir>. Patches go in with git am; plain diffs need --diffs. "
504
+ "Without files, check that <subdir> has no local changes. "
505
+ "If it differs, the command fails. With patches, it then writes the missing patch next to the last one "
506
+ "(or into --output). With --diffs, it writes the whole diff to --output. "
507
+ "For the patches folder of .gitnested, use 'git nested patches'."
508
+ ),
351
509
  'version': "Print the git-nested version, and the versions of git and Python it is running on.",
352
510
  }
353
511
 
@@ -397,11 +555,21 @@ COMMAND_EXAMPLES: dict[str, list[str]] = {
397
555
  "git nested init -r https://github.com/user/lib --commit abc123 ext/lib",
398
556
  "git nested init -r https://github.com/user/lib -b main ext/lib",
399
557
  ],
558
+ 'patches': [
559
+ "git nested patches create ext/lib",
560
+ "git nested patches verify ext/lib",
561
+ "git nested patches verify --all",
562
+ "git nested patches drop ext/lib 0002-Old-workaround.patch",
563
+ "git nested patches cherry-pick ext/lib a1b2c3d",
564
+ "git nested patches cherry-pick -r https://github.com/fork/lib --local ext/lib refs/pull/42/head",
565
+ "git nested patches cherry-pick ext/lib ~/fix-build.patch",
566
+ ],
400
567
  'pull': [
401
568
  "git nested pull ext/lib",
402
569
  "git nested pull --no-squash ext/lib",
403
570
  "git nested pull -M rebase ext/lib",
404
571
  "git nested pull --rebase ext/lib",
572
+ "git nested pull --ignore ext/lib/tests ext/lib",
405
573
  "git nested pull --all",
406
574
  "git nested pull --all -b release/2.0",
407
575
  ],
@@ -410,6 +578,7 @@ COMMAND_EXAMPLES: dict[str, list[str]] = {
410
578
  "git nested push --dry-run ext/lib",
411
579
  "git nested push --rebase ext/lib",
412
580
  "git nested push --squash -m 'one commit upstream' ext/lib",
581
+ "git nested push --ignore ext/lib/local ext/lib",
413
582
  "git nested push --all -b release/2.0",
414
583
  ],
415
584
  'repair': [
@@ -424,6 +593,23 @@ COMMAND_EXAMPLES: dict[str, list[str]] = {
424
593
  "git nested status",
425
594
  "git nested status -q",
426
595
  ],
596
+ 'submodules': [
597
+ "git nested submodules init",
598
+ "git nested submodules init --recursive",
599
+ "git nested submodules init --only lib/core",
600
+ "git nested submodules init --resolve-relative https://github.com/org/project",
601
+ "git nested submodules update upstream/main",
602
+ "git nested submodules update --dry-run upstream/main",
603
+ "git nested submodules update v2.0",
604
+ "git nested submodules update --continue",
605
+ ],
606
+ 'verify': [
607
+ "git nested verify ext/lib --patches patches/lib/*.patch",
608
+ "git nested verify -o patches/lib ext/lib",
609
+ "git nested verify ext/lib --diffs lib.diff",
610
+ "git nested verify -o lib.diff ext/lib --diffs lib.diff",
611
+ "git nested verify --ignore ext/lib/build ext/lib",
612
+ ],
427
613
  'version': [
428
614
  "git nested version",
429
615
  ],
@@ -26,11 +26,14 @@ from . import (
26
26
  diff,
27
27
  fetch,
28
28
  init,
29
+ patchset,
29
30
  pull,
30
31
  push,
31
32
  repair,
32
33
  run,
33
34
  status,
35
+ submodules,
36
+ verify,
34
37
  version,
35
38
  )
36
39
 
@@ -44,10 +47,13 @@ REGISTRY: dict[str, Callable[[CommandContext], None]] = {
44
47
  'diff': diff.cmd_diff,
45
48
  'fetch': fetch.cmd_fetch,
46
49
  'init': init.cmd_init,
50
+ 'patches': patchset.cmd_patches,
47
51
  'pull': pull.cmd_pull,
48
52
  'push': push.cmd_push,
49
53
  'repair': repair.cmd_repair,
50
54
  'run': run.cmd_run,
51
55
  'status': status.cmd_status,
56
+ 'submodules': submodules.cmd_submodules,
57
+ 'verify': verify.cmd_verify,
52
58
  'version': version.cmd_version,
53
59
  }