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/git/worktree.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Git worktree operations wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from gww.git.repository import GitCommandError, _run_git, get_repository_root, is_worktree
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def read_gitdir(gitfile: Path) -> Optional[str]:
|
|
14
|
+
"""Read the ``gitdir: ...`` line from a worktree's ``.git`` file.
|
|
15
|
+
|
|
16
|
+
A worktree's ``.git`` is a regular file (not a directory) containing a
|
|
17
|
+
single line of the form ``gitdir: /path/to/source/.git/worktrees/<id>``.
|
|
18
|
+
Source repos and submodules use this file too.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
gitfile: Path to the ``.git`` file to read.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The trimmed ``gitdir`` path, or ``None`` if ``gitfile`` does not
|
|
25
|
+
exist, is not a regular file, cannot be read, or does not have a
|
|
26
|
+
``gitdir:`` prefix.
|
|
27
|
+
"""
|
|
28
|
+
if not gitfile.is_file():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
content = gitfile.read_text().strip()
|
|
32
|
+
except OSError:
|
|
33
|
+
return None
|
|
34
|
+
if not content.startswith("gitdir:"):
|
|
35
|
+
return None
|
|
36
|
+
return content.split(":", 1)[1].strip()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def worktree_id_from_gitdir(gitdir: str) -> Optional[str]:
|
|
40
|
+
"""Extract the worktree id from a ``gitdir`` path.
|
|
41
|
+
|
|
42
|
+
Git encodes the worktree identity as ``.../worktrees/<id>``. The id is
|
|
43
|
+
used by operations that need to relocate a copied worktree's ``.git``
|
|
44
|
+
pointer to its new source repo.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
gitdir: Value returned from :func:`read_gitdir`.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The worktree id, or ``None`` if the path does not contain a
|
|
51
|
+
``worktrees/<id>`` segment.
|
|
52
|
+
"""
|
|
53
|
+
parts = Path(gitdir.replace("\\", "/")).parts
|
|
54
|
+
try:
|
|
55
|
+
idx = parts.index("worktrees")
|
|
56
|
+
except ValueError:
|
|
57
|
+
return None
|
|
58
|
+
if idx + 1 >= len(parts):
|
|
59
|
+
return None
|
|
60
|
+
return parts[idx + 1]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class WorktreeError(Exception):
|
|
64
|
+
"""Base exception for worktree-related errors."""
|
|
65
|
+
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WorktreeNotFoundError(WorktreeError):
|
|
70
|
+
"""Raised when worktree cannot be found."""
|
|
71
|
+
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WorktreeDirtyError(WorktreeError):
|
|
76
|
+
"""Raised when worktree has uncommitted changes."""
|
|
77
|
+
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class WorktreeExistsError(WorktreeError):
|
|
82
|
+
"""Raised when worktree already exists."""
|
|
83
|
+
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class Worktree:
|
|
89
|
+
"""Represents a git worktree.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
path: Absolute path to worktree root.
|
|
93
|
+
branch: Branch checked out in worktree.
|
|
94
|
+
commit: Commit hash (abbreviated).
|
|
95
|
+
is_bare: Whether this is the bare repository.
|
|
96
|
+
is_detached: Whether HEAD is detached.
|
|
97
|
+
is_locked: Whether worktree is locked.
|
|
98
|
+
prunable: Reason worktree can be pruned, if any.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
path: Path
|
|
102
|
+
branch: Optional[str]
|
|
103
|
+
commit: str
|
|
104
|
+
is_bare: bool = False
|
|
105
|
+
is_detached: bool = False
|
|
106
|
+
is_locked: bool = False
|
|
107
|
+
prunable: Optional[str] = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def list_worktrees(repo_path: Path) -> list[Worktree]:
|
|
111
|
+
"""List all worktrees for a repository.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
repo_path: Path to repository (source or worktree).
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
List of Worktree objects.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
GitCommandError: If command fails.
|
|
121
|
+
"""
|
|
122
|
+
result = _run_git(
|
|
123
|
+
["worktree", "list", "--porcelain"],
|
|
124
|
+
cwd=repo_path,
|
|
125
|
+
check=True,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
worktrees: list[Worktree] = []
|
|
129
|
+
current: dict[str, str] = {}
|
|
130
|
+
|
|
131
|
+
for line in result.stdout.strip().split("\n"):
|
|
132
|
+
line = line.strip()
|
|
133
|
+
|
|
134
|
+
if not line:
|
|
135
|
+
# End of worktree entry
|
|
136
|
+
if current:
|
|
137
|
+
wt = _parse_worktree_entry(current)
|
|
138
|
+
worktrees.append(wt)
|
|
139
|
+
current = {}
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if line.startswith("worktree "):
|
|
143
|
+
current["path"] = line[9:]
|
|
144
|
+
elif line.startswith("HEAD "):
|
|
145
|
+
current["commit"] = line[5:]
|
|
146
|
+
elif line.startswith("branch "):
|
|
147
|
+
current["branch"] = line[7:]
|
|
148
|
+
elif line == "bare":
|
|
149
|
+
current["bare"] = "true"
|
|
150
|
+
elif line == "detached":
|
|
151
|
+
current["detached"] = "true"
|
|
152
|
+
elif line == "locked":
|
|
153
|
+
current["locked"] = "true"
|
|
154
|
+
elif line.startswith("prunable "):
|
|
155
|
+
current["prunable"] = line[9:]
|
|
156
|
+
|
|
157
|
+
# Handle last entry
|
|
158
|
+
if current:
|
|
159
|
+
wt = _parse_worktree_entry(current)
|
|
160
|
+
worktrees.append(wt)
|
|
161
|
+
|
|
162
|
+
return worktrees
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _parse_worktree_entry(data: dict[str, str]) -> Worktree:
|
|
166
|
+
"""Parse a worktree entry from porcelain output.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
data: Dictionary of worktree properties.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Worktree object.
|
|
173
|
+
"""
|
|
174
|
+
branch = data.get("branch")
|
|
175
|
+
if branch and branch.startswith("refs/heads/"):
|
|
176
|
+
branch = branch[11:] # Remove refs/heads/ prefix
|
|
177
|
+
|
|
178
|
+
return Worktree(
|
|
179
|
+
path=Path(data["path"]),
|
|
180
|
+
branch=branch,
|
|
181
|
+
commit=data.get("commit", ""),
|
|
182
|
+
is_bare=data.get("bare") == "true",
|
|
183
|
+
is_detached=data.get("detached") == "true",
|
|
184
|
+
is_locked=data.get("locked") == "true",
|
|
185
|
+
prunable=data.get("prunable"),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def find_worktree_by_branch(repo_path: Path, branch: str) -> Optional[Worktree]:
|
|
190
|
+
"""Find a worktree by branch name.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
repo_path: Path to repository.
|
|
194
|
+
branch: Branch name to find.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
Worktree if found, None otherwise.
|
|
198
|
+
"""
|
|
199
|
+
worktrees = list_worktrees(repo_path)
|
|
200
|
+
|
|
201
|
+
for wt in worktrees:
|
|
202
|
+
if wt.branch == branch:
|
|
203
|
+
return wt
|
|
204
|
+
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_worktree_by_path(repo_path: Path, worktree_path: Path) -> Optional[Worktree]:
|
|
209
|
+
"""Find a worktree by path.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
repo_path: Path to repository.
|
|
213
|
+
worktree_path: Path to worktree.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Worktree if found, None otherwise.
|
|
217
|
+
"""
|
|
218
|
+
worktrees = list_worktrees(repo_path)
|
|
219
|
+
resolved_path = worktree_path.resolve()
|
|
220
|
+
|
|
221
|
+
for wt in worktrees:
|
|
222
|
+
if wt.path.resolve() == resolved_path:
|
|
223
|
+
return wt
|
|
224
|
+
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def is_worktree_clean(worktree_path: Path) -> bool:
|
|
229
|
+
"""Check if worktree has no uncommitted changes.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
worktree_path: Path to worktree.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
True if worktree is clean.
|
|
236
|
+
"""
|
|
237
|
+
result = _run_git(
|
|
238
|
+
["status", "--porcelain"],
|
|
239
|
+
cwd=worktree_path,
|
|
240
|
+
check=False,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return not bool(result.stdout.strip())
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def add_worktree(
|
|
247
|
+
repo_path: Path,
|
|
248
|
+
worktree_path: Path,
|
|
249
|
+
branch: str,
|
|
250
|
+
create_branch: bool = False,
|
|
251
|
+
base_commit: Optional[str] = None,
|
|
252
|
+
pass_through_stdout: bool = False,
|
|
253
|
+
) -> Path:
|
|
254
|
+
"""Add a new worktree.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
repo_path: Path to source repository.
|
|
258
|
+
worktree_path: Path where worktree should be created.
|
|
259
|
+
branch: Branch to checkout in worktree.
|
|
260
|
+
create_branch: If True, create branch if it doesn't exist.
|
|
261
|
+
base_commit: Commit to base new branch on (if create_branch=True).
|
|
262
|
+
pass_through_stdout: Forwarded to :func:`_run_git`. When ``True``,
|
|
263
|
+
git's ``worktree add`` progress (``Preparing worktree…``) streams
|
|
264
|
+
to the parent process's stderr.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Path to created worktree.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
WorktreeExistsError: If worktree for branch already exists.
|
|
271
|
+
GitCommandError: If command fails.
|
|
272
|
+
"""
|
|
273
|
+
# Check if worktree already exists for this branch
|
|
274
|
+
existing = find_worktree_by_branch(repo_path, branch)
|
|
275
|
+
if existing:
|
|
276
|
+
raise WorktreeExistsError(
|
|
277
|
+
f"Worktree for branch '{branch}' already exists at: {existing.path}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Ensure parent directory exists
|
|
281
|
+
worktree_path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
|
|
283
|
+
args = ["worktree", "add", str(worktree_path)]
|
|
284
|
+
|
|
285
|
+
if create_branch:
|
|
286
|
+
args.extend(["-b", branch])
|
|
287
|
+
if base_commit:
|
|
288
|
+
args.append(base_commit)
|
|
289
|
+
else:
|
|
290
|
+
args.append(branch)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
_run_git(args, cwd=repo_path, check=True, pass_through_stdout=pass_through_stdout)
|
|
294
|
+
except GitCommandError as e:
|
|
295
|
+
if "already exists" in str(e):
|
|
296
|
+
raise WorktreeExistsError(
|
|
297
|
+
f"Branch '{branch}' is already checked out in another worktree"
|
|
298
|
+
) from e
|
|
299
|
+
raise
|
|
300
|
+
|
|
301
|
+
return worktree_path
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def remove_worktree(
|
|
305
|
+
repo_path: Path,
|
|
306
|
+
worktree_path: Path,
|
|
307
|
+
force: bool = False,
|
|
308
|
+
pass_through_stdout: bool = False,
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Remove a worktree.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
repo_path: Path to source repository.
|
|
314
|
+
worktree_path: Path to worktree to remove.
|
|
315
|
+
force: If True, remove even if dirty.
|
|
316
|
+
pass_through_stdout: Forwarded to :func:`_run_git`. When ``True``,
|
|
317
|
+
git's ``worktree remove`` output (if any) streams to the parent
|
|
318
|
+
process's stdout.
|
|
319
|
+
|
|
320
|
+
Raises:
|
|
321
|
+
WorktreeNotFoundError: If worktree doesn't exist.
|
|
322
|
+
WorktreeDirtyError: If worktree is dirty and force=False.
|
|
323
|
+
GitCommandError: If command fails.
|
|
324
|
+
"""
|
|
325
|
+
# Verify worktree exists
|
|
326
|
+
wt = find_worktree_by_path(repo_path, worktree_path)
|
|
327
|
+
if not wt:
|
|
328
|
+
raise WorktreeNotFoundError(f"Worktree not found: {worktree_path}")
|
|
329
|
+
|
|
330
|
+
# Check if clean (unless forcing)
|
|
331
|
+
if not force and not is_worktree_clean(worktree_path):
|
|
332
|
+
raise WorktreeDirtyError(
|
|
333
|
+
f"Worktree has uncommitted changes: {worktree_path}\n"
|
|
334
|
+
"Use --force to remove anyway."
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
args = ["worktree", "remove"]
|
|
338
|
+
if force:
|
|
339
|
+
args.append("--force")
|
|
340
|
+
args.append(str(worktree_path))
|
|
341
|
+
|
|
342
|
+
_run_git(args, cwd=repo_path, check=True, pass_through_stdout=pass_through_stdout)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def prune_worktrees(repo_path: Path, dry_run: bool = False) -> list[str]:
|
|
346
|
+
"""Prune stale worktree information.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
repo_path: Path to repository.
|
|
350
|
+
dry_run: If True, only report what would be pruned.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
List of pruned worktree paths.
|
|
354
|
+
"""
|
|
355
|
+
args = ["worktree", "prune"]
|
|
356
|
+
if dry_run:
|
|
357
|
+
args.append("--dry-run")
|
|
358
|
+
|
|
359
|
+
result = _run_git(args, cwd=repo_path, check=True)
|
|
360
|
+
|
|
361
|
+
# Parse output for pruned paths
|
|
362
|
+
pruned: list[str] = []
|
|
363
|
+
for line in result.stdout.strip().split("\n"):
|
|
364
|
+
if line.strip():
|
|
365
|
+
pruned.append(line.strip())
|
|
366
|
+
|
|
367
|
+
return pruned
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def repair_worktrees(
|
|
371
|
+
repo_path: Path,
|
|
372
|
+
worktree_paths: Optional[list[Path]] = None,
|
|
373
|
+
pass_through_stdout: bool = False,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Repair worktree administrative files after worktrees have been moved.
|
|
376
|
+
|
|
377
|
+
This updates the worktree paths stored in the source repository's
|
|
378
|
+
.git/worktrees directory to reflect the current locations of worktrees.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
repo_path: Path to repository (source or worktree).
|
|
382
|
+
worktree_paths: Optional list of worktree paths to repair. When provided,
|
|
383
|
+
git worktree repair is called with these paths so the repo updates
|
|
384
|
+
to point to the new locations.
|
|
385
|
+
pass_through_stdout: Forwarded to :func:`_run_git`. When ``True``,
|
|
386
|
+
git's ``worktree repair`` output (if any) streams to the parent
|
|
387
|
+
process's stdout.
|
|
388
|
+
|
|
389
|
+
Raises:
|
|
390
|
+
GitCommandError: If repair command fails.
|
|
391
|
+
"""
|
|
392
|
+
args = ["worktree", "repair"]
|
|
393
|
+
if worktree_paths:
|
|
394
|
+
args.extend(str(p) for p in worktree_paths)
|
|
395
|
+
_run_git(args, cwd=repo_path, check=True, pass_through_stdout=pass_through_stdout)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Migration planning and execution split out of ``gww.cli.commands.migrate``.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
* :func:`plan_migration` — match repo roots against the config and produce
|
|
6
|
+
either a :class:`Migration` or a :class:`Blocked` result.
|
|
7
|
+
* :func:`execute` — run the plan in copy or inplace mode.
|
|
8
|
+
* :func:`collect_repositories` — directory scan helper used by the CLI.
|
|
9
|
+
* :func:`fix_copied_worktree_gitfile` — repair ``.git`` pointer after copy.
|
|
10
|
+
|
|
11
|
+
See :mod:`gww.migration.planner` and :mod:`gww.migration.executor` for the
|
|
12
|
+
detailed contracts of each function.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from gww.migration.executor import (
|
|
16
|
+
Mode,
|
|
17
|
+
execute,
|
|
18
|
+
fix_copied_worktree_gitfile,
|
|
19
|
+
)
|
|
20
|
+
from gww.migration.planner import (
|
|
21
|
+
Blocked,
|
|
22
|
+
Migration,
|
|
23
|
+
MigrationPlan,
|
|
24
|
+
MigrationResult,
|
|
25
|
+
Skip,
|
|
26
|
+
collect_repositories,
|
|
27
|
+
find_git_repositories,
|
|
28
|
+
plan_migration,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Blocked",
|
|
34
|
+
"Migration",
|
|
35
|
+
"MigrationPlan",
|
|
36
|
+
"MigrationResult",
|
|
37
|
+
"Mode",
|
|
38
|
+
"Skip",
|
|
39
|
+
"collect_repositories",
|
|
40
|
+
"execute",
|
|
41
|
+
"find_git_repositories",
|
|
42
|
+
"fix_copied_worktree_gitfile",
|
|
43
|
+
"plan_migration",
|
|
44
|
+
]
|