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
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Template evaluation engine using simpleeval with strict type checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from simpleeval import EvalWithCompoundTypes, FunctionNotDefined, NameNotDefined
|
|
9
|
+
|
|
10
|
+
from gww.template.functions import TemplateContext, create_function_registry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TemplateError(Exception):
|
|
14
|
+
"""Base exception for template-related errors."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FunctionTypeError(TemplateError):
|
|
20
|
+
"""Raised when function arguments have wrong types."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContextError(TemplateError):
|
|
26
|
+
"""Raised when required context variable is missing."""
|
|
27
|
+
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StrictSimpleEval(EvalWithCompoundTypes): # type: ignore[misc]
|
|
32
|
+
"""Subclass of simpleeval with strict type checking.
|
|
33
|
+
|
|
34
|
+
Provides:
|
|
35
|
+
- Type validation for function arguments
|
|
36
|
+
- Clear error messages with context
|
|
37
|
+
- Safe evaluation of predicates and templates
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
functions: dict[str, Callable[..., Any]] | None = None,
|
|
43
|
+
names: dict[str, Any] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Initialize with custom functions and names.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
functions: Dictionary of functions available during evaluation.
|
|
49
|
+
names: Dictionary of variables available during evaluation.
|
|
50
|
+
"""
|
|
51
|
+
super().__init__(functions=functions or {}, names=names or {})
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Pattern to match function calls in templates: function_name(args)
|
|
55
|
+
FUNCTION_CALL_PATTERN = re.compile(r"([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^()]*)\)")
|
|
56
|
+
|
|
57
|
+
# Escape sequences for literal parentheses
|
|
58
|
+
ESCAPE_OPEN = "\x00ESCAPE_OPEN\x00"
|
|
59
|
+
ESCAPE_CLOSE = "\x00ESCAPE_CLOSE\x00"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _preprocess_template(template: str) -> tuple[str, list[tuple[str, str]]]:
|
|
63
|
+
"""Preprocess template to extract function calls.
|
|
64
|
+
|
|
65
|
+
Handles:
|
|
66
|
+
- Escaped parentheses (( -> single (
|
|
67
|
+
- Function call extraction
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
template: Raw template string.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (preprocessed template with placeholders, list of (placeholder, expression) tuples).
|
|
74
|
+
"""
|
|
75
|
+
# Replace escaped parentheses
|
|
76
|
+
processed = template.replace("((", ESCAPE_OPEN)
|
|
77
|
+
processed = processed.replace("))", ESCAPE_CLOSE)
|
|
78
|
+
|
|
79
|
+
# Find and extract function calls
|
|
80
|
+
function_calls: list[tuple[str, str]] = []
|
|
81
|
+
placeholder_idx = 0
|
|
82
|
+
|
|
83
|
+
def replace_function(match: re.Match[str]) -> str:
|
|
84
|
+
nonlocal placeholder_idx
|
|
85
|
+
func_name = match.group(1)
|
|
86
|
+
args = match.group(2)
|
|
87
|
+
expression = f"{func_name}({args})"
|
|
88
|
+
placeholder = f"\x01FUNC_{placeholder_idx}\x01"
|
|
89
|
+
function_calls.append((placeholder, expression))
|
|
90
|
+
placeholder_idx += 1
|
|
91
|
+
return placeholder
|
|
92
|
+
|
|
93
|
+
# Repeatedly find function calls (handles simple nesting)
|
|
94
|
+
prev = None
|
|
95
|
+
while prev != processed:
|
|
96
|
+
prev = processed
|
|
97
|
+
processed = FUNCTION_CALL_PATTERN.sub(replace_function, processed)
|
|
98
|
+
|
|
99
|
+
return processed, function_calls
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _postprocess_template(template: str) -> str:
|
|
103
|
+
"""Restore escaped parentheses in template.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
template: Template with escape sequences.
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
Template with single parentheses restored.
|
|
110
|
+
"""
|
|
111
|
+
result = template.replace(ESCAPE_OPEN, "(")
|
|
112
|
+
result = result.replace(ESCAPE_CLOSE, ")")
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def evaluate_template(
|
|
117
|
+
template: str,
|
|
118
|
+
context: TemplateContext,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Evaluate a path template string.
|
|
121
|
+
|
|
122
|
+
Template syntax:
|
|
123
|
+
- Function calls: path(-1), branch(), norm_branch("_")
|
|
124
|
+
- Escaped parentheses: (( -> (
|
|
125
|
+
- Static text: passed through as-is
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
template: Template string to evaluate.
|
|
129
|
+
context: Template context with URI, branch, etc.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Evaluated template string.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
TemplateError: If evaluation fails.
|
|
136
|
+
FunctionTypeError: If function arguments are invalid.
|
|
137
|
+
ContextError: If required context is missing.
|
|
138
|
+
"""
|
|
139
|
+
# Create function registry
|
|
140
|
+
functions = create_function_registry(context)
|
|
141
|
+
|
|
142
|
+
# Preprocess template
|
|
143
|
+
processed, function_calls = _preprocess_template(template)
|
|
144
|
+
|
|
145
|
+
# Evaluate each function call
|
|
146
|
+
evaluator = StrictSimpleEval(functions=functions)
|
|
147
|
+
|
|
148
|
+
for placeholder, expression in function_calls:
|
|
149
|
+
try:
|
|
150
|
+
result = evaluator.eval(expression)
|
|
151
|
+
if not isinstance(result, str):
|
|
152
|
+
result = str(result)
|
|
153
|
+
processed = processed.replace(placeholder, result)
|
|
154
|
+
except FunctionNotDefined as e:
|
|
155
|
+
raise TemplateError(f"Unknown function in template: {e}") from e
|
|
156
|
+
except NameNotDefined as e:
|
|
157
|
+
raise TemplateError(f"Unknown variable in template: {e}") from e
|
|
158
|
+
except ValueError as e:
|
|
159
|
+
raise ContextError(str(e)) from e
|
|
160
|
+
except TypeError as e:
|
|
161
|
+
raise FunctionTypeError(f"Invalid argument types: {e}") from e
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise TemplateError(f"Template evaluation failed: {e}") from e
|
|
164
|
+
|
|
165
|
+
# Postprocess to restore escaped parentheses
|
|
166
|
+
return _postprocess_template(processed)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def evaluate_predicate(
|
|
170
|
+
predicate: str,
|
|
171
|
+
variables: dict[str, Any],
|
|
172
|
+
) -> bool:
|
|
173
|
+
"""Evaluate a predicate expression.
|
|
174
|
+
|
|
175
|
+
Predicate syntax:
|
|
176
|
+
- Comparison: host == "github.com"
|
|
177
|
+
- Contains: "github" in host
|
|
178
|
+
- Logical: not contains(host, "scp") and len(path) > 1
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
predicate: Predicate expression string.
|
|
182
|
+
variables: Dictionary of variables and functions for evaluation.
|
|
183
|
+
Callable values are treated as functions, others as variables.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Boolean result of predicate evaluation.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
TemplateError: If evaluation fails or result is not boolean.
|
|
190
|
+
"""
|
|
191
|
+
# Separate functions from variables
|
|
192
|
+
functions: dict[str, Callable[..., Any]] = {}
|
|
193
|
+
names: dict[str, Any] = {}
|
|
194
|
+
|
|
195
|
+
for key, value in variables.items():
|
|
196
|
+
if callable(value):
|
|
197
|
+
functions[key] = value
|
|
198
|
+
else:
|
|
199
|
+
names[key] = value
|
|
200
|
+
|
|
201
|
+
evaluator = StrictSimpleEval(functions=functions, names=names)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
result = evaluator.eval(predicate)
|
|
205
|
+
except FunctionNotDefined as e:
|
|
206
|
+
raise TemplateError(f"Unknown function in predicate: {e}") from e
|
|
207
|
+
except NameNotDefined as e:
|
|
208
|
+
raise TemplateError(f"Unknown variable in predicate: {e}") from e
|
|
209
|
+
except Exception as e:
|
|
210
|
+
raise TemplateError(f"Predicate evaluation failed: {e}") from e
|
|
211
|
+
|
|
212
|
+
if not isinstance(result, bool):
|
|
213
|
+
raise TemplateError(
|
|
214
|
+
f"Predicate must evaluate to boolean, got {type(result).__name__}: {predicate}"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def evaluate_command_template(
|
|
221
|
+
command: str,
|
|
222
|
+
variables: dict[str, Any],
|
|
223
|
+
) -> str:
|
|
224
|
+
"""Evaluate a command template string with function calls.
|
|
225
|
+
|
|
226
|
+
Command template syntax:
|
|
227
|
+
- Function calls: current_worktree(), tag("name"), source_path()
|
|
228
|
+
- Escaped parentheses: (( -> (
|
|
229
|
+
- Static text: passed through as-is
|
|
230
|
+
|
|
231
|
+
This is similar to evaluate_template() but accepts a pre-built context
|
|
232
|
+
dictionary (like evaluate_predicate()) instead of a TemplateContext.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
command: Command template string to evaluate.
|
|
236
|
+
variables: Dictionary of variables and functions for evaluation.
|
|
237
|
+
Callable values are treated as functions, others as variables.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
Evaluated command string with all functions replaced.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
TemplateError: If evaluation fails.
|
|
244
|
+
FunctionTypeError: If function arguments are invalid.
|
|
245
|
+
ContextError: If required context is missing.
|
|
246
|
+
"""
|
|
247
|
+
# Separate functions from variables
|
|
248
|
+
functions: dict[str, Callable[..., Any]] = {}
|
|
249
|
+
names: dict[str, Any] = {}
|
|
250
|
+
|
|
251
|
+
for key, value in variables.items():
|
|
252
|
+
if callable(value):
|
|
253
|
+
functions[key] = value
|
|
254
|
+
else:
|
|
255
|
+
names[key] = value
|
|
256
|
+
|
|
257
|
+
# Preprocess template to extract function calls
|
|
258
|
+
processed, function_calls = _preprocess_template(command)
|
|
259
|
+
|
|
260
|
+
# Evaluate each function call
|
|
261
|
+
evaluator = StrictSimpleEval(functions=functions, names=names)
|
|
262
|
+
|
|
263
|
+
for placeholder, expression in function_calls:
|
|
264
|
+
try:
|
|
265
|
+
result = evaluator.eval(expression)
|
|
266
|
+
if not isinstance(result, str):
|
|
267
|
+
result = str(result)
|
|
268
|
+
processed = processed.replace(placeholder, result)
|
|
269
|
+
except FunctionNotDefined as e:
|
|
270
|
+
raise TemplateError(f"Unknown function in command: {e}") from e
|
|
271
|
+
except NameNotDefined as e:
|
|
272
|
+
raise TemplateError(f"Unknown variable in command: {e}") from e
|
|
273
|
+
except ValueError as e:
|
|
274
|
+
raise ContextError(str(e)) from e
|
|
275
|
+
except TypeError as e:
|
|
276
|
+
raise FunctionTypeError(f"Invalid argument types: {e}") from e
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise TemplateError(f"Command template evaluation failed: {e}") from e
|
|
279
|
+
|
|
280
|
+
# Postprocess to restore escaped parentheses
|
|
281
|
+
return _postprocess_template(processed)
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""Template function registry for path template evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from gww.utils.uri import ParsedURI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TemplateContext:
|
|
15
|
+
"""Context for template evaluation.
|
|
16
|
+
|
|
17
|
+
The same context object feeds every evaluation site that uses the unified
|
|
18
|
+
function registry: source-rule ``when`` predicates, ``default_sources``
|
|
19
|
+
and ``default_worktrees`` path templates, and project-rule ``when``
|
|
20
|
+
predicates plus their command templates.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
uri: Parsed URI object. Populated for ``clone`` (from CLI) and for
|
|
24
|
+
``add`` (from the source repo's ``origin`` remote — *not* the
|
|
25
|
+
original clone URL if the user later changed remotes).
|
|
26
|
+
branch: Git branch name. For ``add``, the user-supplied branch. For
|
|
27
|
+
``clone``, the branch git checked out by default (the remote's
|
|
28
|
+
HEAD) after the clone operation completes; ``""`` if HEAD is
|
|
29
|
+
detached.
|
|
30
|
+
source_path: Source repository path. Always set for project-rule
|
|
31
|
+
predicates; feeds the ``source_path(extra?)`` template function
|
|
32
|
+
and the ``file_exists``/``dir_exists``/``path_exists`` helpers.
|
|
33
|
+
dest_path: Destination path for project-rule actions — the worktree
|
|
34
|
+
path for ``after_add`` and ``before_remove``, the source path
|
|
35
|
+
for ``after_clone``. Feeds the ``current_worktree(extra?)``
|
|
36
|
+
template function. ``None`` in non-project evaluation sites
|
|
37
|
+
(URI predicates, path templates).
|
|
38
|
+
tags: Dictionary of tag key-value pairs from the CLI.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
uri: Optional[ParsedURI] = None
|
|
42
|
+
branch: Optional[str] = None
|
|
43
|
+
source_path: Optional[Path] = None
|
|
44
|
+
dest_path: Optional[Path] = None
|
|
45
|
+
tags: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FunctionRegistry:
|
|
49
|
+
"""Registry of template functions available during evaluation.
|
|
50
|
+
|
|
51
|
+
Provides shared functions available in templates, URI predicates, and project predicates:
|
|
52
|
+
- URI functions: host(), port(), protocol(), uri(), path(index)
|
|
53
|
+
- Branch functions: branch(), norm_branch(replacement)
|
|
54
|
+
- Tag functions: tag(name), tag_exist(name)
|
|
55
|
+
|
|
56
|
+
Template-only functions (not available in predicates):
|
|
57
|
+
- Utility functions: time_id(fmt) - generates datetime-based identifier strings
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, context: TemplateContext) -> None:
|
|
61
|
+
"""Initialize registry with evaluation context.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
context: Template context with URI, branch, etc.
|
|
65
|
+
"""
|
|
66
|
+
self._context = context
|
|
67
|
+
self._functions: dict[str, Callable[..., Any]] = {}
|
|
68
|
+
self._cached_datetime: Optional[datetime] = None
|
|
69
|
+
self._register_builtin_functions()
|
|
70
|
+
|
|
71
|
+
def _register_builtin_functions(self) -> None:
|
|
72
|
+
"""Register all built-in template functions."""
|
|
73
|
+
# URI functions
|
|
74
|
+
self._functions["host"] = self._host
|
|
75
|
+
self._functions["port"] = self._port
|
|
76
|
+
self._functions["protocol"] = self._protocol
|
|
77
|
+
self._functions["uri"] = self._uri
|
|
78
|
+
self._functions["path"] = self._path
|
|
79
|
+
# Branch functions
|
|
80
|
+
self._functions["branch"] = self._branch
|
|
81
|
+
self._functions["norm_branch"] = self._norm_branch
|
|
82
|
+
# Tag functions
|
|
83
|
+
self._functions["tag"] = self._tag
|
|
84
|
+
self._functions["tag_exist"] = self._tag_exist
|
|
85
|
+
# Utility functions (template-only)
|
|
86
|
+
self._functions["time_id"] = self._time_id
|
|
87
|
+
|
|
88
|
+
def get_functions(self) -> dict[str, Callable[..., Any]]:
|
|
89
|
+
"""Return dictionary of all registered functions.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dictionary mapping function names to callables.
|
|
93
|
+
"""
|
|
94
|
+
return self._functions.copy()
|
|
95
|
+
|
|
96
|
+
# --- URI Functions ---
|
|
97
|
+
|
|
98
|
+
def _host(self) -> str:
|
|
99
|
+
"""Get URI hostname.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Hostname from URI (e.g., "github.com").
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
ValueError: If no URI context available.
|
|
106
|
+
"""
|
|
107
|
+
if self._context.uri is None:
|
|
108
|
+
raise ValueError("No URI context available for host() function")
|
|
109
|
+
return self._context.uri.host
|
|
110
|
+
|
|
111
|
+
def _port(self) -> str:
|
|
112
|
+
"""Get URI port.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Port from URI, empty string if not specified.
|
|
116
|
+
|
|
117
|
+
Raises:
|
|
118
|
+
ValueError: If no URI context available.
|
|
119
|
+
"""
|
|
120
|
+
if self._context.uri is None:
|
|
121
|
+
raise ValueError("No URI context available for port() function")
|
|
122
|
+
return self._context.uri.port
|
|
123
|
+
|
|
124
|
+
def _protocol(self) -> str:
|
|
125
|
+
"""Get URI protocol/scheme.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Protocol from URI (e.g., "https", "ssh", "git").
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If no URI context available.
|
|
132
|
+
"""
|
|
133
|
+
if self._context.uri is None:
|
|
134
|
+
raise ValueError("No URI context available for protocol() function")
|
|
135
|
+
return self._context.uri.protocol
|
|
136
|
+
|
|
137
|
+
def _uri(self) -> str:
|
|
138
|
+
"""Get full URI string.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Full URI string.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If no URI context available.
|
|
145
|
+
"""
|
|
146
|
+
if self._context.uri is None:
|
|
147
|
+
raise ValueError("No URI context available for uri() function")
|
|
148
|
+
return self._context.uri.uri
|
|
149
|
+
|
|
150
|
+
def _path(self, index: int) -> str:
|
|
151
|
+
"""Get URI path segment by index.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
index: Path segment index (0-based, negative for reverse).
|
|
155
|
+
Example: path(-1) returns last segment, path(0) returns first.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Path segment string at the specified index.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
ValueError: If no URI context or index out of range.
|
|
162
|
+
"""
|
|
163
|
+
if self._context.uri is None:
|
|
164
|
+
raise ValueError("No URI context available for path() function")
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return self._context.uri.path(index)
|
|
168
|
+
except IndexError:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"Path segment index {index} out of range. "
|
|
171
|
+
f"Available segments: {self._context.uri.path_segments}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def _branch(self) -> str:
|
|
175
|
+
"""Get current branch name as-is.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Branch name.
|
|
179
|
+
|
|
180
|
+
Raises:
|
|
181
|
+
ValueError: If no branch context available.
|
|
182
|
+
"""
|
|
183
|
+
if self._context.branch is None:
|
|
184
|
+
raise ValueError("No branch context available for branch() function")
|
|
185
|
+
return self._context.branch
|
|
186
|
+
|
|
187
|
+
def _norm_branch(self, replacement: str = "-") -> str:
|
|
188
|
+
"""Get branch name with '/' replaced.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
replacement: Character to replace '/' with (default: '-').
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
Normalized branch name.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
ValueError: If no branch context available.
|
|
198
|
+
"""
|
|
199
|
+
if self._context.branch is None:
|
|
200
|
+
raise ValueError("No branch context available for norm_branch() function")
|
|
201
|
+
return self._context.branch.replace("/", replacement)
|
|
202
|
+
|
|
203
|
+
def _tag(self, name: str) -> str:
|
|
204
|
+
"""Get tag value by name.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
name: Tag name.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
Tag value if tag exists with a value, empty string otherwise.
|
|
211
|
+
"""
|
|
212
|
+
return self._context.tags.get(name, "")
|
|
213
|
+
|
|
214
|
+
def _tag_exist(self, name: str) -> bool:
|
|
215
|
+
"""Check if tag exists.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Tag name.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True if tag exists (with or without value), False otherwise.
|
|
222
|
+
"""
|
|
223
|
+
return name in self._context.tags
|
|
224
|
+
|
|
225
|
+
# --- Utility Functions ---
|
|
226
|
+
|
|
227
|
+
def _time_id(self, fmt: str = "%Y%m%d-%H%M.%S") -> str:
|
|
228
|
+
"""Generate a datetime-based identifier string.
|
|
229
|
+
|
|
230
|
+
The datetime is captured on first call and cached for subsequent calls
|
|
231
|
+
within the same template evaluation session. This ensures consistent
|
|
232
|
+
timestamps when time_id() is called multiple times with different formats.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
fmt: Optional strftime format string. If not provided, uses default
|
|
236
|
+
format "%Y%m%d-%H%M.%S" (e.g., "20260120-2134.03").
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Formatted datetime string.
|
|
240
|
+
"""
|
|
241
|
+
if self._cached_datetime is None:
|
|
242
|
+
self._cached_datetime = datetime.now()
|
|
243
|
+
|
|
244
|
+
return self._cached_datetime.strftime(fmt)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def create_function_registry(context: TemplateContext) -> dict[str, Callable[..., Any]]:
|
|
248
|
+
"""Create a function registry for template evaluation.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
context: Template context with URI, branch, etc.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Dictionary of functions to pass to simpleeval.
|
|
255
|
+
"""
|
|
256
|
+
registry = FunctionRegistry(context)
|
|
257
|
+
return registry.get_functions()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def create_project_functions(
|
|
261
|
+
context: TemplateContext,
|
|
262
|
+
) -> dict[str, Callable[..., Any]]:
|
|
263
|
+
"""Create project-specific functions for project predicate evaluation.
|
|
264
|
+
|
|
265
|
+
These functions are only available in project predicates, not in templates
|
|
266
|
+
or URI predicates. The path-bearing helpers follow a fixed mapping that
|
|
267
|
+
does not vary by operation (ADR-0012 §"Uniform semantics across
|
|
268
|
+
operations"):
|
|
269
|
+
|
|
270
|
+
* ``source_path(extra?)`` is ``context.source_path`` (optionally joined
|
|
271
|
+
with ``extra``).
|
|
272
|
+
* ``current_worktree(extra?)`` is ``context.dest_path`` (optionally joined
|
|
273
|
+
with ``extra``).
|
|
274
|
+
|
|
275
|
+
Neither function aliases the other under any condition. They may resolve
|
|
276
|
+
to the same path string during ``gww clone`` because the CLI populates
|
|
277
|
+
*both* context fields with the clone target, but that is a CLI-side
|
|
278
|
+
property of how the calling command populates the context — never an
|
|
279
|
+
aliasing inside the helper itself.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
context: Template context whose ``source_path`` and ``dest_path`` are
|
|
283
|
+
used by the returned functions. ``source_path`` must be set;
|
|
284
|
+
``dest_path`` is required for ``current_worktree()`` to evaluate
|
|
285
|
+
and raises ``ValueError`` when ``None``.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Dictionary of project-specific functions.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: If ``context.source_path`` is ``None``.
|
|
292
|
+
"""
|
|
293
|
+
if context.source_path is None:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"create_project_functions requires context.source_path"
|
|
296
|
+
)
|
|
297
|
+
source_path = context.source_path
|
|
298
|
+
dest_path = context.dest_path
|
|
299
|
+
|
|
300
|
+
def _source_path(extra: str = "") -> str:
|
|
301
|
+
"""Get absolute path to source repository, optionally joined with ``extra``.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
extra: Path segment to append to the source repository root. An
|
|
305
|
+
empty string returns the bare source path.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Absolute, resolved path to the source repository (or the joined
|
|
309
|
+
path when ``extra`` is non-empty).
|
|
310
|
+
"""
|
|
311
|
+
return str((source_path / extra).resolve())
|
|
312
|
+
|
|
313
|
+
def _current_worktree(extra: str = "") -> str:
|
|
314
|
+
"""Get absolute path to the current worktree, optionally joined with ``extra``.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
extra: Path segment to append to the worktree root. An empty
|
|
318
|
+
string returns the bare worktree path.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
Absolute, resolved path to the worktree (or the joined path when
|
|
322
|
+
``extra`` is non-empty).
|
|
323
|
+
|
|
324
|
+
Raises:
|
|
325
|
+
ValueError: If ``context.dest_path`` is ``None``. The function
|
|
326
|
+
does not fall back to ``source_path()`` — that would re-
|
|
327
|
+
introduce the per-operation aliasing the uniform-mapping
|
|
328
|
+
principle rules out (ADR-0012).
|
|
329
|
+
"""
|
|
330
|
+
if dest_path is None:
|
|
331
|
+
raise ValueError(
|
|
332
|
+
"current_worktree() requires context.dest_path"
|
|
333
|
+
)
|
|
334
|
+
return str((dest_path / extra).resolve())
|
|
335
|
+
|
|
336
|
+
def _file_exists(path: str) -> bool:
|
|
337
|
+
"""Check if a file exists relative to source repository.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
path: Relative path to check.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
True if file exists.
|
|
344
|
+
"""
|
|
345
|
+
full_path = source_path / path
|
|
346
|
+
return full_path.is_file()
|
|
347
|
+
|
|
348
|
+
def _dir_exists(path: str) -> bool:
|
|
349
|
+
"""Check if a directory exists relative to source repository.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
path: Relative path to check.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
True if directory exists.
|
|
356
|
+
"""
|
|
357
|
+
full_path = source_path / path
|
|
358
|
+
return full_path.is_dir()
|
|
359
|
+
|
|
360
|
+
def _path_exists(path: str) -> bool:
|
|
361
|
+
"""Check if a path exists (file or directory) relative to source repository.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
path: Relative path to check.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
True if path exists.
|
|
368
|
+
"""
|
|
369
|
+
full_path = source_path / path
|
|
370
|
+
return full_path.exists()
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"source_path": _source_path,
|
|
374
|
+
"current_worktree": _current_worktree,
|
|
375
|
+
"file_exists": _file_exists,
|
|
376
|
+
"dir_exists": _dir_exists,
|
|
377
|
+
"path_exists": _path_exists,
|
|
378
|
+
}
|
gww/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions."""
|