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,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
|
gww/migration/planner.py
ADDED
|
@@ -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
|
gww/template/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Template evaluation engine."""
|