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,342 @@
1
+ """Execute a :class:`Migration` plan produced by the planner.
2
+
3
+ The executor has a single inner loop. The only branch between copy and
4
+ inplace modes is which :mod:`shutil` operation moves each plan into place;
5
+ everything else (parent-directory creation, post-move worktree repair,
6
+ already-at-target reporting, summary printing) is shared.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Callable, Literal, Optional
15
+
16
+ from gww.git.repository import GitCommandError
17
+ from gww.git.worktree import (
18
+ read_gitdir,
19
+ repair_worktrees,
20
+ worktree_id_from_gitdir,
21
+ )
22
+ from gww.migration.planner import Migration, MigrationPlan, Skip
23
+
24
+ Mode = Literal["copy", "inplace"]
25
+
26
+ # One shutil callable per mode. ``copytree`` preserves symlinks (matches
27
+ # ``shutil -a`` behaviour), ``move`` does a true rename when source and
28
+ # destination live on the same filesystem and falls back to copy+remove.
29
+ _MOVE: dict[Mode, Callable[[str, str], object]] = {
30
+ "copy": lambda src, dst: shutil.copytree(src, dst, symlinks=True),
31
+ "inplace": shutil.move,
32
+ }
33
+
34
+
35
+ def fix_copied_worktree_gitfile(
36
+ new_worktree_path: Path,
37
+ new_source_path: Path,
38
+ ) -> None:
39
+ """Rewrite a copied worktree's ``.git`` file to point at the new source.
40
+
41
+ After :func:`shutil.copytree` the ``.git`` file in the new worktree still
42
+ references the old source's ``.git/worktrees/<id>``. Extract the
43
+ worktree id (using the shared gitfile parser) and point the file at the
44
+ matching directory in ``new_source_path``.
45
+
46
+ No-op if ``.git`` is a directory (source repo), missing, unparseable, or
47
+ not of the ``.../worktrees/<id>`` shape.
48
+ """
49
+ git_file = new_worktree_path / ".git"
50
+ gitdir = read_gitdir(git_file)
51
+ if gitdir is None:
52
+ return
53
+ wt_id = worktree_id_from_gitdir(gitdir)
54
+ if wt_id is None:
55
+ return
56
+ new_gitdir = str(new_source_path / ".git" / "worktrees" / wt_id)
57
+ git_file.write_text(f"gitdir: {new_gitdir}\n")
58
+
59
+
60
+ def _format_skipped_items(items: list[Skip]) -> str:
61
+ """Group skips by reason and render a multi-line summary, or empty."""
62
+ if not items:
63
+ return ""
64
+
65
+ reason_counts: dict[str, tuple[int, int]] = {} # reason -> (sources, worktrees)
66
+ for skip in items:
67
+ sources, worktrees = reason_counts.get(skip.reason, (0, 0))
68
+ if skip.is_worktree:
69
+ reason_counts[skip.reason] = (sources, worktrees + 1)
70
+ else:
71
+ reason_counts[skip.reason] = (sources + 1, worktrees)
72
+
73
+ total_sources = sum(s for s, _ in reason_counts.values())
74
+ total_worktrees = sum(w for _, w in reason_counts.values())
75
+
76
+ lines: list[str] = []
77
+ if total_sources > 0 and total_worktrees > 0:
78
+ src_word = "source" if total_sources == 1 else "sources"
79
+ wt_word = "worktree" if total_worktrees == 1 else "worktrees"
80
+ lines.append(f"Ignored {total_sources} {src_word}, {total_worktrees} {wt_word}:")
81
+ elif total_sources > 0:
82
+ src_word = "source" if total_sources == 1 else "sources"
83
+ lines.append(f"Ignored {total_sources} {src_word}:")
84
+ elif total_worktrees > 0:
85
+ wt_word = "worktree" if total_worktrees == 1 else "worktrees"
86
+ lines.append(f"Ignored {total_worktrees} {wt_word}:")
87
+
88
+ for reason, (sources, worktrees) in sorted(reason_counts.items()):
89
+ parts: list[str] = []
90
+ if sources > 0:
91
+ parts.append(f"{sources} source{'s' if sources != 1 else ''}")
92
+ if worktrees > 0:
93
+ parts.append(f"{worktrees} worktree{'s' if worktrees != 1 else ''}")
94
+ lines.append(f" - {reason}: {', '.join(parts)}")
95
+
96
+ return "\n".join(lines)
97
+
98
+
99
+ def _repair_after_move(
100
+ plan: MigrationPlan,
101
+ new_source: Optional[Path],
102
+ verbose: int,
103
+ quiet: bool,
104
+ ) -> None:
105
+ """Run ``git worktree repair`` for the source that owns this plan.
106
+
107
+ Used in the worktree-move loop, where the source already exists at its
108
+ original location (``new_source is None``) and only the worktree moved.
109
+ When the source is being migrated too, repair is deferred to
110
+ :func:`_repair_source_after_move` after the source has actually moved.
111
+ """
112
+ if plan.source_path is None or new_source is not None:
113
+ return
114
+ target_source = plan.source_path
115
+ try:
116
+ if verbose > 0 and not quiet:
117
+ print(f"Repairing worktree paths in {target_source}", file=sys.stderr)
118
+ repair_worktrees(
119
+ target_source, [plan.new_path], pass_through_stdout=not quiet
120
+ )
121
+ except GitCommandError as e:
122
+ print(
123
+ f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}",
124
+ file=sys.stderr,
125
+ )
126
+
127
+
128
+ def _repair_source_after_move(
129
+ new_source: Path,
130
+ moved_worktree_paths: list[Path],
131
+ verbose: int,
132
+ quiet: bool,
133
+ ) -> None:
134
+ """Repair worktree admin files in a newly-moved source.
135
+
136
+ Called after a source has been moved to ``new_source`` and one or more
137
+ of its worktrees were also moved in this run. ``git worktree repair``
138
+ updates each moved worktree's ``gitdir`` entry inside the source (and,
139
+ for inplace mode, rewrites the worktree's own ``.git`` file). Copy mode
140
+ already rewrote the worktree's ``.git`` file up-front via
141
+ :func:`fix_copied_worktree_gitfile`; repair completes the round trip
142
+ by updating the source's side.
143
+ """
144
+ try:
145
+ if verbose > 0 and not quiet:
146
+ print(f"Repairing worktree paths in {new_source}", file=sys.stderr)
147
+ repair_worktrees(
148
+ new_source, moved_worktree_paths, pass_through_stdout=not quiet
149
+ )
150
+ except GitCommandError as e:
151
+ print(
152
+ f"Warning: Failed to repair worktree paths in {new_source}: {e}",
153
+ file=sys.stderr,
154
+ )
155
+
156
+
157
+ def _empty_source_dirs(plans: list[MigrationPlan], roots: list[Path], quiet: bool) -> None:
158
+ """Recursively remove empty directories left behind after inplace moves.
159
+
160
+ ``shutil.move`` already removes the moved leaf, so we start walking from
161
+ the first surviving ancestor and remove empty parents up to (but not
162
+ including) the user-supplied input roots.
163
+ """
164
+ if not plans:
165
+ return
166
+ vacated = [p.old_path.resolve() for p in plans]
167
+ roots_set = set(roots)
168
+ # Process deepest paths first so parents can be removed after children.
169
+ vacated_sorted = sorted(vacated, key=lambda p: len(p.parts), reverse=True)
170
+ for start_path in vacated_sorted:
171
+ # If the leaf is already gone (typical), walk up to the first
172
+ # surviving ancestor before attempting cleanup.
173
+ current = start_path
174
+ while current not in roots_set and not current.exists() and current.parent != current:
175
+ current = current.parent
176
+ while True:
177
+ if current in roots_set:
178
+ break
179
+ if not current.exists() or not current.is_dir():
180
+ break
181
+ try:
182
+ if any(current.iterdir()):
183
+ break
184
+ current.rmdir()
185
+ current = current.parent
186
+ except OSError:
187
+ break
188
+
189
+
190
+ def _resolve_new_source(plan: MigrationPlan, source_plans: list[MigrationPlan]) -> Optional[Path]:
191
+ """If this worktree's source repo is also being migrated, return the new path."""
192
+ if plan.source_path is None:
193
+ return None
194
+ plan_source = plan.source_path.resolve()
195
+ for sp in source_plans:
196
+ if sp.old_path.resolve() == plan_source:
197
+ return sp.new_path
198
+ return None
199
+
200
+
201
+ def _print_summary(
202
+ migrated_sources: int,
203
+ migrated_worktrees: int,
204
+ info_skips: list[Skip],
205
+ already_at_target: list[Path],
206
+ failed: int,
207
+ mode_label: str,
208
+ quiet: bool,
209
+ ) -> None:
210
+ if quiet:
211
+ return
212
+ if migrated_sources > 0 and migrated_worktrees > 0:
213
+ print(f"{mode_label} {migrated_sources} sources, {migrated_worktrees} worktrees")
214
+ elif migrated_sources > 0:
215
+ print(f"{mode_label} {migrated_sources} sources")
216
+ elif migrated_worktrees > 0:
217
+ print(f"{mode_label} {migrated_worktrees} worktrees")
218
+
219
+ skip_msg = _format_skipped_items(info_skips)
220
+ if skip_msg:
221
+ print(skip_msg)
222
+
223
+ if already_at_target:
224
+ print(f"Already at target: {len(already_at_target)} repositories")
225
+ if failed:
226
+ print(f"Failed {failed} repositories")
227
+
228
+
229
+ def execute(
230
+ migration: Migration,
231
+ input_roots: list[Path],
232
+ mode: Mode,
233
+ dry_run: bool,
234
+ quiet: bool,
235
+ verbose: int,
236
+ ) -> int:
237
+ """Execute the planned migrations.
238
+
239
+ Args:
240
+ migration: Plan returned by :func:`gww.migration.planner.plan_migration`.
241
+ input_roots: Original input directories (used for inplace cleanup).
242
+ mode: ``"copy"`` to copy, ``"inplace"`` to move.
243
+ dry_run: If ``True``, only print what would happen.
244
+ quiet: If ``True``, suppress non-error output.
245
+ verbose: Verbosity level.
246
+
247
+ Returns:
248
+ Exit code (0 for success, 1 if any individual migration failed).
249
+ """
250
+ move = _MOVE[mode]
251
+ mode_verb = "Copying" if mode == "copy" else "Moving"
252
+ mode_label = "Migrated" if mode == "copy" else "Moved"
253
+
254
+ if migration.already_at_target and not quiet:
255
+ for path in migration.already_at_target:
256
+ print(f"Already at target: {path}")
257
+
258
+ if not migration.plans:
259
+ if not quiet:
260
+ for skip in migration.info_skips:
261
+ print(f"{skip.path}: {skip.reason}")
262
+ print("No repositories to migrate.")
263
+ skip_msg = _format_skipped_items(migration.info_skips)
264
+ if skip_msg:
265
+ print(skip_msg)
266
+ if migration.already_at_target:
267
+ print(f"Already at target: {len(migration.already_at_target)} repositories")
268
+ return 0
269
+
270
+ if not quiet:
271
+ for plan in migration.plans:
272
+ kind = "Worktree" if plan.is_worktree else "Source"
273
+ print(f"{kind}: {plan.old_path} -> {plan.new_path}")
274
+ for skip in migration.info_skips:
275
+ print(f"{skip.path}: {skip.reason}")
276
+
277
+ if dry_run:
278
+ if not quiet:
279
+ print(f"Would migrate {len(migration.plans)} repositories")
280
+ if migration.info_skips:
281
+ print(f"Would skip {len(migration.info_skips)} repositories")
282
+ return 0
283
+
284
+ source_plans = [p for p in migration.plans if not p.is_worktree]
285
+ worktree_plans = [p for p in migration.plans if p.is_worktree]
286
+
287
+ migrated_sources = 0
288
+ migrated_worktrees = 0
289
+ failed = 0
290
+
291
+ # Worktrees first so the source's .git/worktrees/<id> directory exists
292
+ # in time for the later source moves to be safe (inplace only — copy
293
+ # just rewrites the .git pointer). When the source is also being
294
+ # migrated, the worktree's per-source repair is deferred to the source
295
+ # loop, which runs _after_ the source has been moved to new_source.
296
+ for plan in worktree_plans:
297
+ try:
298
+ if not quiet:
299
+ print(f"{mode_verb} worktree {plan.old_path} -> {plan.new_path}")
300
+ plan.new_path.parent.mkdir(parents=True, exist_ok=True)
301
+ move(str(plan.old_path), str(plan.new_path))
302
+ migrated_worktrees += 1
303
+ new_source = _resolve_new_source(plan, source_plans)
304
+ if mode == "copy" and new_source is not None:
305
+ fix_copied_worktree_gitfile(plan.new_path, new_source)
306
+ _repair_after_move(plan, new_source, verbose, quiet)
307
+ except OSError as e:
308
+ print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr)
309
+ failed += 1
310
+
311
+ for plan in source_plans:
312
+ try:
313
+ if not quiet:
314
+ print(f"{mode_verb} repository {plan.old_path} -> {plan.new_path}")
315
+ plan.new_path.parent.mkdir(parents=True, exist_ok=True)
316
+ move(str(plan.old_path), str(plan.new_path))
317
+ migrated_sources += 1
318
+ moved_wt_paths = [
319
+ wt.new_path for wt in worktree_plans
320
+ if wt.source_path is not None
321
+ and wt.source_path.resolve() == plan.old_path.resolve()
322
+ ]
323
+ if moved_wt_paths:
324
+ _repair_source_after_move(plan.new_path, moved_wt_paths, verbose, quiet)
325
+ except OSError as e:
326
+ print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr)
327
+ failed += 1
328
+
329
+ if mode == "inplace":
330
+ _empty_source_dirs(migration.plans, input_roots, quiet)
331
+
332
+ _print_summary(
333
+ migrated_sources,
334
+ migrated_worktrees,
335
+ migration.info_skips,
336
+ migration.already_at_target,
337
+ failed,
338
+ mode_label,
339
+ quiet,
340
+ )
341
+
342
+ return 1 if failed > 0 else 0
@@ -0,0 +1,260 @@
1
+ """Plan repository migrations.
2
+
3
+ The planner is a pure function over the validated ``Config`` plus the list
4
+ of repository roots found by the directory scan. It returns a
5
+ :class:`MigrationResult` tagged union so callers can pattern-match on the
6
+ shape of the answer without having to thread an ``is_fatal`` flag through a
7
+ 4-tuple of skips.
8
+
9
+ Two outcomes:
10
+
11
+ * :class:`Migration` — at least one plan succeeded and there are no
12
+ destination-exists blockers. Carries the planned moves and the
13
+ informational skips so the executor can report them.
14
+ * :class:`Blocked` — at least one destination already exists in copy mode.
15
+ Callers should bail with exit code 1 before invoking the executor.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Optional, Union
25
+
26
+ from gww.config.resolver import ResolverError, resolve_source_path, resolve_worktree_path
27
+ from gww.config.validator import Config
28
+ from gww.git.repository import (
29
+ GitCommandError,
30
+ get_current_branch,
31
+ get_remote_uri,
32
+ get_source_repository,
33
+ is_submodule,
34
+ is_worktree,
35
+ )
36
+ from gww.utils.uri import parse_uri
37
+
38
+
39
+ @dataclass
40
+ class MigrationPlan:
41
+ """One repository to migrate.
42
+
43
+ Attributes:
44
+ old_path: Current location of the repository.
45
+ new_path: Configured target location.
46
+ uri: Remote URI of the repository.
47
+ is_worktree: Whether this plan is for a worktree (vs a source).
48
+ source_path: For worktrees, the path to the source repo. ``None`` for
49
+ source repos themselves.
50
+ """
51
+
52
+ old_path: Path
53
+ new_path: Path
54
+ uri: str
55
+ is_worktree: bool = False
56
+ source_path: Optional[Path] = None
57
+
58
+
59
+ @dataclass
60
+ class Skip:
61
+ """A repository the planner could not produce a migration for.
62
+
63
+ Attributes:
64
+ reason: Human-readable explanation (e.g. ``"no remote origin configured"``).
65
+ path: Repository the skip applies to.
66
+ is_worktree: Whether the skipped repo was a worktree.
67
+ """
68
+
69
+ reason: str
70
+ path: Path
71
+ is_worktree: bool
72
+
73
+
74
+ @dataclass
75
+ class Migration:
76
+ """Planner found at least one plan and no destination-exists blockers."""
77
+
78
+ plans: list[MigrationPlan] = field(default_factory=list)
79
+ already_at_target: list[Path] = field(default_factory=list)
80
+ info_skips: list[Skip] = field(default_factory=list)
81
+
82
+
83
+ @dataclass
84
+ class Blocked:
85
+ """At least one destination already exists; migration cannot proceed."""
86
+
87
+ destinations: list[Path]
88
+
89
+
90
+ MigrationResult = Union[Migration, Blocked]
91
+
92
+
93
+ def find_git_repositories(directory: Path) -> list[Path]:
94
+ """Find all git repositories and worktrees in a directory tree.
95
+
96
+ Repository and worktree interiors are not traversed; each repo or
97
+ worktree is treated as a single unit (no descent into subdirectories).
98
+
99
+ Args:
100
+ directory: Directory to scan.
101
+
102
+ Returns:
103
+ List of paths to git repository roots.
104
+ """
105
+ repos: list[Path] = []
106
+
107
+ for root, dirs, _ in os.walk(directory):
108
+ root_path = Path(root)
109
+
110
+ # Check if this is a git repository or worktree (skip submodules - they move with parent)
111
+ if (root_path / ".git").exists() and not is_submodule(root_path):
112
+ repos.append(root_path)
113
+ # Do not descend into the repository or worktree (treat as single unit)
114
+ dirs.clear()
115
+
116
+ return repos
117
+
118
+
119
+ def collect_repositories(input_paths: list[Path]) -> tuple[list[Path], list[Path]]:
120
+ """Collect and merge repo roots from multiple input directories.
121
+
122
+ Args:
123
+ input_paths: List of directories to scan.
124
+
125
+ Returns:
126
+ Tuple of (deduplicated repo paths, input roots for cleanup).
127
+ """
128
+ seen: set[Path] = set()
129
+ repos: list[Path] = []
130
+ for directory in input_paths:
131
+ for repo_path in find_git_repositories(directory):
132
+ resolved = repo_path.resolve()
133
+ if resolved not in seen:
134
+ seen.add(resolved)
135
+ repos.append(repo_path)
136
+ return repos, [p.resolve() for p in input_paths]
137
+
138
+
139
+ def plan_migration(
140
+ repos: list[Path],
141
+ config: Config,
142
+ inplace: bool,
143
+ verbose: int = 0,
144
+ tags: Optional[dict[str, str]] = None,
145
+ ) -> MigrationResult:
146
+ """Plan migrations for all repositories.
147
+
148
+ Args:
149
+ repos: List of repository root paths.
150
+ config: Validated configuration.
151
+ inplace: Migration mode flag. When ``False`` (copy mode) a
152
+ destination-exists conflict is fatal and produces a
153
+ :class:`Blocked` result. When ``True`` (inplace) those conflicts
154
+ are recorded as informational :class:`Skip` entries instead.
155
+ verbose: Verbosity level.
156
+ tags: Optional tags for template evaluation.
157
+
158
+ Returns:
159
+ Either a :class:`Migration` with the planned moves and informational
160
+ skips, or a :class:`Blocked` listing the conflicting destinations.
161
+ """
162
+ if tags is None:
163
+ tags = {}
164
+
165
+ migration = Migration()
166
+ blocked = Blocked(destinations=[])
167
+
168
+ for repo_path in repos:
169
+ remote_uri = get_remote_uri(repo_path)
170
+ if not remote_uri:
171
+ migration.info_skips.append(
172
+ Skip(reason="no remote origin configured", path=repo_path, is_worktree=False)
173
+ )
174
+ if verbose > 0:
175
+ print(
176
+ f"Skipping {repo_path}: No remote origin configured",
177
+ file=sys.stderr,
178
+ )
179
+ continue
180
+
181
+ try:
182
+ uri_parsed = parse_uri(remote_uri)
183
+ except ValueError as e:
184
+ migration.info_skips.append(
185
+ Skip(reason=f"invalid remote URI: {e}", path=repo_path, is_worktree=False)
186
+ )
187
+ if verbose > 0:
188
+ print(f"Skipping {repo_path}: Invalid remote URI: {e}", file=sys.stderr)
189
+ continue
190
+
191
+ is_wt = is_worktree(repo_path)
192
+ source_path: Optional[Path] = None
193
+ if is_wt:
194
+ try:
195
+ source_path = get_source_repository(repo_path)
196
+ except Exception:
197
+ migration.info_skips.append(
198
+ Skip(reason="could not resolve source repository", path=repo_path, is_worktree=is_wt)
199
+ )
200
+ if verbose > 0:
201
+ print(f"Skipping {repo_path}: Could not resolve source repository", file=sys.stderr)
202
+ continue
203
+ try:
204
+ branch = get_current_branch(repo_path)
205
+ except GitCommandError:
206
+ migration.info_skips.append(
207
+ Skip(reason="detached HEAD", path=repo_path, is_worktree=is_wt)
208
+ )
209
+ if verbose > 0:
210
+ print(f"Skipping {repo_path}: Detached HEAD (branch required for worktree path)", file=sys.stderr)
211
+ continue
212
+ try:
213
+ expected_path = resolve_worktree_path(config, uri_parsed, branch, tags)
214
+ except ResolverError as e:
215
+ migration.info_skips.append(
216
+ Skip(reason=str(e), path=repo_path, is_worktree=is_wt)
217
+ )
218
+ if verbose > 0:
219
+ print(f"Skipping {repo_path}: {e}", file=sys.stderr)
220
+ continue
221
+ else:
222
+ try:
223
+ expected_path = resolve_source_path(config, uri_parsed, tags)
224
+ except ResolverError as e:
225
+ migration.info_skips.append(
226
+ Skip(reason=str(e), path=repo_path, is_worktree=is_wt)
227
+ )
228
+ if verbose > 0:
229
+ print(f"Skipping {repo_path}: {e}", file=sys.stderr)
230
+ continue
231
+
232
+ if repo_path.resolve() == expected_path.resolve():
233
+ migration.already_at_target.append(repo_path)
234
+ continue
235
+
236
+ if expected_path.exists():
237
+ if inplace:
238
+ # In inplace mode: skip and continue; the executor will leave
239
+ # the existing destination alone.
240
+ migration.info_skips.append(
241
+ Skip(reason="destination exists", path=expected_path, is_worktree=is_wt)
242
+ )
243
+ else:
244
+ # Copy mode: this is a hard blocker.
245
+ blocked.destinations.append(expected_path)
246
+ continue
247
+
248
+ migration.plans.append(
249
+ MigrationPlan(
250
+ old_path=repo_path,
251
+ new_path=expected_path,
252
+ uri=remote_uri,
253
+ is_worktree=is_wt,
254
+ source_path=source_path,
255
+ )
256
+ )
257
+
258
+ if blocked.destinations:
259
+ return blocked
260
+ return migration
@@ -0,0 +1 @@
1
+ """Template evaluation engine."""