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.
@@ -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."""
@@ -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