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/config/validator.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Configuration validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigValidationError(Exception):
|
|
10
|
+
"""Raised when config validation fails."""
|
|
11
|
+
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SourceRule:
|
|
17
|
+
"""Validated source routing rule.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
name: Rule name/identifier.
|
|
21
|
+
when: Expression evaluated against URI context.
|
|
22
|
+
sources: Template string for source checkout location.
|
|
23
|
+
worktrees: Template string for worktree location.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
when: str
|
|
28
|
+
sources: Optional[str] = None
|
|
29
|
+
worktrees: Optional[str] = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Action:
|
|
34
|
+
"""Validated project action.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
action_type: One of "copy", "command".
|
|
38
|
+
args: Action arguments.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
action_type: str
|
|
42
|
+
args: list[str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ProjectRule:
|
|
47
|
+
"""Validated project detection rule.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
when: Expression evaluated against the :class:`TemplateContext`
|
|
51
|
+
populated by the calling command (``clone``, ``add``, or
|
|
52
|
+
``remove``). Sees URI, branch, tags, source path, and
|
|
53
|
+
destination path.
|
|
54
|
+
after_clone: Actions executed after source checkout.
|
|
55
|
+
after_add: Actions executed when worktree is added.
|
|
56
|
+
before_remove: Actions executed before a worktree is removed by
|
|
57
|
+
``gww remove``. Allows cleanup (archive, notify, stash) before
|
|
58
|
+
``git worktree remove`` deletes the worktree. ADR-0011.
|
|
59
|
+
critical: Whether failures in this rule abort the command with exit 1.
|
|
60
|
+
Defaults to ``True`` so that a fresh rule behaves like a setup
|
|
61
|
+
step that must succeed. Set to ``False`` for best-effort rules
|
|
62
|
+
whose failures should be reported but not block the command.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
when: str
|
|
66
|
+
after_clone: list[Action] = field(default_factory=list)
|
|
67
|
+
after_add: list[Action] = field(default_factory=list)
|
|
68
|
+
before_remove: list[Action] = field(default_factory=list)
|
|
69
|
+
critical: bool = True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Config:
|
|
74
|
+
"""Validated configuration.
|
|
75
|
+
|
|
76
|
+
Attributes:
|
|
77
|
+
default_sources: Template string for default source location.
|
|
78
|
+
default_worktrees: Template string for default worktree location.
|
|
79
|
+
sources: Named source routing rules.
|
|
80
|
+
actions: Action rules for project detection.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
default_sources: str
|
|
84
|
+
default_worktrees: str
|
|
85
|
+
sources: dict[str, SourceRule] = field(default_factory=dict)
|
|
86
|
+
actions: list[ProjectRule] = field(default_factory=list)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_string(value: Any, field_name: str) -> str:
|
|
90
|
+
"""Validate that value is a non-empty string.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
value: Value to validate.
|
|
94
|
+
field_name: Name of the field for error messages.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Validated string.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ConfigValidationError: If validation fails.
|
|
101
|
+
"""
|
|
102
|
+
if not isinstance(value, str):
|
|
103
|
+
raise ConfigValidationError(
|
|
104
|
+
f"Field '{field_name}' must be a string, got {type(value).__name__}"
|
|
105
|
+
)
|
|
106
|
+
if not value.strip():
|
|
107
|
+
raise ConfigValidationError(f"Field '{field_name}' cannot be empty")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_action(action_data: Any, context: str) -> Action:
|
|
112
|
+
"""Validate and parse a single action.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
action_data: Action data from config (dict with single key).
|
|
116
|
+
context: Context string for error messages.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Validated Action object.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
ConfigValidationError: If validation fails.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(action_data, dict):
|
|
125
|
+
raise ConfigValidationError(
|
|
126
|
+
f"{context}: action must be a mapping, got {type(action_data).__name__}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if "critical" in action_data:
|
|
130
|
+
raise ConfigValidationError(
|
|
131
|
+
f"{context}: 'critical' is only valid at the rule level, "
|
|
132
|
+
f"not on individual actions"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if len(action_data) != 1:
|
|
136
|
+
raise ConfigValidationError(
|
|
137
|
+
f"{context}: action must have exactly one key (action type)"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
action_type = list(action_data.keys())[0]
|
|
141
|
+
args = action_data[action_type]
|
|
142
|
+
|
|
143
|
+
valid_types = {"copy", "command"}
|
|
144
|
+
if action_type not in valid_types:
|
|
145
|
+
raise ConfigValidationError(
|
|
146
|
+
f"{context}: invalid action type '{action_type}'. "
|
|
147
|
+
f"Must be one of: {', '.join(sorted(valid_types))}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Command action requires a single string (can contain template functions)
|
|
151
|
+
if action_type == "command":
|
|
152
|
+
if not isinstance(args, str):
|
|
153
|
+
raise ConfigValidationError(
|
|
154
|
+
f"{context}: command action must be a single string, "
|
|
155
|
+
f"got {type(args).__name__}"
|
|
156
|
+
)
|
|
157
|
+
if not args.strip():
|
|
158
|
+
raise ConfigValidationError(f"{context}: command string cannot be empty")
|
|
159
|
+
# Store as single-element list for consistency
|
|
160
|
+
args = [args]
|
|
161
|
+
else:
|
|
162
|
+
# copy accepts a string or list of two template-evaluated strings
|
|
163
|
+
if isinstance(args, str):
|
|
164
|
+
args = [args]
|
|
165
|
+
elif not isinstance(args, list):
|
|
166
|
+
raise ConfigValidationError(
|
|
167
|
+
f"{context}: action arguments must be a string or list"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Validate args are strings
|
|
171
|
+
for i, arg in enumerate(args):
|
|
172
|
+
if not isinstance(arg, str):
|
|
173
|
+
raise ConfigValidationError(
|
|
174
|
+
f"{context}: argument {i} must be a string, got {type(arg).__name__}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return Action(action_type=action_type, args=list(args))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _validate_source_rule(name: str, data: Any) -> SourceRule:
|
|
181
|
+
"""Validate and parse a source routing rule.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
name: Rule name.
|
|
185
|
+
data: Rule data from config.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
Validated SourceRule object.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
ConfigValidationError: If validation fails.
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(data, dict):
|
|
194
|
+
raise ConfigValidationError(
|
|
195
|
+
f"Source rule '{name}' must be a mapping, got {type(data).__name__}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if "when" not in data:
|
|
199
|
+
raise ConfigValidationError(f"Source rule '{name}' missing required 'when'")
|
|
200
|
+
|
|
201
|
+
when = _validate_string(data["when"], f"sources.{name}.when")
|
|
202
|
+
|
|
203
|
+
sources = None
|
|
204
|
+
if "sources" in data:
|
|
205
|
+
sources = _validate_string(data["sources"], f"sources.{name}.sources")
|
|
206
|
+
|
|
207
|
+
worktrees = None
|
|
208
|
+
if "worktrees" in data:
|
|
209
|
+
worktrees = _validate_string(data["worktrees"], f"sources.{name}.worktrees")
|
|
210
|
+
|
|
211
|
+
return SourceRule(
|
|
212
|
+
name=name,
|
|
213
|
+
when=when,
|
|
214
|
+
sources=sources,
|
|
215
|
+
worktrees=worktrees,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _validate_project_rule(data: Any, index: int) -> ProjectRule:
|
|
220
|
+
"""Validate and parse a project detection rule.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
data: Rule data from config.
|
|
224
|
+
index: Index for error messages.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
Validated ProjectRule object.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
ConfigValidationError: If validation fails.
|
|
231
|
+
"""
|
|
232
|
+
context = f"actions[{index}]"
|
|
233
|
+
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
raise ConfigValidationError(
|
|
236
|
+
f"{context} must be a mapping, got {type(data).__name__}"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if "when" not in data:
|
|
240
|
+
raise ConfigValidationError(f"{context} missing required 'when'")
|
|
241
|
+
|
|
242
|
+
when = _validate_string(data["when"], f"{context}.when")
|
|
243
|
+
|
|
244
|
+
critical: bool = True
|
|
245
|
+
if "critical" in data:
|
|
246
|
+
critical_value = data["critical"]
|
|
247
|
+
if not isinstance(critical_value, bool):
|
|
248
|
+
raise ConfigValidationError(
|
|
249
|
+
f"{context}.critical must be a boolean, "
|
|
250
|
+
f"got {type(critical_value).__name__}"
|
|
251
|
+
)
|
|
252
|
+
critical = critical_value
|
|
253
|
+
|
|
254
|
+
after_clone: list[Action] = []
|
|
255
|
+
if "after_clone" in data:
|
|
256
|
+
actions_data = data["after_clone"]
|
|
257
|
+
if not isinstance(actions_data, list):
|
|
258
|
+
raise ConfigValidationError(f"{context}.after_clone must be a list")
|
|
259
|
+
for i, action_data in enumerate(actions_data):
|
|
260
|
+
action = _validate_action(action_data, f"{context}.after_clone[{i}]")
|
|
261
|
+
after_clone.append(action)
|
|
262
|
+
|
|
263
|
+
after_add: list[Action] = []
|
|
264
|
+
if "after_add" in data:
|
|
265
|
+
actions_data = data["after_add"]
|
|
266
|
+
if not isinstance(actions_data, list):
|
|
267
|
+
raise ConfigValidationError(f"{context}.after_add must be a list")
|
|
268
|
+
for i, action_data in enumerate(actions_data):
|
|
269
|
+
action = _validate_action(action_data, f"{context}.after_add[{i}]")
|
|
270
|
+
after_add.append(action)
|
|
271
|
+
|
|
272
|
+
before_remove: list[Action] = []
|
|
273
|
+
if "before_remove" in data:
|
|
274
|
+
actions_data = data["before_remove"]
|
|
275
|
+
if not isinstance(actions_data, list):
|
|
276
|
+
raise ConfigValidationError(f"{context}.before_remove must be a list")
|
|
277
|
+
for i, action_data in enumerate(actions_data):
|
|
278
|
+
action = _validate_action(action_data, f"{context}.before_remove[{i}]")
|
|
279
|
+
before_remove.append(action)
|
|
280
|
+
|
|
281
|
+
if not after_clone and not after_add and not before_remove:
|
|
282
|
+
raise ConfigValidationError(
|
|
283
|
+
f"{context} must have at least one of: after_clone, after_add, "
|
|
284
|
+
f"before_remove"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return ProjectRule(
|
|
288
|
+
when=when,
|
|
289
|
+
after_clone=after_clone,
|
|
290
|
+
after_add=after_add,
|
|
291
|
+
before_remove=before_remove,
|
|
292
|
+
critical=critical,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def validate_config(data: dict[str, Any]) -> Config:
|
|
297
|
+
"""Validate and parse configuration data.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
data: Raw configuration dictionary from YAML.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Validated Config object.
|
|
304
|
+
|
|
305
|
+
Raises:
|
|
306
|
+
ConfigValidationError: If validation fails.
|
|
307
|
+
"""
|
|
308
|
+
# Validate required fields
|
|
309
|
+
if "default_sources" not in data:
|
|
310
|
+
raise ConfigValidationError("Missing required field: default_sources")
|
|
311
|
+
if "default_worktrees" not in data:
|
|
312
|
+
raise ConfigValidationError("Missing required field: default_worktrees")
|
|
313
|
+
|
|
314
|
+
default_sources = _validate_string(data["default_sources"], "default_sources")
|
|
315
|
+
default_worktrees = _validate_string(data["default_worktrees"], "default_worktrees")
|
|
316
|
+
|
|
317
|
+
# Validate optional sources
|
|
318
|
+
sources: dict[str, SourceRule] = {}
|
|
319
|
+
if "sources" in data:
|
|
320
|
+
sources_data = data["sources"]
|
|
321
|
+
if not isinstance(sources_data, dict):
|
|
322
|
+
raise ConfigValidationError(
|
|
323
|
+
f"'sources' must be a mapping, got {type(sources_data).__name__}"
|
|
324
|
+
)
|
|
325
|
+
for name, rule_data in sources_data.items():
|
|
326
|
+
sources[name] = _validate_source_rule(name, rule_data)
|
|
327
|
+
|
|
328
|
+
# Validate optional actions
|
|
329
|
+
actions: list[ProjectRule] = []
|
|
330
|
+
if "actions" in data:
|
|
331
|
+
actions_data = data["actions"]
|
|
332
|
+
if not isinstance(actions_data, list):
|
|
333
|
+
raise ConfigValidationError(
|
|
334
|
+
f"'actions' must be a list, got {type(actions_data).__name__}"
|
|
335
|
+
)
|
|
336
|
+
for i, rule_data in enumerate(actions_data):
|
|
337
|
+
actions.append(_validate_project_rule(rule_data, i))
|
|
338
|
+
|
|
339
|
+
return Config(
|
|
340
|
+
default_sources=default_sources,
|
|
341
|
+
default_worktrees=default_worktrees,
|
|
342
|
+
sources=sources,
|
|
343
|
+
actions=actions,
|
|
344
|
+
)
|
gww/git/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Git operations wrapper."""
|
gww/git/branch.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Git branch operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from gww.git.repository import GitCommandError, _run_git
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BranchError(Exception):
|
|
12
|
+
"""Base exception for branch-related errors."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BranchNotFoundError(BranchError):
|
|
18
|
+
"""Raised when branch cannot be found."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BranchExistsError(BranchError):
|
|
24
|
+
"""Raised when branch already exists."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def branch_exists(repo_path: Path, branch: str) -> bool:
|
|
30
|
+
"""Check if a branch exists (local or remote).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
repo_path: Path to repository.
|
|
34
|
+
branch: Branch name to check.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
True if branch exists.
|
|
38
|
+
"""
|
|
39
|
+
# Check local branches
|
|
40
|
+
result = _run_git(
|
|
41
|
+
["rev-parse", "--verify", f"refs/heads/{branch}"],
|
|
42
|
+
cwd=repo_path,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
if result.returncode == 0:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
# Check remote branches
|
|
49
|
+
result = _run_git(
|
|
50
|
+
["rev-parse", "--verify", f"refs/remotes/origin/{branch}"],
|
|
51
|
+
cwd=repo_path,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode == 0:
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def local_branch_exists(repo_path: Path, branch: str) -> bool:
|
|
61
|
+
"""Check if a local branch exists.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
repo_path: Path to repository.
|
|
65
|
+
branch: Branch name to check.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
True if local branch exists.
|
|
69
|
+
"""
|
|
70
|
+
result = _run_git(
|
|
71
|
+
["rev-parse", "--verify", f"refs/heads/{branch}"],
|
|
72
|
+
cwd=repo_path,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
return result.returncode == 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def remote_branch_exists(repo_path: Path, branch: str, remote: str = "origin") -> bool:
|
|
79
|
+
"""Check if a remote branch exists.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
repo_path: Path to repository.
|
|
83
|
+
branch: Branch name to check.
|
|
84
|
+
remote: Remote name (default: "origin").
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if remote branch exists.
|
|
88
|
+
"""
|
|
89
|
+
result = _run_git(
|
|
90
|
+
["rev-parse", "--verify", f"refs/remotes/{remote}/{branch}"],
|
|
91
|
+
cwd=repo_path,
|
|
92
|
+
check=False,
|
|
93
|
+
)
|
|
94
|
+
return result.returncode == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_branch(
|
|
98
|
+
repo_path: Path,
|
|
99
|
+
branch: str,
|
|
100
|
+
start_point: Optional[str] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Create a new branch.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
repo_path: Path to repository.
|
|
106
|
+
branch: Branch name to create.
|
|
107
|
+
start_point: Commit/branch to start from (default: HEAD).
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
BranchExistsError: If branch already exists.
|
|
111
|
+
GitCommandError: If command fails.
|
|
112
|
+
"""
|
|
113
|
+
if local_branch_exists(repo_path, branch):
|
|
114
|
+
raise BranchExistsError(f"Branch '{branch}' already exists")
|
|
115
|
+
|
|
116
|
+
args = ["branch", branch]
|
|
117
|
+
if start_point:
|
|
118
|
+
args.append(start_point)
|
|
119
|
+
|
|
120
|
+
_run_git(args, cwd=repo_path, check=True)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def delete_branch(
|
|
124
|
+
repo_path: Path,
|
|
125
|
+
branch: str,
|
|
126
|
+
force: bool = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Delete a local branch.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
repo_path: Path to repository.
|
|
132
|
+
branch: Branch name to delete.
|
|
133
|
+
force: If True, force deletion even if not merged.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
BranchNotFoundError: If branch doesn't exist.
|
|
137
|
+
GitCommandError: If command fails.
|
|
138
|
+
"""
|
|
139
|
+
if not local_branch_exists(repo_path, branch):
|
|
140
|
+
raise BranchNotFoundError(f"Branch '{branch}' not found")
|
|
141
|
+
|
|
142
|
+
flag = "-D" if force else "-d"
|
|
143
|
+
_run_git(["branch", flag, branch], cwd=repo_path, check=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def list_local_branches(repo_path: Path) -> list[str]:
|
|
147
|
+
"""List all local branches.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
repo_path: Path to repository.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of branch names.
|
|
154
|
+
"""
|
|
155
|
+
result = _run_git(
|
|
156
|
+
["branch", "--format=%(refname:short)"],
|
|
157
|
+
cwd=repo_path,
|
|
158
|
+
check=True,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
branches = [b.strip() for b in result.stdout.strip().split("\n") if b.strip()]
|
|
162
|
+
return branches
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def list_remote_branches(repo_path: Path, remote: str = "origin") -> list[str]:
|
|
166
|
+
"""List all remote branches.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
repo_path: Path to repository.
|
|
170
|
+
remote: Remote name (default: "origin").
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
List of branch names (without remote prefix).
|
|
174
|
+
"""
|
|
175
|
+
result = _run_git(
|
|
176
|
+
["branch", "-r", "--format=%(refname:short)"],
|
|
177
|
+
cwd=repo_path,
|
|
178
|
+
check=True,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
prefix = f"{remote}/"
|
|
182
|
+
branches: list[str] = []
|
|
183
|
+
|
|
184
|
+
for line in result.stdout.strip().split("\n"):
|
|
185
|
+
line = line.strip()
|
|
186
|
+
if line.startswith(prefix):
|
|
187
|
+
branch = line[len(prefix) :]
|
|
188
|
+
if branch != "HEAD": # Skip origin/HEAD
|
|
189
|
+
branches.append(branch)
|
|
190
|
+
|
|
191
|
+
return branches
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_tracking_branch(repo_path: Path, branch: str) -> Optional[str]:
|
|
195
|
+
"""Get the upstream tracking branch for a local branch.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
repo_path: Path to repository.
|
|
199
|
+
branch: Local branch name.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Tracking branch name (e.g., "origin/main") or None.
|
|
203
|
+
"""
|
|
204
|
+
result = _run_git(
|
|
205
|
+
["rev-parse", "--abbrev-ref", f"{branch}@{{upstream}}"],
|
|
206
|
+
cwd=repo_path,
|
|
207
|
+
check=False,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if result.returncode != 0:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
return result.stdout.strip()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def fetch_remote(repo_path: Path, remote: str = "origin") -> None:
|
|
217
|
+
"""Fetch updates from remote.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
repo_path: Path to repository.
|
|
221
|
+
remote: Remote name (default: "origin").
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
GitCommandError: If command fails.
|
|
225
|
+
"""
|
|
226
|
+
_run_git(["fetch", remote], cwd=repo_path, check=True)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_main_branch(branch: str) -> bool:
|
|
230
|
+
"""Check if branch is a main/master branch.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
branch: Branch name to check.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
True if branch is "main" or "master".
|
|
237
|
+
"""
|
|
238
|
+
return branch in ("main", "master")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def get_default_branch(repo_path: Path) -> str:
|
|
242
|
+
"""Get the default branch (main or master).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
repo_path: Path to repository.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Default branch name.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
BranchError: If neither main nor master exists.
|
|
252
|
+
"""
|
|
253
|
+
if local_branch_exists(repo_path, "main"):
|
|
254
|
+
return "main"
|
|
255
|
+
if local_branch_exists(repo_path, "master"):
|
|
256
|
+
return "master"
|
|
257
|
+
|
|
258
|
+
# Check remote
|
|
259
|
+
if remote_branch_exists(repo_path, "main"):
|
|
260
|
+
return "main"
|
|
261
|
+
if remote_branch_exists(repo_path, "master"):
|
|
262
|
+
return "master"
|
|
263
|
+
|
|
264
|
+
raise BranchError("Could not determine default branch (neither 'main' nor 'master' found)")
|