git-worktree-wrapper 0.1.0__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_worktree_wrapper-0.1.0.dist-info/METADATA +473 -0
- git_worktree_wrapper-0.1.0.dist-info/RECORD +35 -0
- git_worktree_wrapper-0.1.0.dist-info/WHEEL +4 -0
- git_worktree_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- gww/__init__.py +3 -0
- gww/actions/__init__.py +224 -0
- gww/actions/types.py +187 -0
- gww/cli/__init__.py +1 -0
- gww/cli/commands/__init__.py +1 -0
- gww/cli/commands/add.py +122 -0
- gww/cli/commands/clone.py +97 -0
- gww/cli/commands/init.py +147 -0
- gww/cli/commands/migrate.py +81 -0
- gww/cli/commands/pull.py +62 -0
- gww/cli/commands/remove.py +153 -0
- gww/cli/context.py +382 -0
- gww/cli/main.py +285 -0
- gww/config/__init__.py +1 -0
- gww/config/loader.py +305 -0
- gww/config/resolver.py +188 -0
- gww/config/validator.py +344 -0
- gww/git/__init__.py +1 -0
- gww/git/branch.py +264 -0
- gww/git/repository.py +403 -0
- gww/git/worktree.py +395 -0
- gww/migration/__init__.py +44 -0
- gww/migration/executor.py +342 -0
- gww/migration/planner.py +260 -0
- gww/template/__init__.py +1 -0
- gww/template/evaluator.py +281 -0
- gww/template/functions.py +378 -0
- gww/utils/__init__.py +1 -0
- gww/utils/shell.py +894 -0
- gww/utils/uri.py +171 -0
- gww/utils/xdg.py +71 -0
gww/cli/context.py
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
"""Shared CLI command context: tag parsing, config loading, source resolution.
|
|
2
|
+
|
|
3
|
+
This module collects the boilerplate that every command used to inline:
|
|
4
|
+
|
|
5
|
+
* :class:`CommandContext` — small dataclass carrying the per-invocation state
|
|
6
|
+
(``verbose``, ``quiet``, ``tags``) plus command-specific fields (URI,
|
|
7
|
+
branch, …) that was previously pulled off ``argparse.Namespace`` with
|
|
8
|
+
``getattr`` calls.
|
|
9
|
+
* :class:`CommandExit` — control-flow exception used by ``…_or_exit``
|
|
10
|
+
helpers to abort a command with a specific exit code.
|
|
11
|
+
* :func:`exit_on_error` — decorator that turns :class:`CommandExit` raised
|
|
12
|
+
inside a command into a printed error + return-code, preserving the
|
|
13
|
+
contract that ``run_<command>(ctx) -> int``.
|
|
14
|
+
* :func:`parse_tags` — turn a list of ``"key=value"`` strings into a dict.
|
|
15
|
+
* :func:`parse_uri_or_exit` — parse and validate a repository URI.
|
|
16
|
+
* :func:`load_config_or_exit` — load + validate the gww config, raising
|
|
17
|
+
:class:`CommandExit(2, ...)` on any failure so callers do not have to repeat
|
|
18
|
+
the tri-except pattern.
|
|
19
|
+
* :func:`resolve_source_repo_or_exit` — detect the current repo, walk from a
|
|
20
|
+
worktree back to its source, and return ``(source_path, remote_uri)``. Raises
|
|
21
|
+
:class:`CommandExit(1, ...)` on any failure so callers do not have to repeat
|
|
22
|
+
the detect-or-walk pattern.
|
|
23
|
+
* :func:`resolve_source_repo` — same detect-or-walk pattern, but without the
|
|
24
|
+
remote-origin requirement. Used by commands that only need the source path
|
|
25
|
+
(e.g. ``pull``).
|
|
26
|
+
* :class:`RuleFailure` / :func:`print_action_failure_summary` — shared
|
|
27
|
+
per-rule failure record and the grouped stderr summary used by
|
|
28
|
+
``clone``/``add`` at the end of the action loop.
|
|
29
|
+
|
|
30
|
+
Commands still own their own control flow. They raise :class:`CommandExit`
|
|
31
|
+
when they want to bail out with a specific exit code; the ``@exit_on_error``
|
|
32
|
+
decorator on the public ``run_<command>`` entry points converts that exception
|
|
33
|
+
into the return code that the old ``return N`` pattern used to produce.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import functools
|
|
40
|
+
import sys
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Callable, Optional, TypeVar
|
|
44
|
+
|
|
45
|
+
from gww.actions import Action, ActionError, RuleActions
|
|
46
|
+
from gww.config.loader import ConfigLoadError, ConfigNotFoundError, load_config
|
|
47
|
+
from gww.config.validator import Config, ConfigValidationError, validate_config
|
|
48
|
+
from gww.git.repository import (
|
|
49
|
+
GitCommandError,
|
|
50
|
+
NotGitRepositoryError,
|
|
51
|
+
detect_repository,
|
|
52
|
+
get_source_repository,
|
|
53
|
+
)
|
|
54
|
+
from gww.utils.uri import ParsedURI, parse_uri
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def exit_on_error(func: _F) -> _F:
|
|
61
|
+
"""Decorator that converts :class:`CommandExit` into a return code.
|
|
62
|
+
|
|
63
|
+
The command's body can freely ``raise CommandExit(code, message)``;
|
|
64
|
+
the wrapper prints ``message`` to stderr and returns ``code``. Any
|
|
65
|
+
other exception propagates unchanged so the global handler in
|
|
66
|
+
:func:`gww.cli.main:main` (or the test framework) sees it.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
@functools.wraps(func)
|
|
70
|
+
def wrapper(*args: Any, **kwargs: Any) -> int:
|
|
71
|
+
try:
|
|
72
|
+
result = func(*args, **kwargs)
|
|
73
|
+
except CommandExit as e:
|
|
74
|
+
if e.message:
|
|
75
|
+
print(e.message, file=sys.stderr)
|
|
76
|
+
return e.code
|
|
77
|
+
return int(result)
|
|
78
|
+
|
|
79
|
+
return wrapper # type: ignore[return-value]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class CommandExit(Exception):
|
|
83
|
+
"""Raised when a command needs to terminate early with a specific exit code.
|
|
84
|
+
|
|
85
|
+
Attributes:
|
|
86
|
+
code: Exit code to return from the CLI. ``1`` for a runtime error,
|
|
87
|
+
``2`` for a configuration error.
|
|
88
|
+
message: Human-readable error message. Written to stderr by
|
|
89
|
+
:func:`gww.cli.main:main` before the process exits.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, code: int, message: str = "") -> None:
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
self.code = code
|
|
95
|
+
self.message = message
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_tags(tag_args: Optional[list[str]]) -> dict[str, str]:
|
|
99
|
+
"""Parse ``--tag key=value`` arguments into a dictionary.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
tag_args: List of tag strings in format ``"key=value"`` or ``"key"``.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Dictionary mapping tag keys to values (empty string if no value).
|
|
106
|
+
"""
|
|
107
|
+
tags: dict[str, str] = {}
|
|
108
|
+
for tag_arg in tag_args or []:
|
|
109
|
+
if "=" in tag_arg:
|
|
110
|
+
key, value = tag_arg.split("=", 1)
|
|
111
|
+
tags[key] = value
|
|
112
|
+
else:
|
|
113
|
+
tags[tag_arg] = ""
|
|
114
|
+
return tags
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def parse_uri_or_exit(uri_str: str) -> ParsedURI:
|
|
118
|
+
"""Parse a repository URI, raising :class:`CommandExit` on failure.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
uri_str: Raw URI string from the command line.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Validated :class:`ParsedURI` object.
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
CommandExit: With code ``1`` if the URI is malformed.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
return parse_uri(uri_str)
|
|
131
|
+
except ValueError as e:
|
|
132
|
+
raise CommandExit(1, f"Error: Invalid repository URI: {e}") from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class CommandContext:
|
|
137
|
+
"""Per-invocation context shared by all CLI commands.
|
|
138
|
+
|
|
139
|
+
Replaces the ``getattr(args, "verbose", 0)`` / ``getattr(args, "quiet", False)``
|
|
140
|
+
pattern with a typed container. Built once from the parsed
|
|
141
|
+
:class:`argparse.Namespace` and threaded into helpers and commands.
|
|
142
|
+
|
|
143
|
+
Command-specific fields (``uri``, ``branch``, ``branch_or_path``, …) are
|
|
144
|
+
optional and only populated for the commands that need them.
|
|
145
|
+
|
|
146
|
+
Attributes:
|
|
147
|
+
verbose: Verbosity level (``-v`` count).
|
|
148
|
+
quiet: Whether to suppress non-error output (``-q``).
|
|
149
|
+
tags: Tag key-value pairs from ``--tag`` options.
|
|
150
|
+
uri: Source URI string (clone command).
|
|
151
|
+
branch: Branch name (add command).
|
|
152
|
+
branch_or_path: Branch name or worktree path (remove command).
|
|
153
|
+
create_branch: Whether to create the branch if missing (add command).
|
|
154
|
+
force: Whether to force the operation past safety checks.
|
|
155
|
+
old_repos: Source directories to scan for migration (migrate command).
|
|
156
|
+
dry_run: Whether to only report what would happen (migrate command).
|
|
157
|
+
inplace: Whether to move in place instead of copying (migrate command).
|
|
158
|
+
init_command: ``"config"`` or ``"shell"`` for ``gww init``.
|
|
159
|
+
shell: Shell name for ``gww init shell``.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
verbose: int = 0
|
|
163
|
+
quiet: bool = False
|
|
164
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
165
|
+
uri: Optional[str] = None
|
|
166
|
+
branch: Optional[str] = None
|
|
167
|
+
branch_or_path: Optional[str] = None
|
|
168
|
+
create_branch: bool = False
|
|
169
|
+
force: bool = False
|
|
170
|
+
old_repos: list[str] = field(default_factory=list)
|
|
171
|
+
dry_run: bool = False
|
|
172
|
+
inplace: bool = False
|
|
173
|
+
init_command: Optional[str] = None
|
|
174
|
+
shell: Optional[str] = None
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_args(cls, args: argparse.Namespace) -> CommandContext:
|
|
178
|
+
"""Build a :class:`CommandContext` from parsed argparse args.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
args: Parsed command-line arguments.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Populated context.
|
|
185
|
+
"""
|
|
186
|
+
old_repos_raw = getattr(args, "old_repos", None) or []
|
|
187
|
+
if isinstance(old_repos_raw, str):
|
|
188
|
+
old_repos = [old_repos_raw]
|
|
189
|
+
else:
|
|
190
|
+
old_repos = list(old_repos_raw)
|
|
191
|
+
return cls(
|
|
192
|
+
verbose=getattr(args, "verbose", 0),
|
|
193
|
+
quiet=getattr(args, "quiet", False),
|
|
194
|
+
tags=parse_tags(getattr(args, "tag", None)),
|
|
195
|
+
uri=getattr(args, "uri", None),
|
|
196
|
+
branch=getattr(args, "branch", None),
|
|
197
|
+
branch_or_path=getattr(args, "branch_or_path", None),
|
|
198
|
+
create_branch=getattr(args, "create_branch", False),
|
|
199
|
+
force=getattr(args, "force", False),
|
|
200
|
+
old_repos=old_repos,
|
|
201
|
+
dry_run=getattr(args, "dry_run", False),
|
|
202
|
+
inplace=getattr(args, "inplace", False),
|
|
203
|
+
init_command=getattr(args, "init_command", None),
|
|
204
|
+
shell=getattr(args, "shell", None),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def say(self, message: str) -> None:
|
|
208
|
+
"""Print a status message unless quiet mode is set.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
message: Message to write to stdout.
|
|
212
|
+
"""
|
|
213
|
+
if not self.quiet:
|
|
214
|
+
print(message)
|
|
215
|
+
|
|
216
|
+
def verbose_msg(self, message: str) -> None:
|
|
217
|
+
"""Print a verbose status message.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
message: Message to write to stderr (only when ``verbose > 0``).
|
|
221
|
+
"""
|
|
222
|
+
if self.verbose > 0 and not self.quiet:
|
|
223
|
+
print(message, file=sys.stderr)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def load_config_or_exit() -> Config:
|
|
227
|
+
"""Load and validate the gww configuration.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Validated :class:`Config` object.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
CommandExit: With code ``2`` if the config is missing, malformed, or
|
|
234
|
+
fails validation.
|
|
235
|
+
"""
|
|
236
|
+
try:
|
|
237
|
+
raw_config = load_config()
|
|
238
|
+
except ConfigNotFoundError as e:
|
|
239
|
+
raise CommandExit(
|
|
240
|
+
2,
|
|
241
|
+
"Error: Config file not found. Run 'gww init config' to create one.",
|
|
242
|
+
) from e
|
|
243
|
+
except ConfigLoadError as e:
|
|
244
|
+
raise CommandExit(2, f"Error: {e}") from e
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
return validate_config(raw_config)
|
|
248
|
+
except ConfigValidationError as e:
|
|
249
|
+
raise CommandExit(2, f"Config validation error: {e}") from e
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def resolve_source_repo_or_exit(cwd: Path) -> tuple[Path, str]:
|
|
253
|
+
"""Detect the repo at ``cwd`` and walk back to its source.
|
|
254
|
+
|
|
255
|
+
If ``cwd`` is inside a worktree, the source (main) repository path is
|
|
256
|
+
returned along with that source repo's remote URI. If ``cwd`` is inside a
|
|
257
|
+
source repository, that path and its remote URI are returned.
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
cwd: Directory to start the detection from.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
Tuple of ``(source_path, remote_uri)``.
|
|
264
|
+
|
|
265
|
+
Raises:
|
|
266
|
+
CommandExit: With code ``1`` if ``cwd`` is not in a git repository, the
|
|
267
|
+
source cannot be found, or the repo has no remote ``origin``.
|
|
268
|
+
"""
|
|
269
|
+
try:
|
|
270
|
+
repo = detect_repository(cwd)
|
|
271
|
+
except NotGitRepositoryError as e:
|
|
272
|
+
raise CommandExit(1, "Error: Not in a git repository.") from e
|
|
273
|
+
|
|
274
|
+
if repo.is_worktree:
|
|
275
|
+
try:
|
|
276
|
+
source_path = get_source_repository(repo.path)
|
|
277
|
+
except (NotGitRepositoryError, GitCommandError) as e:
|
|
278
|
+
raise CommandExit(1, f"Error finding source repository: {e}") from e
|
|
279
|
+
else:
|
|
280
|
+
source_path = repo.path
|
|
281
|
+
|
|
282
|
+
if not repo.remote_uri:
|
|
283
|
+
raise CommandExit(
|
|
284
|
+
1,
|
|
285
|
+
"Error: Repository has no remote origin. "
|
|
286
|
+
"Cannot determine worktree path.",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
return source_path, repo.remote_uri
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def resolve_source_repo(cwd: Path) -> Path:
|
|
293
|
+
"""Detect repo at ``cwd`` and walk back to its source.
|
|
294
|
+
|
|
295
|
+
Unlike :func:`resolve_source_repo_or_exit` this helper does not require
|
|
296
|
+
a remote origin — it returns the source path regardless of remote state.
|
|
297
|
+
Used by commands that operate on the local source (e.g. ``pull``).
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
cwd: Directory to start the detection from.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Path to the source repository.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
CommandExit: With code ``1`` if ``cwd`` is not in a git repository or
|
|
307
|
+
the source repo cannot be found.
|
|
308
|
+
"""
|
|
309
|
+
try:
|
|
310
|
+
repo = detect_repository(cwd)
|
|
311
|
+
except NotGitRepositoryError as e:
|
|
312
|
+
raise CommandExit(1, "Error: Not in a git repository.") from e
|
|
313
|
+
|
|
314
|
+
if repo.is_worktree:
|
|
315
|
+
try:
|
|
316
|
+
return get_source_repository(repo.path)
|
|
317
|
+
except (NotGitRepositoryError, GitCommandError) as e:
|
|
318
|
+
raise CommandExit(1, f"Error finding source repository: {e}") from e
|
|
319
|
+
return repo.path
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass
|
|
323
|
+
class RuleFailure:
|
|
324
|
+
"""A single failing action in the clone/add action loop.
|
|
325
|
+
|
|
326
|
+
Pairs the failing :class:`Action` with the :class:`RuleActions` bundle it
|
|
327
|
+
came from and the :class:`ActionError` it raised. Used by
|
|
328
|
+
:func:`print_action_failure_summary` to produce the grouped stderr block,
|
|
329
|
+
and by the CLI to choose the exit code (any failure with
|
|
330
|
+
``bundle.critical`` set → exit 1).
|
|
331
|
+
|
|
332
|
+
Attributes:
|
|
333
|
+
bundle: The rule that produced the action.
|
|
334
|
+
action: The action whose ``run()`` raised.
|
|
335
|
+
error: The exception raised by ``action.run()``.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
bundle: RuleActions
|
|
339
|
+
action: Action
|
|
340
|
+
error: ActionError
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _describe_action(action: Action) -> str:
|
|
344
|
+
"""Return a short human-readable label for an action in failure messages.
|
|
345
|
+
|
|
346
|
+
Uses the action's concrete class name (e.g. ``"copy"``, ``"command"``) —
|
|
347
|
+
no need to add a method to the action classes just for diagnostics. The
|
|
348
|
+
``"Action"`` suffix is stripped and the result lowercased, so
|
|
349
|
+
``CopyAction`` → ``"copy"`` and ``CommandAction`` → ``"command"``.
|
|
350
|
+
"""
|
|
351
|
+
name = type(action).__name__
|
|
352
|
+
if name.endswith("Action"):
|
|
353
|
+
name = name[: -len("Action")]
|
|
354
|
+
return name.lower()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def print_action_failure_summary(failures: list[RuleFailure]) -> None:
|
|
358
|
+
"""Print the grouped action-execution summary to stderr.
|
|
359
|
+
|
|
360
|
+
Lists one entry per failing action, identified by the rule's config index,
|
|
361
|
+
the rule's criticality, the failing action's type, and the error text.
|
|
362
|
+
Critical rules' loops break after the first failure, so each critical rule
|
|
363
|
+
appears at most once; non-critical rules appear once per failing action.
|
|
364
|
+
Suppressing the ``say()`` success line is the caller's responsibility — the
|
|
365
|
+
helper only renders the block.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
failures: Failures collected by the CLI action loop, in order.
|
|
369
|
+
"""
|
|
370
|
+
print(
|
|
371
|
+
f"Action execution summary: {len(failures)} failure(s):",
|
|
372
|
+
file=sys.stderr,
|
|
373
|
+
)
|
|
374
|
+
for failure in failures:
|
|
375
|
+
criticality = "critical" if failure.bundle.critical else "non-critical"
|
|
376
|
+
action_label = _describe_action(failure.action)
|
|
377
|
+
print(
|
|
378
|
+
f" Rule {failure.bundle.index} ({criticality}, "
|
|
379
|
+
f"when: {failure.bundle.predicate}): "
|
|
380
|
+
f"failed at {action_label}: {failure.error}",
|
|
381
|
+
file=sys.stderr,
|
|
382
|
+
)
|
gww/cli/main.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""CLI entry point and argument parser structure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from gww import __version__
|
|
10
|
+
from gww.cli.context import CommandContext, CommandExit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
14
|
+
"""Create the main argument parser with all subcommands.
|
|
15
|
+
|
|
16
|
+
The returned parser has ``init_parser`` attached as an attribute so
|
|
17
|
+
callers (notably :func:`main`) can print help for the ``init`` command
|
|
18
|
+
without re-creating the parser.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Configured ArgumentParser.
|
|
22
|
+
"""
|
|
23
|
+
parser = argparse.ArgumentParser(
|
|
24
|
+
prog="gww",
|
|
25
|
+
description="Git Worktree Wrapper - manage git worktrees with configurable paths",
|
|
26
|
+
epilog="Run 'gww <command> --help' for more information on a specific command.",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--version",
|
|
31
|
+
action="version",
|
|
32
|
+
version=f"%(prog)s {__version__}",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"-v", "--verbose",
|
|
37
|
+
action="count",
|
|
38
|
+
default=0,
|
|
39
|
+
help="Increase verbosity (can be repeated)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"-q", "--quiet",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Suppress non-error output",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-t", "--tag",
|
|
50
|
+
action="append",
|
|
51
|
+
default=[],
|
|
52
|
+
help="Tag in format key=value or just key (can be specified multiple times)",
|
|
53
|
+
metavar="TAG",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Create subparsers for commands
|
|
57
|
+
subparsers = parser.add_subparsers(
|
|
58
|
+
dest="command",
|
|
59
|
+
title="commands",
|
|
60
|
+
metavar="<command>",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# clone command
|
|
64
|
+
clone_parser = subparsers.add_parser(
|
|
65
|
+
"clone",
|
|
66
|
+
help="Clone a repository to configured location",
|
|
67
|
+
description="Clone a git repository to the location determined by configuration rules.",
|
|
68
|
+
)
|
|
69
|
+
clone_parser.add_argument(
|
|
70
|
+
"uri",
|
|
71
|
+
help="Git repository URI (HTTP, HTTPS, SSH, or file://)",
|
|
72
|
+
)
|
|
73
|
+
clone_parser.add_argument(
|
|
74
|
+
"-t", "--tag",
|
|
75
|
+
action="append",
|
|
76
|
+
default=[],
|
|
77
|
+
help="Tag in format key=value or just key (can be specified multiple times)",
|
|
78
|
+
metavar="TAG",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# add command
|
|
82
|
+
add_parser = subparsers.add_parser(
|
|
83
|
+
"add",
|
|
84
|
+
help="Add a worktree for a branch",
|
|
85
|
+
description="Add a worktree for the specified branch.",
|
|
86
|
+
)
|
|
87
|
+
add_parser.add_argument(
|
|
88
|
+
"branch",
|
|
89
|
+
help="Branch name to checkout in worktree",
|
|
90
|
+
)
|
|
91
|
+
add_parser.add_argument(
|
|
92
|
+
"-c", "--create-branch",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Create branch from current commit if it doesn't exist",
|
|
95
|
+
)
|
|
96
|
+
add_parser.add_argument(
|
|
97
|
+
"-t", "--tag",
|
|
98
|
+
action="append",
|
|
99
|
+
default=[],
|
|
100
|
+
help="Tag in format key=value or just key (can be specified multiple times)",
|
|
101
|
+
metavar="TAG",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# remove command
|
|
105
|
+
remove_parser = subparsers.add_parser(
|
|
106
|
+
"remove",
|
|
107
|
+
help="Remove a worktree",
|
|
108
|
+
description="Remove a worktree by branch name or path.",
|
|
109
|
+
)
|
|
110
|
+
remove_parser.add_argument(
|
|
111
|
+
"branch_or_path",
|
|
112
|
+
help="Branch name or absolute path to worktree",
|
|
113
|
+
)
|
|
114
|
+
remove_parser.add_argument(
|
|
115
|
+
"-f", "--force",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Force removal even if worktree has uncommitted changes",
|
|
118
|
+
)
|
|
119
|
+
remove_parser.add_argument(
|
|
120
|
+
"-t", "--tag",
|
|
121
|
+
action="append",
|
|
122
|
+
default=[],
|
|
123
|
+
help="Tag in format key=value or just key (can be specified multiple times)",
|
|
124
|
+
metavar="TAG",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# pull command
|
|
128
|
+
pull_parser = subparsers.add_parser(
|
|
129
|
+
"pull",
|
|
130
|
+
help="Update source repository",
|
|
131
|
+
description="Pull changes from remote in the source repository.",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# migrate command
|
|
135
|
+
migrate_parser = subparsers.add_parser(
|
|
136
|
+
"migrate",
|
|
137
|
+
help="Migrate repositories to new locations",
|
|
138
|
+
description="Scan directory for repositories and migrate them based on current configuration.",
|
|
139
|
+
)
|
|
140
|
+
migrate_parser.add_argument(
|
|
141
|
+
"old_repos",
|
|
142
|
+
nargs="+",
|
|
143
|
+
help="Path(s) to directory(ies) containing old repositories",
|
|
144
|
+
)
|
|
145
|
+
migrate_parser.add_argument(
|
|
146
|
+
"-n", "--dry-run",
|
|
147
|
+
action="store_true",
|
|
148
|
+
help="Show what would be migrated without making changes",
|
|
149
|
+
)
|
|
150
|
+
migrate_group = migrate_parser.add_mutually_exclusive_group()
|
|
151
|
+
migrate_group.add_argument(
|
|
152
|
+
"--copy",
|
|
153
|
+
action="store_true",
|
|
154
|
+
help="Copy repositories to new locations (default)",
|
|
155
|
+
)
|
|
156
|
+
migrate_group.add_argument(
|
|
157
|
+
"--inplace",
|
|
158
|
+
action="store_true",
|
|
159
|
+
help="Move repositories in place and clean empty source folders",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# init command (with subcommands)
|
|
163
|
+
init_parser = subparsers.add_parser(
|
|
164
|
+
"init",
|
|
165
|
+
help="Initialize config or shell completion",
|
|
166
|
+
description="Create default configuration file or install shell completion.",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
init_subparsers = init_parser.add_subparsers(
|
|
170
|
+
dest="init_command",
|
|
171
|
+
title="init commands",
|
|
172
|
+
metavar="<init_command>",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# init config
|
|
176
|
+
init_config_parser = init_subparsers.add_parser(
|
|
177
|
+
"config",
|
|
178
|
+
help="Create default configuration file",
|
|
179
|
+
description="Create a default configuration file with examples and documentation.",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# init shell
|
|
183
|
+
init_shell_parser = init_subparsers.add_parser(
|
|
184
|
+
"shell",
|
|
185
|
+
help="Install shell completion",
|
|
186
|
+
description="Generate and install shell autocompletion script.",
|
|
187
|
+
)
|
|
188
|
+
init_shell_parser.add_argument(
|
|
189
|
+
"shell",
|
|
190
|
+
choices=["bash", "zsh", "fish"],
|
|
191
|
+
help="Shell to install completion for",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
parser.init_parser = init_parser # type: ignore[attr-defined]
|
|
195
|
+
return parser
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
199
|
+
"""Main entry point for gww CLI.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
argv: Command line arguments (defaults to sys.argv[1:]).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Exit code (0 for success, 1 for error, 2 for config error).
|
|
206
|
+
"""
|
|
207
|
+
parser = create_parser()
|
|
208
|
+
args = parser.parse_args(argv)
|
|
209
|
+
|
|
210
|
+
if args.command is None:
|
|
211
|
+
parser.print_help()
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
# Remind the user if their installed shell alias predates the current
|
|
215
|
+
# source. Skipped for `gww init shell` since that's the command that
|
|
216
|
+
# rewrites the alias file (no point nagging mid-regeneration).
|
|
217
|
+
if not (args.command == "init" and getattr(args, "init_command", None) == "shell"):
|
|
218
|
+
from gww.cli.commands.init import detect_user_shell, warn_if_alias_is_stale
|
|
219
|
+
|
|
220
|
+
shell = detect_user_shell()
|
|
221
|
+
if shell is not None:
|
|
222
|
+
warn_if_alias_is_stale(shell)
|
|
223
|
+
|
|
224
|
+
ctx = CommandContext.from_args(args)
|
|
225
|
+
|
|
226
|
+
# Import and run command handlers
|
|
227
|
+
try:
|
|
228
|
+
if args.command == "clone":
|
|
229
|
+
from gww.cli.commands.clone import run_clone
|
|
230
|
+
return run_clone(ctx)
|
|
231
|
+
|
|
232
|
+
elif args.command == "add":
|
|
233
|
+
from gww.cli.commands.add import run_add
|
|
234
|
+
return run_add(ctx)
|
|
235
|
+
|
|
236
|
+
elif args.command == "remove":
|
|
237
|
+
from gww.cli.commands.remove import run_remove
|
|
238
|
+
return run_remove(ctx)
|
|
239
|
+
|
|
240
|
+
elif args.command == "pull":
|
|
241
|
+
from gww.cli.commands.pull import run_pull
|
|
242
|
+
return run_pull(ctx)
|
|
243
|
+
|
|
244
|
+
elif args.command == "migrate":
|
|
245
|
+
from gww.cli.commands.migrate import run_migrate
|
|
246
|
+
return run_migrate(ctx)
|
|
247
|
+
|
|
248
|
+
elif args.command == "init":
|
|
249
|
+
if args.init_command is None:
|
|
250
|
+
parser.init_parser.print_help() # type: ignore[attr-defined]
|
|
251
|
+
return 0
|
|
252
|
+
|
|
253
|
+
if args.init_command == "config":
|
|
254
|
+
from gww.cli.commands.init import run_init_config
|
|
255
|
+
return run_init_config(ctx)
|
|
256
|
+
|
|
257
|
+
elif args.init_command == "shell":
|
|
258
|
+
from gww.cli.commands.init import run_init_shell
|
|
259
|
+
return run_init_shell(ctx)
|
|
260
|
+
|
|
261
|
+
else:
|
|
262
|
+
parser.print_help()
|
|
263
|
+
return 1
|
|
264
|
+
|
|
265
|
+
except CommandExit as e:
|
|
266
|
+
if e.message:
|
|
267
|
+
print(e.message, file=sys.stderr)
|
|
268
|
+
return e.code
|
|
269
|
+
|
|
270
|
+
except KeyboardInterrupt:
|
|
271
|
+
print("\nOperation cancelled.", file=sys.stderr)
|
|
272
|
+
return 130
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
276
|
+
if ctx.verbose > 0:
|
|
277
|
+
import traceback
|
|
278
|
+
traceback.print_exc()
|
|
279
|
+
return 1
|
|
280
|
+
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
sys.exit(main())
|
gww/config/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Configuration loading and management."""
|