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.
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
+ ]