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/actions/__init__.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Project action matching and execution.
|
|
2
|
+
|
|
3
|
+
This package replaces the old ``actions/matcher.py`` and ``actions/executor.py``
|
|
4
|
+
split with a single entry point (:func:`apply_actions`) that returns typed
|
|
5
|
+
:class:`Action` objects grouped per matched rule. Commands iterate over the
|
|
6
|
+
returned bundles and call :meth:`Action.run` for each action.
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
|
|
10
|
+
* :class:`Action` protocol and concrete :class:`CopyAction`,
|
|
11
|
+
:class:`CommandAction` (in :mod:`gww.actions.types`)
|
|
12
|
+
* :class:`ActionError` raised by ``run()`` on failure
|
|
13
|
+
* :class:`MatcherError` raised by :func:`apply_actions` when a rule predicate
|
|
14
|
+
or command template cannot be evaluated
|
|
15
|
+
* :class:`RuleActions` — a rule that matched, its index/predicate/criticality,
|
|
16
|
+
and the executable actions for the requested kind
|
|
17
|
+
* :func:`apply_actions` — match rules and return executable bundles
|
|
18
|
+
* :data:`ActionKind` — literal distinguishing ``after_clone`` vs ``after_add``
|
|
19
|
+
vs ``before_remove`` (ADR-0011)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import shlex
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Literal
|
|
27
|
+
|
|
28
|
+
from gww.actions.types import (
|
|
29
|
+
Action,
|
|
30
|
+
ActionError,
|
|
31
|
+
CommandAction,
|
|
32
|
+
CopyAction,
|
|
33
|
+
)
|
|
34
|
+
from gww.config.validator import Action as RawAction
|
|
35
|
+
from gww.config.validator import ProjectRule
|
|
36
|
+
from gww.template.evaluator import (
|
|
37
|
+
TemplateError,
|
|
38
|
+
evaluate_command_template,
|
|
39
|
+
evaluate_predicate,
|
|
40
|
+
)
|
|
41
|
+
from gww.template.functions import (
|
|
42
|
+
TemplateContext,
|
|
43
|
+
create_function_registry,
|
|
44
|
+
create_project_functions,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MatcherError(Exception):
|
|
49
|
+
"""Raised when project matching or command-template evaluation fails."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
ActionKind = Literal["after_clone", "after_add", "before_remove"]
|
|
53
|
+
|
|
54
|
+
_KIND_TO_FIELD: dict[str, str] = {
|
|
55
|
+
"after_clone": "after_clone",
|
|
56
|
+
"after_add": "after_add",
|
|
57
|
+
"before_remove": "before_remove",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class RuleActions:
|
|
63
|
+
"""A matched project rule plus the actions to execute for one kind.
|
|
64
|
+
|
|
65
|
+
Carries enough context (index, predicate text, criticality flag) for the
|
|
66
|
+
CLI loop to attribute per-action failures back to the rule that produced
|
|
67
|
+
them, and to decide whether the command should exit 1.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
index: Position of the rule in ``config.actions`` — used as the rule's
|
|
71
|
+
identifier in error messages.
|
|
72
|
+
predicate: The ``when:`` expression as it appears in the config; kept
|
|
73
|
+
verbatim for diagnostics.
|
|
74
|
+
critical: Per-rule criticality flag from :class:`ProjectRule`. When
|
|
75
|
+
``True``, a failing action aborts the rule's remaining actions
|
|
76
|
+
and causes the command to exit 1.
|
|
77
|
+
actions: Executable actions for the requested ``kind``, in the order
|
|
78
|
+
they appear in the rule's ``after_clone``/``after_add``/
|
|
79
|
+
``before_remove`` list.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
index: int
|
|
83
|
+
predicate: str
|
|
84
|
+
critical: bool
|
|
85
|
+
actions: list[Action] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
__all__ = [
|
|
89
|
+
"Action",
|
|
90
|
+
"ActionError",
|
|
91
|
+
"ActionKind",
|
|
92
|
+
"CommandAction",
|
|
93
|
+
"CopyAction",
|
|
94
|
+
"MatcherError",
|
|
95
|
+
"RuleActions",
|
|
96
|
+
"apply_actions",
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _create_predicate_context(context: TemplateContext) -> dict[str, object]:
|
|
101
|
+
"""Build the evaluation context shared by ``when`` predicates and command
|
|
102
|
+
templates.
|
|
103
|
+
|
|
104
|
+
Adds project-specific functions (``source_path``, ``current_worktree``,
|
|
105
|
+
``file_exists``, ``dir_exists``, ``path_exists``) on top of the unified
|
|
106
|
+
URI/branch/tag registry seeded by ``context``.
|
|
107
|
+
"""
|
|
108
|
+
functions: dict[str, object] = dict(create_function_registry(context))
|
|
109
|
+
functions.update(create_project_functions(context))
|
|
110
|
+
return functions
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_action(
|
|
114
|
+
raw: RawAction,
|
|
115
|
+
context: dict[str, object],
|
|
116
|
+
) -> Action:
|
|
117
|
+
"""Turn a config-level :class:`Action` into a typed executable.
|
|
118
|
+
|
|
119
|
+
For ``copy`` actions the two template-evaluated args become
|
|
120
|
+
:class:`CopyAction`'s ``source`` and ``destination`` (both template-
|
|
121
|
+
evaluated, so ``source_path('local.properties')`` resolves to an absolute
|
|
122
|
+
path before the action runs). For ``command`` actions the template is
|
|
123
|
+
evaluated against ``context`` and parsed with :mod:`shlex` so the
|
|
124
|
+
resulting :class:`CommandAction` already holds the resolved argv.
|
|
125
|
+
"""
|
|
126
|
+
if raw.action_type == "copy":
|
|
127
|
+
if len(raw.args) < 2:
|
|
128
|
+
raise MatcherError("copy requires source and destination arguments")
|
|
129
|
+
try:
|
|
130
|
+
source = evaluate_command_template(raw.args[0], context)
|
|
131
|
+
except TemplateError as e:
|
|
132
|
+
raise MatcherError(
|
|
133
|
+
f"Error evaluating copy source '{raw.args[0]}': {e}"
|
|
134
|
+
) from e
|
|
135
|
+
try:
|
|
136
|
+
destination = evaluate_command_template(raw.args[1], context)
|
|
137
|
+
except TemplateError as e:
|
|
138
|
+
raise MatcherError(
|
|
139
|
+
f"Error evaluating copy destination '{raw.args[1]}': {e}"
|
|
140
|
+
) from e
|
|
141
|
+
return CopyAction(source=source, destination=destination)
|
|
142
|
+
|
|
143
|
+
if raw.action_type == "command":
|
|
144
|
+
template = raw.args[0] if raw.args else ""
|
|
145
|
+
try:
|
|
146
|
+
evaluated = evaluate_command_template(template, context)
|
|
147
|
+
except TemplateError as e:
|
|
148
|
+
raise MatcherError(
|
|
149
|
+
f"Error evaluating command template '{template}': {e}"
|
|
150
|
+
) from e
|
|
151
|
+
try:
|
|
152
|
+
parsed = shlex.split(evaluated)
|
|
153
|
+
except ValueError as e:
|
|
154
|
+
raise MatcherError(f"Error parsing command '{evaluated}': {e}") from e
|
|
155
|
+
if not parsed:
|
|
156
|
+
raise MatcherError("command requires at least command name")
|
|
157
|
+
return CommandAction(command=parsed[0], args=parsed[1:])
|
|
158
|
+
|
|
159
|
+
raise MatcherError(f"Unknown action type: {raw.action_type}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def apply_actions(
|
|
163
|
+
rules: list[ProjectRule],
|
|
164
|
+
context: TemplateContext,
|
|
165
|
+
kind: ActionKind,
|
|
166
|
+
) -> list[RuleActions]:
|
|
167
|
+
"""Match rules and return executable bundles for the given ``kind``.
|
|
168
|
+
|
|
169
|
+
A :class:`RuleActions` bundle is produced for every rule whose ``when``
|
|
170
|
+
predicate evaluates truthy, even when that rule has no actions for the
|
|
171
|
+
requested ``kind`` — the bundle's ``actions`` list is simply empty. This
|
|
172
|
+
keeps the CLI loop's failure-tracking symmetric: a rule that ran zero
|
|
173
|
+
actions still has a known index/criticality for the summary.
|
|
174
|
+
|
|
175
|
+
The ``context`` carries the URI, branch, tags, source path, and
|
|
176
|
+
destination path that ``when`` predicates and command templates evaluate
|
|
177
|
+
against. Callers (``clone``, ``add``, ``remove``) populate it from the
|
|
178
|
+
operation in progress; see :class:`TemplateContext` for the field-level
|
|
179
|
+
contract.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
rules: Project rules from the validated config.
|
|
183
|
+
context: Evaluation context — see :class:`TemplateContext`.
|
|
184
|
+
kind: Which action list to read — ``"after_clone"``, ``"after_add"``,
|
|
185
|
+
or ``"before_remove"`` (ADR-0011).
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Matched rules paired with their typed actions, in config order. Each
|
|
189
|
+
bundle carries the rule's index, predicate text, and criticality so
|
|
190
|
+
the CLI can attribute failures and choose exit codes.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
MatcherError: If a ``when`` predicate or a ``command`` template fails
|
|
194
|
+
to evaluate. The CLI converts this to a config-error exit (2);
|
|
195
|
+
it is never swallowed.
|
|
196
|
+
"""
|
|
197
|
+
eval_context = _create_predicate_context(context)
|
|
198
|
+
|
|
199
|
+
bundles: list[RuleActions] = []
|
|
200
|
+
for i, rule in enumerate(rules):
|
|
201
|
+
try:
|
|
202
|
+
matched = evaluate_predicate(rule.when, eval_context)
|
|
203
|
+
except TemplateError as e:
|
|
204
|
+
raise MatcherError(
|
|
205
|
+
f"Error evaluating 'when' for project rule {i}: {e}"
|
|
206
|
+
) from e
|
|
207
|
+
if not matched:
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
source_actions = getattr(rule, _KIND_TO_FIELD[kind])
|
|
211
|
+
actions: list[Action] = []
|
|
212
|
+
for raw in source_actions:
|
|
213
|
+
actions.append(_build_action(raw, eval_context))
|
|
214
|
+
|
|
215
|
+
bundles.append(
|
|
216
|
+
RuleActions(
|
|
217
|
+
index=i,
|
|
218
|
+
predicate=rule.when,
|
|
219
|
+
critical=rule.critical,
|
|
220
|
+
actions=actions,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return bundles
|
gww/actions/types.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Typed action objects executed by clone and add commands.
|
|
2
|
+
|
|
3
|
+
Each action is a small class that knows how to run itself against a target
|
|
4
|
+
directory. Actions are constructed by :func:`gww.actions.apply_actions`,
|
|
5
|
+
which evaluates any predicate and command-template context for them.
|
|
6
|
+
|
|
7
|
+
The concrete action classes cover the supported ``copy`` and ``command``
|
|
8
|
+
action types from the YAML config (ADR-0012).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional, Protocol
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ActionError(Exception):
|
|
21
|
+
"""Raised when an action fails to execute."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Action(Protocol):
|
|
25
|
+
"""Protocol every executable action implements.
|
|
26
|
+
|
|
27
|
+
Actions are run by the clone/add commands inside a ``for`` loop; calling
|
|
28
|
+
code is expected to wrap each invocation in its own try/except and decide
|
|
29
|
+
whether to surface the failure or continue with the remaining actions.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def run(
|
|
33
|
+
self,
|
|
34
|
+
source_dir: Optional[Path],
|
|
35
|
+
target_dir: Path,
|
|
36
|
+
pass_through_stdout: bool = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Execute the action against ``target_dir``.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
source_dir: Path to the source repository. Currently unused by
|
|
42
|
+
the shipped action types but retained on the protocol to
|
|
43
|
+
avoid touching every call site (ADR-0012 §"Notes for future
|
|
44
|
+
readers").
|
|
45
|
+
target_dir: Path to operate on (source repo for ``after_clone``,
|
|
46
|
+
worktree for ``after_add`` and ``before_remove``).
|
|
47
|
+
pass_through_stdout: Only meaningful for :class:`CommandAction`.
|
|
48
|
+
When ``True``, the external command's stdout is inherited from
|
|
49
|
+
the parent (so the user sees its progress in real time) while
|
|
50
|
+
stderr stays captured for the :class:`ActionError` message.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ActionError: If the action fails for any reason.
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CopyAction:
|
|
59
|
+
"""Copy a file or directory tree from a resolved source into ``target_dir``.
|
|
60
|
+
|
|
61
|
+
The two constructor arguments are *template-evaluated* strings supplied
|
|
62
|
+
by :func:`gww.actions.apply_actions` (i.e. any ``source_path(extra)``,
|
|
63
|
+
``current_worktree(extra)``, or absolute-literal reference has already
|
|
64
|
+
been resolved before the action is constructed). The operation itself
|
|
65
|
+
is selected by the resolved source's filesystem type — ``shutil.copy2``
|
|
66
|
+
(silent overwrite) for files, ``shutil.copytree(src, dst,
|
|
67
|
+
dirs_exist_ok=True)`` (merge into an existing directory) for directory
|
|
68
|
+
trees. The destination's parent is created with
|
|
69
|
+
``mkdir(parents=True, exist_ok=True)`` before either operation runs.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
source: Absolute source path (file or directory) as returned by the
|
|
73
|
+
template engine — no further template substitution happens here.
|
|
74
|
+
destination: Destination path relative to ``target_dir``. An absolute
|
|
75
|
+
destination bypasses the relative resolution and is used as-is.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, source: str, destination: str) -> None:
|
|
79
|
+
self.source = source
|
|
80
|
+
self.destination = destination
|
|
81
|
+
|
|
82
|
+
def run(
|
|
83
|
+
self,
|
|
84
|
+
source_dir: Optional[Path],
|
|
85
|
+
target_dir: Path,
|
|
86
|
+
pass_through_stdout: bool = False,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Copy ``source`` to ``target_dir / destination``.
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ActionError: If the source is missing, is neither a file nor a
|
|
92
|
+
directory, or the copy operation fails.
|
|
93
|
+
"""
|
|
94
|
+
del source_dir, pass_through_stdout # unused for copy
|
|
95
|
+
literal = Path(self.source).expanduser()
|
|
96
|
+
dest_path = Path(self.destination)
|
|
97
|
+
if not dest_path.is_absolute():
|
|
98
|
+
dest_path = target_dir / dest_path
|
|
99
|
+
|
|
100
|
+
if not os.path.lexists(literal):
|
|
101
|
+
raise ActionError(f"Source path not found for copy: {literal}")
|
|
102
|
+
|
|
103
|
+
# A broken symlink has ``is_symlink() == True`` but ``exists() ==
|
|
104
|
+
# False`` (the latter follows the link). ``Path.resolve()`` follows
|
|
105
|
+
# symlinks too, so a broken link resolves to its (non-existent)
|
|
106
|
+
# target — that would land in the "not found" branch above if we
|
|
107
|
+
# resolved first. Detect the broken-symlink case here so the error
|
|
108
|
+
# points at the symlink, not the missing target.
|
|
109
|
+
if literal.is_symlink() and not literal.exists():
|
|
110
|
+
raise ActionError(
|
|
111
|
+
f"Source is neither a file nor a directory for copy: {literal}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
source_path = literal.resolve()
|
|
115
|
+
|
|
116
|
+
if source_path.is_file():
|
|
117
|
+
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
try:
|
|
119
|
+
shutil.copy2(source_path, dest_path)
|
|
120
|
+
except OSError as e:
|
|
121
|
+
raise ActionError(
|
|
122
|
+
f"Failed to copy {source_path} to {dest_path}: {e}"
|
|
123
|
+
) from e
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if source_path.is_dir():
|
|
127
|
+
try:
|
|
128
|
+
shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
|
|
129
|
+
except OSError as e:
|
|
130
|
+
raise ActionError(
|
|
131
|
+
f"Failed to copy directory {source_path} to {dest_path}: {e}"
|
|
132
|
+
) from e
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
raise ActionError(
|
|
136
|
+
f"Source is neither a file nor a directory for copy: {source_path}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class CommandAction:
|
|
141
|
+
"""Run an external command with ``target_dir`` as the working directory.
|
|
142
|
+
|
|
143
|
+
Attributes:
|
|
144
|
+
command: Executable name or path (already evaluated and shlex-split).
|
|
145
|
+
args: Arguments to pass to the command.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, command: str, args: list[str]) -> None:
|
|
149
|
+
self.command = command
|
|
150
|
+
self.args = args
|
|
151
|
+
|
|
152
|
+
def run(
|
|
153
|
+
self,
|
|
154
|
+
source_dir: Optional[Path],
|
|
155
|
+
target_dir: Path,
|
|
156
|
+
pass_through_stdout: bool = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Invoke the command in ``target_dir``.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ActionError: If the command exits non-zero, the executable is
|
|
162
|
+
missing, or the subprocess cannot be started.
|
|
163
|
+
"""
|
|
164
|
+
del source_dir # commands always run from target_dir
|
|
165
|
+
cmd = [self.command] + self.args
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
cmd,
|
|
170
|
+
cwd=target_dir,
|
|
171
|
+
stdout=None if pass_through_stdout else subprocess.PIPE,
|
|
172
|
+
stderr=None if pass_through_stdout else subprocess.PIPE,
|
|
173
|
+
text=True,
|
|
174
|
+
check=False,
|
|
175
|
+
)
|
|
176
|
+
except FileNotFoundError as e:
|
|
177
|
+
raise ActionError(f"Command not found: {self.command}") from e
|
|
178
|
+
except OSError as e:
|
|
179
|
+
raise ActionError(f"Failed to execute command: {e}") from e
|
|
180
|
+
|
|
181
|
+
if result.returncode != 0:
|
|
182
|
+
stderr_text = (result.stderr or "").strip()
|
|
183
|
+
raise ActionError(
|
|
184
|
+
f"Command failed: {' '.join(cmd)}\n"
|
|
185
|
+
f"Exit code: {result.returncode}\n"
|
|
186
|
+
f"Stderr: {stderr_text}"
|
|
187
|
+
)
|
gww/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI module for sgw commands."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command implementations."""
|
gww/cli/commands/add.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Add worktree command implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from gww.actions import ActionError, MatcherError, apply_actions
|
|
8
|
+
from gww.cli.context import (
|
|
9
|
+
CommandContext,
|
|
10
|
+
CommandExit,
|
|
11
|
+
RuleFailure,
|
|
12
|
+
exit_on_error,
|
|
13
|
+
load_config_or_exit,
|
|
14
|
+
parse_uri_or_exit,
|
|
15
|
+
print_action_failure_summary,
|
|
16
|
+
resolve_source_repo_or_exit,
|
|
17
|
+
)
|
|
18
|
+
from gww.config.resolver import ResolverError, resolve_worktree_path
|
|
19
|
+
from gww.git.branch import (
|
|
20
|
+
BranchExistsError,
|
|
21
|
+
branch_exists,
|
|
22
|
+
create_branch,
|
|
23
|
+
)
|
|
24
|
+
from gww.git.repository import (
|
|
25
|
+
GitCommandError,
|
|
26
|
+
get_current_commit,
|
|
27
|
+
)
|
|
28
|
+
from gww.git.worktree import WorktreeExistsError, add_worktree
|
|
29
|
+
from gww.template.functions import TemplateContext
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@exit_on_error
|
|
33
|
+
def run_add(ctx: CommandContext) -> int:
|
|
34
|
+
"""Execute the add worktree command.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ctx: Per-invocation command context.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Exit code (0 for success, 1 for runtime/action failure, 2 for config
|
|
41
|
+
error).
|
|
42
|
+
"""
|
|
43
|
+
if ctx.branch is None:
|
|
44
|
+
raise CommandExit(1, "Error: Missing branch name.")
|
|
45
|
+
|
|
46
|
+
cwd = Path.cwd()
|
|
47
|
+
source_path, remote_uri = resolve_source_repo_or_exit(cwd)
|
|
48
|
+
uri = parse_uri_or_exit(remote_uri)
|
|
49
|
+
config = load_config_or_exit()
|
|
50
|
+
|
|
51
|
+
if not branch_exists(source_path, ctx.branch):
|
|
52
|
+
if ctx.create_branch:
|
|
53
|
+
try:
|
|
54
|
+
current_commit = get_current_commit(cwd)
|
|
55
|
+
create_branch(source_path, ctx.branch, current_commit)
|
|
56
|
+
ctx.verbose_msg(
|
|
57
|
+
f"Created branch '{ctx.branch}' from {current_commit[:8]}"
|
|
58
|
+
)
|
|
59
|
+
except (GitCommandError, BranchExistsError) as e:
|
|
60
|
+
raise CommandExit(1, f"Error creating branch: {e}") from e
|
|
61
|
+
else:
|
|
62
|
+
raise CommandExit(
|
|
63
|
+
1,
|
|
64
|
+
f"Error: Branch '{ctx.branch}' not found. "
|
|
65
|
+
"Use -c/--create-branch to create from current commit.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
worktree_path = resolve_worktree_path(config, uri, ctx.branch, ctx.tags)
|
|
70
|
+
except ResolverError as e:
|
|
71
|
+
raise CommandExit(2, f"Error resolving worktree path: {e}") from e
|
|
72
|
+
|
|
73
|
+
ctx.verbose_msg(f"Adding worktree for '{ctx.branch}' at {worktree_path}...")
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
add_worktree(
|
|
77
|
+
source_path,
|
|
78
|
+
worktree_path,
|
|
79
|
+
ctx.branch,
|
|
80
|
+
pass_through_stdout=not ctx.quiet,
|
|
81
|
+
)
|
|
82
|
+
except (WorktreeExistsError, GitCommandError) as e:
|
|
83
|
+
raise CommandExit(1, f"Error adding worktree: {e}") from e
|
|
84
|
+
|
|
85
|
+
failures: list[RuleFailure] = []
|
|
86
|
+
if config.actions:
|
|
87
|
+
context = TemplateContext(
|
|
88
|
+
uri=uri,
|
|
89
|
+
branch=ctx.branch,
|
|
90
|
+
source_path=source_path,
|
|
91
|
+
dest_path=worktree_path,
|
|
92
|
+
tags=ctx.tags,
|
|
93
|
+
)
|
|
94
|
+
try:
|
|
95
|
+
rule_bundles = apply_actions(config.actions, context, kind="after_add")
|
|
96
|
+
except MatcherError as e:
|
|
97
|
+
raise CommandExit(2, f"Config error: {e}") from e
|
|
98
|
+
|
|
99
|
+
if rule_bundles:
|
|
100
|
+
ctx.verbose_msg(f"Executing {len(rule_bundles)} rule(s)...")
|
|
101
|
+
for bundle in rule_bundles:
|
|
102
|
+
for action in bundle.actions:
|
|
103
|
+
try:
|
|
104
|
+
action.run(
|
|
105
|
+
source_dir=source_path,
|
|
106
|
+
target_dir=worktree_path,
|
|
107
|
+
pass_through_stdout=not ctx.quiet,
|
|
108
|
+
)
|
|
109
|
+
except ActionError as e:
|
|
110
|
+
failures.append(RuleFailure(bundle, action, e))
|
|
111
|
+
if bundle.critical:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
if failures:
|
|
115
|
+
print_action_failure_summary(failures)
|
|
116
|
+
|
|
117
|
+
if not failures:
|
|
118
|
+
ctx.say(str(worktree_path))
|
|
119
|
+
|
|
120
|
+
if any(f.bundle.critical for f in failures):
|
|
121
|
+
raise CommandExit(1, "")
|
|
122
|
+
return 0
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Clone command implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from gww.actions import ActionError, MatcherError, apply_actions
|
|
6
|
+
from gww.cli.context import (
|
|
7
|
+
CommandContext,
|
|
8
|
+
CommandExit,
|
|
9
|
+
RuleFailure,
|
|
10
|
+
exit_on_error,
|
|
11
|
+
load_config_or_exit,
|
|
12
|
+
parse_uri_or_exit,
|
|
13
|
+
print_action_failure_summary,
|
|
14
|
+
)
|
|
15
|
+
from gww.config.resolver import ResolverError, resolve_source_path
|
|
16
|
+
from gww.git.repository import (
|
|
17
|
+
GitCommandError,
|
|
18
|
+
clone_repository,
|
|
19
|
+
try_get_current_branch,
|
|
20
|
+
)
|
|
21
|
+
from gww.template.functions import TemplateContext
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@exit_on_error
|
|
25
|
+
def run_clone(ctx: CommandContext) -> int:
|
|
26
|
+
"""Execute the clone command.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
ctx: Per-invocation command context.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Exit code (0 for success, 1 for runtime/action failure, 2 for config
|
|
33
|
+
error).
|
|
34
|
+
"""
|
|
35
|
+
if ctx.uri is None:
|
|
36
|
+
raise CommandExit(1, "Error: Missing repository URI.")
|
|
37
|
+
|
|
38
|
+
uri = parse_uri_or_exit(ctx.uri)
|
|
39
|
+
config = load_config_or_exit()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
source_path = resolve_source_path(config, uri, ctx.tags)
|
|
43
|
+
except ResolverError as e:
|
|
44
|
+
raise CommandExit(2, f"Error resolving source path: {e}") from e
|
|
45
|
+
|
|
46
|
+
if source_path.exists():
|
|
47
|
+
raise CommandExit(
|
|
48
|
+
1,
|
|
49
|
+
f"Error: Repository already exists at: {source_path}",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
ctx.verbose_msg(f"Cloning {ctx.uri} to {source_path}...")
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
clone_repository(ctx.uri, source_path, pass_through_stdout=not ctx.quiet)
|
|
56
|
+
except GitCommandError as e:
|
|
57
|
+
raise CommandExit(1, f"Error cloning repository: {e}") from e
|
|
58
|
+
|
|
59
|
+
failures: list[RuleFailure] = []
|
|
60
|
+
if config.actions:
|
|
61
|
+
branch = try_get_current_branch(source_path)
|
|
62
|
+
context = TemplateContext(
|
|
63
|
+
uri=uri,
|
|
64
|
+
branch=branch,
|
|
65
|
+
source_path=source_path,
|
|
66
|
+
dest_path=source_path,
|
|
67
|
+
tags=ctx.tags,
|
|
68
|
+
)
|
|
69
|
+
try:
|
|
70
|
+
rule_bundles = apply_actions(config.actions, context, kind="after_clone")
|
|
71
|
+
except MatcherError as e:
|
|
72
|
+
raise CommandExit(2, f"Config error: {e}") from e
|
|
73
|
+
|
|
74
|
+
if rule_bundles:
|
|
75
|
+
ctx.verbose_msg(f"Executing {len(rule_bundles)} rule(s)...")
|
|
76
|
+
for bundle in rule_bundles:
|
|
77
|
+
for action in bundle.actions:
|
|
78
|
+
try:
|
|
79
|
+
action.run(
|
|
80
|
+
source_dir=None,
|
|
81
|
+
target_dir=source_path,
|
|
82
|
+
pass_through_stdout=not ctx.quiet,
|
|
83
|
+
)
|
|
84
|
+
except ActionError as e:
|
|
85
|
+
failures.append(RuleFailure(bundle, action, e))
|
|
86
|
+
if bundle.critical:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
if failures:
|
|
90
|
+
print_action_failure_summary(failures)
|
|
91
|
+
|
|
92
|
+
if not failures:
|
|
93
|
+
ctx.say(str(source_path))
|
|
94
|
+
|
|
95
|
+
if any(f.bundle.critical for f in failures):
|
|
96
|
+
raise CommandExit(1, "")
|
|
97
|
+
return 0
|