From b992fa64edc3da8a8636f913babc93bfe3d7ec13 Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Tue, 27 Jan 2026 20:36:58 +0300 Subject: [PATCH 1/6] migration progress output proper submodules threatment --- .../contracts/cli-commands.md | 16 +- specs/001-git-worktree-wrapper/spec.md | 16 +- specs/001-git-worktree-wrapper/tasks.md | 1 + src/gww/cli/commands/migrate.py | 47 +++-- src/gww/git/repository.py | 28 +++ tests/integration/test_migration.py | 187 ++++++++++++++++++ tests/unit/test_git_repository.py | 59 ++++++ 7 files changed, 323 insertions(+), 31 deletions(-) diff --git a/specs/001-git-worktree-wrapper/contracts/cli-commands.md b/specs/001-git-worktree-wrapper/contracts/cli-commands.md index 98d53e9..c652e30 100644 --- a/specs/001-git-worktree-wrapper/contracts/cli-commands.md +++ b/specs/001-git-worktree-wrapper/contracts/cli-commands.md @@ -233,19 +233,21 @@ gww pull **Behavior**: 1. Verify `old_repos` path exists and is a directory -2. Recursively scan directory tree for git repositories (directories containing `.git`) +2. Recursively scan directory tree for git repositories (directories containing `.git`). Git submodules are excluded (only top-level repos and worktrees are considered for migration). 3. For each repository found: - Extract URI from remote origin (if available) - Calculate expected location using current config - Compare current location with expected location + - If same: Output "Already at target: \" (when not quiet) and include in summary count - If different: - - If `--dry-run`: Print migration plan + - Output path (e.g. `old_path -> new_path`) immediately when processing that repository, before copy/move + - If `--dry-run`: Output each path immediately; at the end print "Would migrate N repositories" (and "Would skip N repositories" if any skipped) - Else: Copy or move repository to expected location - If the repository being migrated is a worktree: - After moving/copying, call `git worktree repair` on the source repository to update the worktree path - Handle repair errors gracefully (log warning, don't fail migration) - If the repository is a source repository: No repair action needed -4. Report summary: repositories scanned, migrated, repaired, skipped +4. Report summary: repositories migrated, repaired, skipped, already at target **Exit Codes**: - `0`: Success @@ -259,13 +261,15 @@ gww pull **Examples**: ```bash gww migrate ~/old-repos --dry-run -# Output: -# Would migrate 5 repositories: +# Output (each path first, then summary at end): # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 # ... +# Would migrate 5 repositories gww migrate ~/old-repos --move -# Output: +# Output (each path as processed, then summary): +# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 +# ... # Moved 5 repositories # Repaired 2 worktrees ``` diff --git a/specs/001-git-worktree-wrapper/spec.md b/specs/001-git-worktree-wrapper/spec.md index 2766045..ddb164f 100644 --- a/specs/001-git-worktree-wrapper/spec.md +++ b/specs/001-git-worktree-wrapper/spec.md @@ -146,30 +146,34 @@ Users can migrate existing repositories from old locations to new locations base **Acceptance Criteria**: - Command: `gww migrate [--dry-run] [--move]` - Verify `old_repos` path exists and is a directory -- Recursively scan directory tree for git repositories +- Recursively scan directory tree for git repositories (exclude git submodules; only top-level repos and worktrees are considered) - For each repository found: - Extract URI from remote origin (if available) - Calculate expected location using current config - Compare current location with expected location + - If same: Output "Already at target: \" and include in summary count - If different: - - If `--dry-run`: Print migration plan + - Output path (e.g. old_path -> new_path) immediately when processing that repository, before copy/move + - If `--dry-run`: Output each path immediately; at the end print "Would migrate N repositories" (and "Would skip N repositories" if any) - Else: Copy or move repository to expected location - If repository is a worktree: After moving/copying, call `git worktree repair` on the source repository to update worktree paths - If repository is a source repository: No repair needed (worktrees are not migrated with source) -- Report summary: repositories scanned, migrated, skipped +- Report summary: repositories migrated, repaired, skipped, already at target - Handle errors: invalid path, migration failed (exit code 1) - Handle configuration errors (exit code 2) **Examples**: ```bash gww migrate ~/old-repos --dry-run -# Output: -# Would migrate 5 repositories: +# Output (each path first, then summary at end): # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 # ... +# Would migrate 5 repositories gww migrate ~/old-repos --move -# Output: +# Output (each path as processed, then summary): +# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 +# ... # Moved 5 repositories # Repaired 2 worktrees ``` diff --git a/specs/001-git-worktree-wrapper/tasks.md b/specs/001-git-worktree-wrapper/tasks.md index 784ea43..6cb71de 100644 --- a/specs/001-git-worktree-wrapper/tasks.md +++ b/specs/001-git-worktree-wrapper/tasks.md @@ -183,6 +183,7 @@ - [X] T059 [US5] Integrate migrate command into main CLI parser in src/gww/cli/main.py - [X] T060 [US5] Add error handling for invalid paths, migration failures, and worktree updates in src/gww/cli/commands/migrate.py - [X] T061 [US5] Add output formatting (print migration summary to stdout, errors to stderr) in src/gww/cli/commands/migrate.py +- [X] [US5] Migrate refactor: immediate path output when processing each repo; "Already at target: \" when source equals destination; submodules excluded from scan; dry-run outputs paths first then "Would migrate N repositories" at end; unit test for is_submodule; integration tests for submodules **Checkpoint**: At this point, User Stories 1-5 should all work independently. Users can clone, add, remove worktrees, update sources, and migrate repositories. diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 4601959..0905f27 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -17,7 +17,7 @@ GitCommandError, get_remote_uri, get_source_repository, - is_git_repository, + is_submodule, is_worktree, ) from gww.git.worktree import repair_worktrees @@ -48,8 +48,8 @@ def _find_git_repositories(directory: Path) -> list[Path]: for root, dirs, files in os.walk(directory): root_path = Path(root) - # Check if this is a git repository - if (root_path / ".git").exists(): + # Check if this is a git repository (skip submodules - they move with parent) + if (root_path / ".git").exists() and not is_submodule(root_path): repos.append(root_path) # Don't descend into the .git directory dirs[:] = [d for d in dirs if d != ".git"] @@ -61,7 +61,7 @@ def _plan_migration( old_repos: Path, config: "Config", # type: ignore[name-defined] verbose: int = 0, -) -> list[MigrationPlan]: +) -> tuple[list[MigrationPlan], list[Path]]: """Plan migrations for all repositories in a directory. Args: @@ -70,9 +70,10 @@ def _plan_migration( verbose: Verbosity level. Returns: - List of migration plans. + Tuple of (migration plans, paths already at target). """ plans: list[MigrationPlan] = [] + already_at_target: list[Path] = [] repos = _find_git_repositories(old_repos) for repo_path in repos: @@ -104,8 +105,7 @@ def _plan_migration( # Check if migration needed if repo_path.resolve() == expected_path.resolve(): - if verbose > 1: - print(f"Skipping {repo_path}: Already at correct location", file=sys.stderr) + already_at_target.append(repo_path) continue # Check if destination exists @@ -122,7 +122,7 @@ def _plan_migration( ) ) - return plans + return plans, already_at_target def run_migrate(args: argparse.Namespace) -> int: @@ -172,9 +172,9 @@ def run_migrate(args: argparse.Namespace) -> int: if verbose > 0 and not quiet: print(f"Scanning {old_repos} for repositories...", file=sys.stderr) - plans = _plan_migration(old_repos, config, verbose) + plans, already_at_target = _plan_migration(old_repos, config, verbose) - if not plans: + if not plans and not already_at_target: if not quiet: print("No repositories to migrate.") return 0 @@ -183,17 +183,22 @@ def run_migrate(args: argparse.Namespace) -> int: valid_plans = [p for p in plans if not p.reason] skipped_plans = [p for p in plans if p.reason] - if dry_run: - # Show migration plan - print(f"Would migrate {len(valid_plans)} repositories:") - for plan in valid_plans: - print(f" {plan.old_path} -> {plan.new_path}") + # Output "already at target" paths when not quiet + if already_at_target and not quiet: + for path in already_at_target: + print(f"Already at target: {path}") - if skipped_plans: - print(f"\nWould skip {len(skipped_plans)} repositories:") + if dry_run: + # Output each path immediately, then summary at the end + if not quiet: + for plan in valid_plans: + print(f"{plan.old_path} -> {plan.new_path}") for plan in skipped_plans: - print(f" {plan.old_path}: {plan.reason}") - + print(f"{plan.old_path}: {plan.reason}") + if not quiet: + print(f"Would migrate {len(valid_plans)} repositories") + if skipped_plans: + print(f"Would skip {len(skipped_plans)} repositories") return 0 # Execute migrations @@ -203,6 +208,8 @@ def run_migrate(args: argparse.Namespace) -> int: for plan in valid_plans: try: + if not quiet: + print(f"{plan.old_path} -> {plan.new_path}") # Ensure parent directory exists plan.new_path.parent.mkdir(parents=True, exist_ok=True) @@ -254,6 +261,8 @@ def run_migrate(args: argparse.Namespace) -> int: print(f"Repaired {repaired} worktrees") if skipped_plans: print(f"Skipped {len(skipped_plans)} repositories") + if already_at_target: + print(f"Already at target: {len(already_at_target)} repositories") if failed: print(f"Failed {failed} repositories") diff --git a/src/gww/git/repository.py b/src/gww/git/repository.py index 83e5029..0b6d381 100644 --- a/src/gww/git/repository.py +++ b/src/gww/git/repository.py @@ -144,6 +144,34 @@ def is_worktree(path: Path) -> bool: return git_path.is_file() +def is_submodule(path: Path) -> bool: + """Check if a path is a git submodule (not a standalone repository). + + A submodule has a .git file pointing to the parent's .git/modules//, + while a worktree has a .git file pointing to .git/worktrees//. + + Args: + path: Path to repository root (directory containing .git). + + Returns: + True if path is a submodule. + """ + git_path = path / ".git" + if not git_path.is_file(): + return False + try: + content = git_path.read_text().strip() + except OSError: + return False + if not content.startswith("gitdir:"): + return False + gitdir = content.split(":", 1)[1].strip() + resolved = (path / gitdir).resolve() + # Submodules point to parent's .git/modules/; worktrees point to .git/worktrees/ + path_str = str(resolved).replace("\\", "/") + return ".git/modules" in path_str + + def get_source_repository(worktree_path: Path) -> Path: """Get the source (main) repository for a worktree. diff --git a/tests/integration/test_migration.py b/tests/integration/test_migration.py index 072b471..2bf5c3c 100644 --- a/tests/integration/test_migration.py +++ b/tests/integration/test_migration.py @@ -170,6 +170,68 @@ def repo_with_worktree(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, return worktrees_dir, source_repo, worktree_path +@pytest.fixture +def repo_with_submodule( + tmp_path_factory: pytest.TempPathFactory, +) -> tuple[Path, Path, Path]: + """Create a directory with a main repo that has a git submodule. + + Returns: + Tuple of (old_repos_dir, main_repo_path, submodule_path) + """ + old_dir = tmp_path_factory.mktemp("old_repos_with_submodule") + main_repo = old_dir / "main_repo" + main_repo.mkdir() + subprocess.run(["git", "init"], cwd=main_repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=main_repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=main_repo, + check=True, + capture_output=True, + ) + (main_repo / "README.md").write_text("# Main") + subprocess.run(["git", "add", "."], cwd=main_repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=main_repo, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/user/main-repo.git"], + cwd=main_repo, + check=True, + capture_output=True, + ) + # Create second repo to add as submodule + sub_repo = tmp_path_factory.mktemp("sub_repo") + subprocess.run(["git", "init"], cwd=sub_repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=sub_repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=sub_repo, + check=True, + capture_output=True, + ) + (sub_repo / "file.txt").write_text("sub content") + subprocess.run(["git", "add", "."], cwd=sub_repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Sub"], cwd=sub_repo, check=True, capture_output=True) + subprocess.run( + ["git", "-c", "protocol.file.allow=always", "submodule", "add", str(sub_repo), "submod"], + cwd=main_repo, + check=True, + capture_output=True, + ) + submodule_path = main_repo / "submod" + return old_dir, main_repo, submodule_path + + class TestMigrateCommand: """Integration tests for migrate command (T056).""" @@ -390,6 +452,63 @@ class Args: captured = capsys.readouterr() assert "No repositories to migrate" in captured.out + def test_migrate_outputs_already_at_target( + self, + old_repos_dir: Path, + config_dir: Path, + target_dir: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that migrate outputs specific message when repo is already at target.""" + # Place a repo at the exact path that config would resolve to + expected_base = target_dir / "github" / "user" + expected_base.mkdir(parents=True, exist_ok=True) + repo_at_target = expected_base / "project1" + repo_at_target.mkdir() + subprocess.run(["git", "init"], cwd=repo_at_target, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo_at_target, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo_at_target, + check=True, + capture_output=True, + ) + (repo_at_target / "README.md").write_text("# Here") + subprocess.run(["git", "add", "."], cwd=repo_at_target, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo_at_target, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/user/project1.git"], + cwd=repo_at_target, + check=True, + capture_output=True, + ) + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/github/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees +""") + + class Args: + old_repos = str(target_dir) + dry_run = False + move = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + captured = capsys.readouterr() + assert "Already at target:" in captured.out + assert "project1" in captured.out or str(repo_at_target) in captured.out + assert "Already at target: 1 repositories" in captured.out + def test_migrate_verbose_output( self, old_repos_dir: Path, @@ -542,3 +661,71 @@ class Args: assert "Repairing worktree paths" not in captured.err # Output should NOT mention "Repaired" since no worktrees were involved assert "Repaired" not in captured.out + + def test_migrate_dry_run_skips_submodules( + self, + repo_with_submodule: tuple[Path, Path, Path], + config_dir: Path, + target_dir: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that migrate dry-run only plans the main repo, not the submodule as separate repo.""" + old_dir, main_repo, submodule_path = repo_with_submodule + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/github/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees +""") + + class Args: + old_repos = str(old_dir) + dry_run = True + move = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + captured = capsys.readouterr() + # Should plan to migrate main_repo only (one repo) + assert "Would migrate" in captured.out + assert "1 repositories" in captured.out + # Submodule path must not appear as a separate migration target + assert str(submodule_path) not in captured.out or "main_repo" in captured.out + + def test_migrate_with_submodule_copies_parent_and_keeps_submodule( + self, + repo_with_submodule: tuple[Path, Path, Path], + config_dir: Path, + target_dir: Path, + ) -> None: + """Test that migrating a repo with submodule copies parent; submodule stays inside.""" + old_dir, main_repo, submodule_path = repo_with_submodule + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/github/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees +""") + + class Args: + old_repos = str(old_dir) + dry_run = False + move = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + # URI path segment is "main-repo" (from main-repo.git) + migrated_main = target_dir / "github" / "user" / "main-repo" + assert migrated_main.exists() + assert (migrated_main / ".gitmodules").exists() + migrated_submod = migrated_main / "submod" + assert migrated_submod.exists() + assert (migrated_submod / "file.txt").exists() + # Submodule .git should be file pointing to parent's .git/modules + assert (migrated_submod / ".git").is_file() diff --git a/tests/unit/test_git_repository.py b/tests/unit/test_git_repository.py index 9be272a..2231083 100644 --- a/tests/unit/test_git_repository.py +++ b/tests/unit/test_git_repository.py @@ -12,6 +12,7 @@ is_git_repository, get_repository_root, is_worktree, + is_submodule, get_source_repository, get_remote_uri, get_current_branch, @@ -138,6 +139,64 @@ def test_returns_true_for_worktree(self, git_repo_with_worktree: tuple[Path, Pat assert is_worktree(worktree) is True +class TestIsSubmodule: + """Tests for is_submodule function.""" + + def test_returns_false_for_main_repo(self, git_repo: Path) -> None: + """Test that is_submodule returns False for main repository.""" + assert is_submodule(git_repo) is False + + def test_returns_false_for_worktree(self, git_repo_with_worktree: tuple[Path, Path]) -> None: + """Test that is_submodule returns False for worktree (.git points to worktrees).""" + _, worktree = git_repo_with_worktree + assert is_submodule(worktree) is False + + def test_returns_true_for_submodule( + self, git_repo: Path, tmp_path_factory: pytest.TempPathFactory + ) -> None: + """Test that is_submodule returns True for a git submodule.""" + # Create a second repo to use as submodule + sub_repo = tmp_path_factory.mktemp("sub_repo") + subprocess.run(["git", "init"], cwd=sub_repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], + cwd=sub_repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=sub_repo, + check=True, + capture_output=True, + ) + (sub_repo / "file.txt").write_text("sub") + subprocess.run(["git", "add", "."], cwd=sub_repo, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "Initial"], + cwd=sub_repo, + check=True, + capture_output=True, + ) + # Add as submodule to main repo (allow file protocol for local path) + subprocess.run( + ["git", "-c", "protocol.file.allow=always", "submodule", "add", str(sub_repo), "submod"], + cwd=git_repo, + check=True, + capture_output=True, + ) + submodule_path = git_repo / "submod" + assert is_submodule(submodule_path) is True + + def test_returns_false_for_nonexistent_path(self, tmp_path: Path) -> None: + """Test that is_submodule returns False for path without .git.""" + assert is_submodule(tmp_path) is False + + def test_returns_false_when_git_is_directory(self, git_repo: Path) -> None: + """Test that is_submodule returns False when .git is a directory (main repo).""" + assert is_submodule(git_repo) is False + + class TestGetSourceRepository: """Tests for get_source_repository function (T033).""" From da8a8afa03c2ef68d6d26e896144b7bc30effaeb Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Tue, 27 Jan 2026 22:49:00 +0300 Subject: [PATCH 2/6] migration refactoring 2 --- AGENTS.md | 2 +- README.md | 26 +- README.ru.md | 26 +- docs/architecture.md | 2 +- .../contracts/cli-commands.md | 54 +-- specs/001-git-worktree-wrapper/quickstart.md | 6 +- specs/001-git-worktree-wrapper/spec.md | 36 +- src/gww/cli/commands/migrate.py | 443 +++++++++++++----- src/gww/cli/main.py | 15 +- src/gww/git/worktree.py | 13 +- tests/integration/test_migration.py | 185 +++++++- 11 files changed, 585 insertions(+), 223 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5c608d4..10e2bbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,7 +163,7 @@ GWW is a CLI tool (`gww`) that wraps git worktree functionality with: - `gww add [-c] [--tag key=value]...` - Add worktree - `gww remove [-f]` - Remove worktree - `gww pull` - Update source repository -- `gww migrate [--dry-run] [--move]` - Migrate repositories +- `gww migrate ... [--dry-run] [--copy | --inplace]` - Migrate repositories - `gww init config` - Create default config - `gww init shell ` - Install shell completion diff --git a/README.md b/README.md index 3a8c76d..42c614a 100644 --- a/README.md +++ b/README.md @@ -131,31 +131,31 @@ gww migrate ~/old-repos --dry-run # Output: # Would migrate 5 repositories: # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 -# ~/old-repos/repo2 -> ~/Developer/sources/github/user/repo2 # ... gww migrate ~/old-repos -# Output: -# Migrated 5 repositories -# Repaired 2 worktrees +# Copy (default): list, copy sources then worktrees, repair, summary + +gww migrate ~/old-repos --inplace +# Move worktrees then sources, repair, clean empty folders ``` -The `migrate` command scans a directory for git repositories and migrates them to locations based on your current configuration. It's useful when: +The `migrate` command scans one or more directories for git repositories and migrates them to locations based on your current configuration. It's useful when: - You've updated your configuration and want to reorganize existing repositories - You're moving from manual repository management to GWW - You need to consolidate repositories from different locations **Options**: - `--dry-run`, `-n`: Show what would be migrated without making changes -- `--move`: Move repositories instead of copying (default is copy) +- `--copy` (default): Copy repositories to new locations; list, validate, copy sources then worktrees, run `git worktree repair`, then report summary. No folder cleanup. +- `--inplace`: Move repositories in place (worktrees first, then sources), run `git worktree repair`, then recursively clean empty source folders. **Behavior**: -- Recursively scans the specified directory for git repositories -- Extracts the remote URI from each repository -- Calculates the expected location using your current config -- Migrates repositories that are in different locations than expected -- Automatically repairs worktree paths if migrating worktrees -- Skips repositories without remotes or that are already in the correct location +- Accepts one or more paths; scans each and merges repo lists (deduplicated) +- Classifies each repo as source or worktree; uses source path template for sources and worktree path template for worktrees +- **--inplace**: Two passes (worktrees then sources), move and repair, then remove vacated dirs and empty parents up to input roots +- **--copy**: List sources and worktrees, validate destinations, copy sources then worktrees, repair relations, report summary +- Skips repositories without remotes, detached HEAD worktrees, or already at target ## Tutorial @@ -292,7 +292,7 @@ gwa feature-branch --tag review | `gwa [-c] [--tag key=value]...` | ➕ Add worktree for branch (optionally create branch, tags available in templates/conditions) | | `gwr [-f]` | ➖ Remove worktree | | `gww pull` | 🔄 Update source repository (works from worktrees if source is clean and on main/master) | -| `gww migrate [--dry-run] [--move]` | 🚚 Migrate repositories to new locations | +| `gww migrate ... [--dry-run] [--copy \| --inplace]` | 🚚 Migrate repositories to new locations | | `gww init config` | ⚙️ Create default configuration file | | `gww init shell ` | 🐚 Install shell completion (bash/zsh/fish) | diff --git a/README.ru.md b/README.ru.md index 6e3bb91..9991ab8 100644 --- a/README.ru.md +++ b/README.ru.md @@ -131,31 +131,31 @@ gww migrate ~/old-repos --dry-run # Вывод: # Would migrate 5 repositories: # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 -# ~/old-repos/repo2 -> ~/Developer/sources/github/user/repo2 # ... gww migrate ~/old-repos -# Вывод: -# Migrated 5 repositories -# Repaired 2 worktrees +# Копирование (по умолчанию): список, копирование sources затем worktrees, repair, итог + +gww migrate ~/old-repos --inplace +# Перемещение worktrees затем sources, repair, очистка пустых папок ``` -Команда `migrate` сканирует директорию на наличие git-репозиториев и мигрирует их в локации на основе вашей текущей конфигурации. Полезна когда: +Команда `migrate` сканирует одну или несколько директорий на наличие git-репозиториев и мигрирует их в локации на основе вашей текущей конфигурации. Полезна когда: - Вы обновили конфигурацию и хотите реорганизовать существующие репозитории - Вы переходите с ручного управления репозиториями на GWW - Вам нужно объединить репозитории из разных локаций **Опции**: - `--dry-run`, `-n`: Показать что будет мигрировано без внесения изменений -- `--move`: Переместить репозитории вместо копирования (по умолчанию — копирование) +- `--copy` (по умолчанию): Копировать репозитории в новые локации; список, проверка, копирование sources затем worktrees, `git worktree repair`, итог. Без очистки папок. +- `--inplace`: Переместить репозитории на место (сначала worktrees, затем sources), `git worktree repair`, затем рекурсивно очистить пустые исходные папки. **Поведение**: -- Рекурсивно сканирует указанную директорию на наличие git-репозиториев -- Извлекает URI удаленного репозитория из каждого репозитория -- Вычисляет ожидаемую локацию используя вашу текущую конфигурацию -- Мигрирует репозитории, которые находятся в других локациях, чем ожидается -- Автоматически исправляет пути worktree при миграции worktree -- Пропускает репозитории без удаленных репозиториев или уже находящиеся в правильной локации +- Принимает один или несколько путей; сканирует каждый и объединяет списки репозиториев (без дубликатов) +- Классифицирует каждый репозиторий как source или worktree; для sources используется шаблон пути source, для worktrees — шаблон worktree +- **--inplace**: Два прохода (worktrees затем sources), перемещение и repair, затем удаление освободившихся директорий и пустых родителей до корней ввода +- **--copy**: Список sources и worktrees, проверка назначений, копирование sources затем worktrees, восстановление связей через repair, итог +- Пропускает репозитории без remote, worktrees с detached HEAD или уже в целевой локации ## Tutorial @@ -292,7 +292,7 @@ gwa feature-branch --tag review | `gwa [-c] [--tag key=value]...` | ➕ Добавить worktree для ветки (опционально создать ветку, теги доступны в шаблонах/условиях) | | `gwr [-f]` | ➖ Удалить worktree | | `gww pull` | 🔄 Обновить исходный репозиторий (работает из worktree, если исходный репозиторий чист и на main/master) | -| `gww migrate [--dry-run] [--move]` | 🚚 Мигрировать репозитории в новые локации | +| `gww migrate ... [--dry-run] [--copy \| --inplace]` | 🚚 Мигрировать репозитории в новые локации | | `gww init config` | ⚙️ Создать конфиг по умолчанию | | `gww init shell ` | 🐚 Установить автодополнение (bash/zsh/fish) | diff --git a/docs/architecture.md b/docs/architecture.md index ca7b1e0..06d8857 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,7 +156,7 @@ gww remove [--force] - remove worktree folder by branch gww pull - check that sources has main / master branch checkout, it's clean and if it is execute git pull. Can be executed from source or worktree folder. If executed from inside worktree folder will update source folder -gww migrate [--dry-run] [--move] - scan old-repos folder, check it against current config and if location is incorrent copy (default) or move (if --move specified) to new position +gww migrate ... [--dry-run] [--copy | --inplace] - scan path(s) for repos, merge and dedupe; copy (default) or inplace move (worktrees then sources, repair, clean empty folders) to new positions gww init config - create default settings file, gww.yml in $XDG_CONFIG_HOME compliant location. Came up with simple config with default_sources and default_worktrees filled and large comment block with examples covering other cases and function with documentation. diff --git a/specs/001-git-worktree-wrapper/contracts/cli-commands.md b/specs/001-git-worktree-wrapper/contracts/cli-commands.md index c652e30..137296b 100644 --- a/specs/001-git-worktree-wrapper/contracts/cli-commands.md +++ b/specs/001-git-worktree-wrapper/contracts/cli-commands.md @@ -220,34 +220,33 @@ gww pull --- -### 5. `gww migrate [--dry-run] [--move]` +### 5. `gww migrate ... [--dry-run] [--copy | --inplace]` -**Purpose**: Scan old repositories directory and migrate them to new locations based on current configuration. +**Purpose**: Scan one or more directories for git repositories and migrate them to new locations based on current configuration. **Arguments**: -- `old_repos` (str, required): Path to directory containing old repositories +- `path` (one or more, required): Path(s) to directory(ies) containing old repositories. Multiple paths are processed as a single combined set (repos from all paths are merged and deduplicated). **Options**: - `--dry-run`, `-n`: Show what would be migrated without making changes -- `--move`: Move repositories instead of copying (default is copy) +- `--copy` (default): Copy repositories to new locations. List sources and worktrees, validate destinations, copy sources then worktrees, run `git worktree repair` to recover relations, then report summary. No folder cleanup. +- `--inplace`: Move repositories in place. First pass: move worktrees and run `git worktree repair` in each source. Second pass: move sources and run `git worktree repair` in each moved source (for sources that had worktrees). Then recursively clean empty source folders (remove vacated dirs and empty parents up to input roots). Dry-run outputs destination path when changed or "Already at target: \" when not. **Behavior**: -1. Verify `old_repos` path exists and is a directory -2. Recursively scan directory tree for git repositories (directories containing `.git`). Git submodules are excluded (only top-level repos and worktrees are considered for migration). -3. For each repository found: - - Extract URI from remote origin (if available) - - Calculate expected location using current config - - Compare current location with expected location - - If same: Output "Already at target: \" (when not quiet) and include in summary count - - If different: - - Output path (e.g. `old_path -> new_path`) immediately when processing that repository, before copy/move - - If `--dry-run`: Output each path immediately; at the end print "Would migrate N repositories" (and "Would skip N repositories" if any skipped) - - Else: Copy or move repository to expected location - - If the repository being migrated is a worktree: - - After moving/copying, call `git worktree repair` on the source repository to update the worktree path - - Handle repair errors gracefully (log warning, don't fail migration) - - If the repository is a source repository: No repair action needed -4. Report summary: repositories migrated, repaired, skipped, already at target +1. Verify each `path` exists and is a directory +2. Recursively scan each directory for git repositories (directories containing `.git`; submodules excluded). Merge and deduplicate repo lists by resolved path. +3. Classify each repo as **source** or **worktree**. Expected path: sources use `resolve_source_path`; worktrees use `resolve_worktree_path` (branch from current branch; detached HEAD worktrees are skipped). +4. **If `--inplace`**: + - First pass: for each worktree whose path differs, move to new path and run `git worktree repair` in its source (at current path). + - Second pass: for each source whose path differs, move to new path and run `git worktree repair` in the moved source (only for sources that had worktrees moved in pass 1). + - Then (if not dry-run): recursively remove vacated directories and empty parents up to input roots. + - Dry-run: output destination path per item when changed, or "Already at target: \" when same. +5. **If `--copy`** (default): + - Output each found source and worktree (e.g. "Source: \ -> \", "Worktree: ..."). + - Validate that each destination does not exist (plans with "destination exists" are skipped; rest are migrated). + - If not dry-run: copy sources, then copy worktrees; fix copied worktrees' `.git` to point to new source and run `git worktree repair`; report summary (N repositories, M worktrees, skipped, already at target). No folder cleanup. + - Dry-run: list and validate only; output "Would migrate N repositories". +6. Report summary: repositories migrated/moved, repaired, skipped, already at target **Exit Codes**: - `0`: Success @@ -261,17 +260,18 @@ gww pull **Examples**: ```bash gww migrate ~/old-repos --dry-run -# Output (each path first, then summary at end): +# Output (each path, then summary): # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 # ... # Would migrate 5 repositories -gww migrate ~/old-repos --move -# Output (each path as processed, then summary): -# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 -# ... -# Moved 5 repositories -# Repaired 2 worktrees +gww migrate ~/old-repos +# Copy (default): list, copy sources then worktrees, repair, summary + +gww migrate ~/old-repos --inplace +# Move worktrees then sources, repair, clean empty folders +# Moved N repositories +# Already at target: M repositories ``` --- diff --git a/specs/001-git-worktree-wrapper/quickstart.md b/specs/001-git-worktree-wrapper/quickstart.md index 92034bb..f62cfb5 100644 --- a/specs/001-git-worktree-wrapper/quickstart.md +++ b/specs/001-git-worktree-wrapper/quickstart.md @@ -338,11 +338,11 @@ gww migrate ~/old-repos --dry-run # ~/old-repos/repo2 -> ~/Developer/sources/gitlab/group/repo2 # ~/old-repos/repo3 -> ~/Developer/sources/default/org/repo3 -# Actual migration (copy) +# Actual migration (copy, default) gww migrate ~/old-repos -# Or move instead of copy -gww migrate ~/old-repos --move +# Or move in place and clean empty folders +gww migrate ~/old-repos --inplace ``` **Test Case**: diff --git a/specs/001-git-worktree-wrapper/spec.md b/specs/001-git-worktree-wrapper/spec.md index ddb164f..55f8ffd 100644 --- a/specs/001-git-worktree-wrapper/spec.md +++ b/specs/001-git-worktree-wrapper/spec.md @@ -144,38 +144,30 @@ gww pull Users can migrate existing repositories from old locations to new locations based on current configuration. **Acceptance Criteria**: -- Command: `gww migrate [--dry-run] [--move]` -- Verify `old_repos` path exists and is a directory -- Recursively scan directory tree for git repositories (exclude git submodules; only top-level repos and worktrees are considered) -- For each repository found: - - Extract URI from remote origin (if available) - - Calculate expected location using current config - - Compare current location with expected location - - If same: Output "Already at target: \" and include in summary count - - If different: - - Output path (e.g. old_path -> new_path) immediately when processing that repository, before copy/move - - If `--dry-run`: Output each path immediately; at the end print "Would migrate N repositories" (and "Would skip N repositories" if any) - - Else: Copy or move repository to expected location - - If repository is a worktree: After moving/copying, call `git worktree repair` on the source repository to update worktree paths - - If repository is a source repository: No repair needed (worktrees are not migrated with source) -- Report summary: repositories migrated, repaired, skipped, already at target +- Command: `gww migrate ... [--dry-run] [--copy | --inplace]` +- Accept one or more paths; verify each exists and is a directory +- Recursively scan each directory for git repositories (exclude submodules); merge and deduplicate repo lists +- Classify each repo as source or worktree; expected path: sources via `resolve_source_path`, worktrees via `resolve_worktree_path` (branch from current branch; skip detached HEAD worktrees) +- **--inplace**: First pass move worktrees and run `git worktree repair` in each source; second pass move sources and run repair in moved sources that had worktrees; then recursively clean empty source folders (vacated dirs and empty parents up to input roots). Dry-run outputs destination or "Already at target". +- **--copy** (default): List sources and worktrees; validate destinations; copy sources then worktrees; run `git worktree repair` to recover relations; report summary. No folder cleanup. +- Report summary: repositories migrated/moved, repaired, skipped, already at target - Handle errors: invalid path, migration failed (exit code 1) - Handle configuration errors (exit code 2) **Examples**: ```bash gww migrate ~/old-repos --dry-run -# Output (each path first, then summary at end): +# Output (each path, then summary): # ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 # ... # Would migrate 5 repositories -gww migrate ~/old-repos --move -# Output (each path as processed, then summary): -# ~/old-repos/repo1 -> ~/Developer/sources/github/user/repo1 -# ... -# Moved 5 repositories -# Repaired 2 worktrees +gww migrate ~/old-repos +# Copy (default): list, copy sources then worktrees, repair, summary + +gww migrate ~/old-repos --inplace +# Move worktrees then sources, repair, clean empty folders +# Moved N repositories ``` --- diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 0905f27..5275f99 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -11,10 +11,11 @@ from typing import Optional from gww.config.loader import ConfigLoadError, ConfigNotFoundError, load_config -from gww.config.resolver import ResolverError, resolve_source_path -from gww.config.validator import ConfigValidationError, validate_config +from gww.config.resolver import ResolverError, resolve_source_path, resolve_worktree_path +from gww.config.validator import Config, ConfigValidationError, validate_config from gww.git.repository import ( GitCommandError, + get_current_branch, get_remote_uri, get_source_repository, is_submodule, @@ -26,12 +27,14 @@ @dataclass class MigrationPlan: - """Plan for migrating a single repository.""" + """Plan for migrating a single repository (source or worktree).""" old_path: Path new_path: Path uri: str reason: str = "" + is_worktree: bool = False + source_path: Optional[Path] = None # main repo path (for worktrees only) def _find_git_repositories(directory: Path) -> list[Path]: @@ -57,27 +60,54 @@ def _find_git_repositories(directory: Path) -> list[Path]: return repos +def _collect_all_repos( + input_paths: list[Path], +) -> tuple[list[Path], list[Path]]: + """Collect and merge repo roots from multiple input directories. + + Args: + input_paths: List of directories to scan. + + Returns: + Tuple of (deduplicated repo paths, input roots for cleanup). + """ + seen: set[Path] = set() + repos: list[Path] = [] + for directory in input_paths: + for repo_path in _find_git_repositories(directory): + resolved = repo_path.resolve() + if resolved not in seen: + seen.add(resolved) + repos.append(repo_path) + return repos, [p.resolve() for p in input_paths] + + def _plan_migration( - old_repos: Path, - config: "Config", # type: ignore[name-defined] + repos: list[Path], + config: Config, verbose: int = 0, + tags: Optional[dict[str, str]] = None, ) -> tuple[list[MigrationPlan], list[Path]]: - """Plan migrations for all repositories in a directory. + """Plan migrations for all repositories. + + Classifies each repo as source or worktree; uses resolve_source_path for + sources and resolve_worktree_path for worktrees (branch from get_current_branch). Args: - old_repos: Directory containing old repositories. + repos: List of repository root paths. config: Validated configuration. verbose: Verbosity level. + tags: Optional tags for template evaluation. Returns: Tuple of (migration plans, paths already at target). """ + if tags is None: + tags = {} plans: list[MigrationPlan] = [] already_at_target: list[Path] = [] - repos = _find_git_repositories(old_repos) for repo_path in repos: - # Get remote URI remote_uri = get_remote_uri(repo_path) if not remote_uri: if verbose > 0: @@ -87,28 +117,46 @@ def _plan_migration( ) continue - # Parse URI try: - uri = parse_uri(remote_uri) + uri_parsed = parse_uri(remote_uri) except ValueError as e: if verbose > 0: print(f"Skipping {repo_path}: Invalid remote URI: {e}", file=sys.stderr) continue - # Resolve expected path - try: - expected_path = resolve_source_path(config, uri) - except ResolverError as e: - if verbose > 0: - print(f"Skipping {repo_path}: {e}", file=sys.stderr) - continue + is_wt = is_worktree(repo_path) + source_path: Optional[Path] = None + if is_wt: + try: + source_path = get_source_repository(repo_path) + except Exception: + if verbose > 0: + print(f"Skipping {repo_path}: Could not resolve source repository", file=sys.stderr) + continue + try: + branch = get_current_branch(repo_path) + except GitCommandError: + if verbose > 0: + print(f"Skipping {repo_path}: Detached HEAD (branch required for worktree path)", file=sys.stderr) + continue + try: + expected_path = resolve_worktree_path(config, uri_parsed, branch, tags) + except ResolverError as e: + if verbose > 0: + print(f"Skipping {repo_path}: {e}", file=sys.stderr) + continue + else: + try: + expected_path = resolve_source_path(config, uri_parsed, tags) + except ResolverError as e: + if verbose > 0: + print(f"Skipping {repo_path}: {e}", file=sys.stderr) + continue - # Check if migration needed if repo_path.resolve() == expected_path.resolve(): already_at_target.append(repo_path) continue - # Check if destination exists reason = "" if expected_path.exists(): reason = "destination exists - will skip" @@ -119,12 +167,240 @@ def _plan_migration( new_path=expected_path, uri=remote_uri, reason=reason, + is_worktree=is_wt, + source_path=source_path, ) ) return plans, already_at_target +def _run_inplace( + valid_plans: list[MigrationPlan], + already_at_target: list[Path], + input_roots: list[Path], + dry_run: bool, + quiet: bool, + verbose: int, +) -> int: + """Execute inplace migration (move worktrees then sources, then clean empty dirs).""" + # Output "already at target" when not quiet + if already_at_target and not quiet: + for path in already_at_target: + print(f"Already at target: {path}") + + worktree_plans = [p for p in valid_plans if p.is_worktree] + source_plans = [p for p in valid_plans if not p.is_worktree] + + # First pass: worktrees + for plan in worktree_plans: + if dry_run: + if not quiet: + print(plan.new_path) + continue + if not quiet: + print(plan.new_path) + plan.new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(plan.old_path), str(plan.new_path)) + if plan.source_path is not None: + try: + if verbose > 0 and not quiet: + print(f"Repairing worktree paths in {plan.source_path}", file=sys.stderr) + repair_worktrees(plan.source_path) + except GitCommandError as e: + print( + f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}", + file=sys.stderr, + ) + + # Second pass: sources (only repair if this source had worktrees we moved) + source_paths_with_worktrees = {p.source_path.resolve() for p in worktree_plans if p.source_path is not None} + for plan in source_plans: + if dry_run: + if not quiet: + print(plan.new_path) + continue + if not quiet: + print(plan.new_path) + plan.new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(plan.old_path), str(plan.new_path)) + if plan.old_path.resolve() in source_paths_with_worktrees: + try: + if verbose > 0 and not quiet: + print(f"Repairing worktree paths in {plan.new_path}", file=sys.stderr) + repair_worktrees(plan.new_path) + except GitCommandError as e: + print( + f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}", + file=sys.stderr, + ) + + # Clean empty source folders (inplace only, recursive) + if not dry_run and valid_plans: + vacated = [p.old_path.resolve() for p in valid_plans] + roots_set = set(input_roots) + # Process deepest paths first so parents can be removed after children + vacated_sorted = sorted(vacated, key=lambda p: len(p.parts), reverse=True) + for start_path in vacated_sorted: + current = start_path + while True: + if current in roots_set or not current.exists(): + break + if not current.is_dir(): + break + try: + if any(current.iterdir()): + break + current.rmdir() + current = current.parent + except OSError: + break + + if not quiet: + if valid_plans: + moved = len(worktree_plans) + len(source_plans) + print(f"Moved {moved} repositories") + if already_at_target: + print(f"Already at target: {len(already_at_target)} repositories") + return 0 + + +def _run_copy( + valid_plans: list[MigrationPlan], + skipped_plans: list[MigrationPlan], + already_at_target: list[Path], + dry_run: bool, + quiet: bool, + verbose: int, + tags: dict[str, str], +) -> int: + """Execute copy migration (list, validate, copy sources then worktrees, repair, summary).""" + # Output "already at target" when not quiet + if already_at_target and not quiet: + for path in already_at_target: + print(f"Already at target: {path}") + + if not valid_plans: + if skipped_plans and not quiet: + for plan in skipped_plans: + print(f"{plan.old_path}: {plan.reason}") + if not quiet: + if already_at_target: + print(f"Already at target: {len(already_at_target)} repositories") + else: + print("No repositories to migrate.") + return 0 + + # List and output each found source and worktree + if not quiet: + for plan in valid_plans: + kind = "Worktree" if plan.is_worktree else "Source" + print(f"{kind}: {plan.old_path} -> {plan.new_path}") + for plan in skipped_plans: + print(f"{plan.old_path}: {plan.reason}") + + if dry_run: + if not quiet: + print(f"Would migrate {len(valid_plans)} repositories") + if skipped_plans: + print(f"Would skip {len(skipped_plans)} repositories") + return 0 + + # Migrate sources first, then worktrees + source_plans = [p for p in valid_plans if not p.is_worktree] + worktree_plans = [p for p in valid_plans if p.is_worktree] + migrated_sources = 0 + migrated_worktrees = 0 + failed = 0 + + for plan in source_plans: + try: + if not quiet: + print(f"Copying repository {plan.old_path} -> {plan.new_path}") + plan.new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(str(plan.old_path), str(plan.new_path)) + migrated_sources += 1 + except OSError as e: + print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr) + failed += 1 + + for plan in worktree_plans: + try: + if not quiet: + print(f"Copying worktree {plan.old_path} -> {plan.new_path}") + plan.new_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(str(plan.old_path), str(plan.new_path)) + migrated_worktrees += 1 + # Recover relation: point copied worktree's .git to new source (if any) and repair + if plan.source_path is not None: + # Resolve new source path (source may have been copied in this run) + new_source = None + for sp in source_plans: + if sp.old_path.resolve() == plan.source_path.resolve(): + new_source = sp.new_path + break + if new_source is not None: + _fix_copied_worktree_gitfile(plan.new_path, plan.old_path, new_source) + try: + if verbose > 0 and not quiet: + print(f"Repairing worktree paths in {new_source}", file=sys.stderr) + repair_worktrees(new_source) + except GitCommandError as e: + print( + f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}", + file=sys.stderr, + ) + else: + # Source was not in migration; repair old source with new worktree path + try: + if verbose > 0 and not quiet: + print(f"Repairing worktree paths in {plan.source_path}", file=sys.stderr) + repair_worktrees(plan.source_path, [plan.new_path]) + except GitCommandError as e: + print( + f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}", + file=sys.stderr, + ) + except OSError as e: + print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr) + failed += 1 + + if not quiet: + print(f"Migrated {migrated_sources} repositories, {migrated_worktrees} worktrees") + if skipped_plans: + print(f"Skipped {len(skipped_plans)} repositories") + if already_at_target: + print(f"Already at target: {len(already_at_target)} repositories") + if failed: + print(f"Failed {failed} repositories") + return 1 if failed > 0 else 0 + + +def _fix_copied_worktree_gitfile( + new_worktree_path: Path, + old_worktree_path: Path, + new_source_path: Path, +) -> None: + """Update copied worktree's .git file to point to new source's worktrees dir.""" + git_file = new_worktree_path / ".git" + if not git_file.is_file(): + return + content = git_file.read_text().strip() + if not content.startswith("gitdir:"): + return + old_gitdir = content.split(":", 1)[1].strip() + # Old content points to old_source/.git/worktrees/; extract worktree id + parts = Path(old_gitdir.replace("\\", "/")).parts + try: + idx = parts.index("worktrees") + if idx + 1 < len(parts): + wt_id = parts[idx + 1] + new_gitdir = str(new_source_path / ".git" / "worktrees" / wt_id) + git_file.write_text(f"gitdir: {new_gitdir}\n") + except (ValueError, IndexError): + pass + + def run_migrate(args: argparse.Namespace) -> int: """Execute the migrate command. @@ -134,22 +410,25 @@ def run_migrate(args: argparse.Namespace) -> int: Returns: Exit code (0 for success, 1 for error, 2 for config error). """ - old_repos_str = args.old_repos + old_repos_raw = args.old_repos + old_repos_list: list[str] = ( + old_repos_raw if isinstance(old_repos_raw, list) else [old_repos_raw] + ) dry_run = getattr(args, "dry_run", False) - move = getattr(args, "move", False) + inplace = getattr(args, "inplace", False) verbose = getattr(args, "verbose", 0) quiet = getattr(args, "quiet", False) + tags = getattr(args, "tags", {}) or {} - old_repos = Path(old_repos_str).expanduser().resolve() + input_paths = [Path(p).expanduser().resolve() for p in old_repos_list] - # Verify path exists - if not old_repos.exists(): - print(f"Error: Path does not exist: {old_repos}", file=sys.stderr) - return 1 - - if not old_repos.is_dir(): - print(f"Error: Not a directory: {old_repos}", file=sys.stderr) - return 1 + for p in input_paths: + if not p.exists(): + print(f"Error: Path does not exist: {p}", file=sys.stderr) + return 1 + if not p.is_dir(): + print(f"Error: Not a directory: {p}", file=sys.stderr) + return 1 # Load and validate config try: @@ -168,102 +447,24 @@ def run_migrate(args: argparse.Namespace) -> int: print(f"Config validation error: {e}", file=sys.stderr) return 2 - # Plan migrations + repos, input_roots = _collect_all_repos(input_paths) if verbose > 0 and not quiet: - print(f"Scanning {old_repos} for repositories...", file=sys.stderr) - - plans, already_at_target = _plan_migration(old_repos, config, verbose) + print(f"Scanning {len(input_paths)} path(s) for repositories...", file=sys.stderr) - if not plans and not already_at_target: - if not quiet: - print("No repositories to migrate.") - return 0 + plans, already_at_target = _plan_migration(repos, config, verbose, tags) - # Filter out plans with existing destinations valid_plans = [p for p in plans if not p.reason] skipped_plans = [p for p in plans if p.reason] - # Output "already at target" paths when not quiet - if already_at_target and not quiet: - for path in already_at_target: - print(f"Already at target: {path}") - - if dry_run: - # Output each path immediately, then summary at the end - if not quiet: - for plan in valid_plans: - print(f"{plan.old_path} -> {plan.new_path}") - for plan in skipped_plans: - print(f"{plan.old_path}: {plan.reason}") + if not plans and not already_at_target: if not quiet: - print(f"Would migrate {len(valid_plans)} repositories") - if skipped_plans: - print(f"Would skip {len(skipped_plans)} repositories") + print("No repositories to migrate.") return 0 - # Execute migrations - migrated = 0 - failed = 0 - repaired = 0 - - for plan in valid_plans: - try: - if not quiet: - print(f"{plan.old_path} -> {plan.new_path}") - # Ensure parent directory exists - plan.new_path.parent.mkdir(parents=True, exist_ok=True) - - # Check if this is a worktree before moving (need source repo path) - is_wt = is_worktree(plan.old_path) - source_repo: Optional[Path] = None - if is_wt: - try: - source_repo = get_source_repository(plan.old_path) - except Exception: - # If we can't get source repo, we'll skip repair - pass - - if move: - if verbose > 0 and not quiet: - print(f"Moving {plan.old_path} -> {plan.new_path}", file=sys.stderr) - shutil.move(str(plan.old_path), str(plan.new_path)) - else: - if verbose > 0 and not quiet: - print(f"Copying {plan.old_path} -> {plan.new_path}", file=sys.stderr) - shutil.copytree(str(plan.old_path), str(plan.new_path)) - - # If this was a worktree, repair the source repository - if is_wt and source_repo is not None: - try: - if verbose > 0 and not quiet: - print( - f"Repairing worktree paths in {source_repo}", - file=sys.stderr, - ) - repair_worktrees(source_repo) - repaired += 1 - except GitCommandError as e: - print( - f"Warning: Failed to repair worktree paths for {plan.new_path}: {e}", - file=sys.stderr, - ) - - migrated += 1 - except OSError as e: - print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr) - failed += 1 - - # Summary - if not quiet: - action = "Moved" if move else "Migrated" - print(f"{action} {migrated} repositories") - if repaired: - print(f"Repaired {repaired} worktrees") - if skipped_plans: - print(f"Skipped {len(skipped_plans)} repositories") - if already_at_target: - print(f"Already at target: {len(already_at_target)} repositories") - if failed: - print(f"Failed {failed} repositories") - - return 1 if failed > 0 else 0 + if inplace: + return _run_inplace( + valid_plans, already_at_target, input_roots, dry_run, quiet, verbose + ) + return _run_copy( + valid_plans, skipped_plans, already_at_target, dry_run, quiet, verbose, tags + ) diff --git a/src/gww/cli/main.py b/src/gww/cli/main.py index 5cde349..9d95236 100644 --- a/src/gww/cli/main.py +++ b/src/gww/cli/main.py @@ -127,17 +127,24 @@ def create_parser() -> argparse.ArgumentParser: ) migrate_parser.add_argument( "old_repos", - help="Path to directory containing old repositories", + nargs="+", + help="Path(s) to directory(ies) containing old repositories", ) migrate_parser.add_argument( "-n", "--dry-run", action="store_true", help="Show what would be migrated without making changes", ) - migrate_parser.add_argument( - "--move", + migrate_group = migrate_parser.add_mutually_exclusive_group() + migrate_group.add_argument( + "--copy", + action="store_true", + help="Copy repositories to new locations (default)", + ) + migrate_group.add_argument( + "--inplace", action="store_true", - help="Move repositories instead of copying", + help="Move repositories in place and clean empty source folders", ) # init command (with subcommands) diff --git a/src/gww/git/worktree.py b/src/gww/git/worktree.py index e854c42..d703ec7 100644 --- a/src/gww/git/worktree.py +++ b/src/gww/git/worktree.py @@ -309,7 +309,10 @@ def prune_worktrees(repo_path: Path, dry_run: bool = False) -> list[str]: return pruned -def repair_worktrees(repo_path: Path) -> None: +def repair_worktrees( + repo_path: Path, + worktree_paths: Optional[list[Path]] = None, +) -> None: """Repair worktree administrative files after worktrees have been moved. This updates the worktree paths stored in the source repository's @@ -317,8 +320,14 @@ def repair_worktrees(repo_path: Path) -> None: Args: repo_path: Path to repository (source or worktree). + worktree_paths: Optional list of worktree paths to repair. When provided, + git worktree repair is called with these paths so the repo updates + to point to the new locations. Raises: GitCommandError: If repair command fails. """ - _run_git(["worktree", "repair"], cwd=repo_path, check=True) + args = ["worktree", "repair"] + if worktree_paths: + args.extend(str(p) for p in worktree_paths) + _run_git(args, cwd=repo_path, check=True) diff --git a/tests/integration/test_migration.py b/tests/integration/test_migration.py index 2bf5c3c..648540c 100644 --- a/tests/integration/test_migration.py +++ b/tests/integration/test_migration.py @@ -261,7 +261,7 @@ def test_migrate_dry_run_shows_plan( class Args: old_repos = str(old_repos_dir) dry_run = True - move = False + inplace = False verbose = 0 quiet = False @@ -299,7 +299,7 @@ def test_migrate_copies_repositories( class Args: old_repos = str(old_repos_dir) dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -319,7 +319,7 @@ def test_migrate_moves_repositories( config_dir: Path, target_dir: Path, ) -> None: - """Test that migrate with --move moves repositories.""" + """Test that migrate with --inplace moves repositories.""" config_path = config_dir / "gww" / "config.yml" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(f""" @@ -338,7 +338,7 @@ def test_migrate_moves_repositories( class Args: old_repos = str(old_repos_dir) dry_run = False - move = True + inplace = True verbose = 0 quiet = False @@ -370,7 +370,7 @@ def test_migrate_skips_repos_without_remote( class Args: old_repos = str(old_repos_dir) dry_run = False - move = False + inplace = False verbose = 1 # Verbose to see skip messages quiet = False @@ -397,7 +397,7 @@ def test_migrate_fails_for_nonexistent_path( class Args: old_repos = "/nonexistent/path" dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -416,7 +416,7 @@ def test_migrate_fails_without_config( class Args: old_repos = str(old_repos_dir) dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -442,7 +442,7 @@ def test_migrate_handles_empty_directory( class Args: old_repos = str(tmp_path) dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -497,7 +497,7 @@ def test_migrate_outputs_already_at_target( class Args: old_repos = str(target_dir) dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -532,7 +532,7 @@ def test_migrate_verbose_output( class Args: old_repos = str(old_repos_dir) dry_run = False - move = False + inplace = False verbose = 1 quiet = False @@ -557,12 +557,18 @@ def test_migrate_repairs_worktree_after_move( config_path.write_text(f""" default_sources: {target_dir}/github/path(-2)/path(-1) default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) + worktrees: {target_dir}/github/path(-2)/path(-1) """) class Args: old_repos = str(worktrees_dir) dry_run = False - move = True + inplace = True verbose = 1 quiet = False @@ -599,12 +605,18 @@ def test_migrate_repairs_worktree_after_copy( config_path.write_text(f""" default_sources: {target_dir}/github/path(-2)/path(-1) default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) + worktrees: {target_dir}/github/path(-2)/path(-1) """) class Args: old_repos = str(worktrees_dir) dry_run = False - move = False # Copy, not move + inplace = False # Copy (default) verbose = 1 quiet = False @@ -615,7 +627,7 @@ class Args: # Verify original worktree still exists (copy, not move) assert worktree_path.exists() - # Verify copy was created + # Verify copy was created (worktree path from rule) new_worktree_path = target_dir / "github" / "user" / "feature-worktree" assert new_worktree_path.exists() @@ -645,7 +657,7 @@ def test_migrate_does_not_repair_source_repositories( class Args: old_repos = str(old_repos_dir) dry_run = False - move = True + inplace = True verbose = 1 quiet = False @@ -681,7 +693,7 @@ def test_migrate_dry_run_skips_submodules( class Args: old_repos = str(old_dir) dry_run = True - move = False + inplace = False verbose = 0 quiet = False @@ -713,7 +725,7 @@ def test_migrate_with_submodule_copies_parent_and_keeps_submodule( class Args: old_repos = str(old_dir) dry_run = False - move = False + inplace = False verbose = 0 quiet = False @@ -729,3 +741,144 @@ class Args: assert (migrated_submod / "file.txt").exists() # Submodule .git should be file pointing to parent's .git/modules assert (migrated_submod / ".git").is_file() + + def test_migrate_multiple_input_folders( + self, + old_repos_dir: Path, + config_dir: Path, + target_dir: Path, + tmp_path_factory: pytest.TempPathFactory, + ) -> None: + """Test that migrate with multiple paths merges repos and processes as one set.""" + # Second folder with one repo + other_dir = tmp_path_factory.mktemp("old_repos_other") + repo3 = other_dir / "project3" + repo3.mkdir() + subprocess.run(["git", "init"], cwd=repo3, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo3, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo3, + check=True, + capture_output=True, + ) + (repo3 / "README.md").write_text("# Project 3") + subprocess.run(["git", "add", "."], cwd=repo3, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo3, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/user/project3.git"], + cwd=repo3, + check=True, + capture_output=True, + ) + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/default/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) + gitlab: + when: '"gitlab" in host()' + sources: {target_dir}/gitlab/path(-2)/path(-1) +""") + + class Args: + old_repos = [str(old_repos_dir), str(other_dir)] + dry_run = False + inplace = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + assert (target_dir / "github" / "user" / "project1").exists() + assert (target_dir / "gitlab" / "group" / "project2").exists() + assert (target_dir / "github" / "user" / "project3").exists() + + def test_migrate_inplace_cleans_empty_folders( + self, + old_repos_dir: Path, + config_dir: Path, + target_dir: Path, + ) -> None: + """Test that --inplace removes vacated dirs and empty parents recursively.""" + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/default/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) + gitlab: + when: '"gitlab" in host()' + sources: {target_dir}/gitlab/path(-2)/path(-1) +""") + + class Args: + old_repos = str(old_repos_dir) + dry_run = False + inplace = True + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + assert not (old_repos_dir / "project1").exists() + assert not (old_repos_dir / "project2").exists() + assert (target_dir / "github" / "user" / "project1").exists() + assert (target_dir / "gitlab" / "group" / "project2").exists() + + def test_migrate_copy_validation_destination_exists( + self, + old_repos_dir: Path, + config_dir: Path, + target_dir: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Test that copy mode reports all validation errors (e.g. destination exists).""" + # Pre-create one destination so validation fails + dest = target_dir / "github" / "user" / "project1" + dest.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/default/path(-2)/path(-1) +default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) + gitlab: + when: '"gitlab" in host()' + sources: {target_dir}/gitlab/path(-2)/path(-1) +""") + + class Args: + old_repos = str(old_repos_dir) + dry_run = False + inplace = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + captured = capsys.readouterr() + # When destination exists we skip that plan; output mentions skip or destination + assert "destination exists" in captured.out or "Skipped" in captured.out + # project2 (no conflict) should still be migrated + assert (target_dir / "gitlab" / "group" / "project2").exists() + assert result == 0 From eddbef348a8e5e3bb0b0bacd0aabbc0ee735a686 Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Tue, 27 Jan 2026 23:59:00 +0300 Subject: [PATCH 3/6] migration progress --- .../contracts/cli-commands.md | 3 +- specs/001-git-worktree-wrapper/spec.md | 2 +- src/gww/cli/commands/migrate.py | 39 +++++++++++++++++-- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/specs/001-git-worktree-wrapper/contracts/cli-commands.md b/specs/001-git-worktree-wrapper/contracts/cli-commands.md index 137296b..ca1618a 100644 --- a/specs/001-git-worktree-wrapper/contracts/cli-commands.md +++ b/specs/001-git-worktree-wrapper/contracts/cli-commands.md @@ -234,7 +234,7 @@ gww pull **Behavior**: 1. Verify each `path` exists and is a directory -2. Recursively scan each directory for git repositories (directories containing `.git`; submodules excluded). Merge and deduplicate repo lists by resolved path. +2. Recursively scan each directory for git repositories (directories containing `.git`; submodules excluded). Merge and deduplicate repo lists by resolved path. During the scan, when not `--quiet`, output the current examining directory to stderr on a single line (overwriting), updating at most once per second; clear the line after the scan completes. 3. Classify each repo as **source** or **worktree**. Expected path: sources use `resolve_source_path`; worktrees use `resolve_worktree_path` (branch from current branch; detached HEAD worktrees are skipped). 4. **If `--inplace`**: - First pass: for each worktree whose path differs, move to new path and run `git worktree repair` in its source (at current path). @@ -255,6 +255,7 @@ gww pull **Output**: - Success: Print summary to stdout +- Progress: When not `--quiet`, during directory scan print current examining folder to stderr on a single line (at most once per second); clear the line after the scan - Error: Print error message to stderr **Examples**: diff --git a/specs/001-git-worktree-wrapper/spec.md b/specs/001-git-worktree-wrapper/spec.md index 55f8ffd..9a8dcd7 100644 --- a/specs/001-git-worktree-wrapper/spec.md +++ b/specs/001-git-worktree-wrapper/spec.md @@ -146,7 +146,7 @@ Users can migrate existing repositories from old locations to new locations base **Acceptance Criteria**: - Command: `gww migrate ... [--dry-run] [--copy | --inplace]` - Accept one or more paths; verify each exists and is a directory -- Recursively scan each directory for git repositories (exclude submodules); merge and deduplicate repo lists +- Recursively scan each directory for git repositories (exclude submodules); merge and deduplicate repo lists. During the scan, when not quiet: output current examining folder to stderr on a single line, updating at most once per second; clear the line after the scan. - Classify each repo as source or worktree; expected path: sources via `resolve_source_path`, worktrees via `resolve_worktree_path` (branch from current branch; skip detached HEAD worktrees) - **--inplace**: First pass move worktrees and run `git worktree repair` in each source; second pass move sources and run repair in moved sources that had worktrees; then recursively clean empty source folders (vacated dirs and empty parents up to input roots). Dry-run outputs destination or "Already at target". - **--copy** (default): List sources and worktrees; validate destinations; copy sources then worktrees; run `git worktree repair` to recover relations; report summary. No folder cleanup. diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 5275f99..3d7c5c3 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -6,9 +6,10 @@ import os import shutil import sys +import time from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Callable, Optional from gww.config.loader import ConfigLoadError, ConfigNotFoundError, load_config from gww.config.resolver import ResolverError, resolve_source_path, resolve_worktree_path @@ -37,11 +38,17 @@ class MigrationPlan: source_path: Optional[Path] = None # main repo path (for worktrees only) -def _find_git_repositories(directory: Path) -> list[Path]: +def _find_git_repositories( + directory: Path, + *, + progress_callback: Optional[Callable[[Path], None]] = None, +) -> list[Path]: """Find all git repositories in a directory tree. Args: directory: Directory to scan. + progress_callback: Optional callback invoked with current directory path + at the start of each os.walk iteration. Returns: List of paths to git repository roots. @@ -50,6 +57,8 @@ def _find_git_repositories(directory: Path) -> list[Path]: for root, dirs, files in os.walk(directory): root_path = Path(root) + if progress_callback is not None: + progress_callback(root_path) # Check if this is a git repository (skip submodules - they move with parent) if (root_path / ".git").exists() and not is_submodule(root_path): @@ -62,11 +71,15 @@ def _find_git_repositories(directory: Path) -> list[Path]: def _collect_all_repos( input_paths: list[Path], + *, + progress_callback: Optional[Callable[[Path], None]] = None, ) -> tuple[list[Path], list[Path]]: """Collect and merge repo roots from multiple input directories. Args: input_paths: List of directories to scan. + progress_callback: Optional callback invoked with current directory path + during the scan (passed to _find_git_repositories). Returns: Tuple of (deduplicated repo paths, input roots for cleanup). @@ -74,7 +87,9 @@ def _collect_all_repos( seen: set[Path] = set() repos: list[Path] = [] for directory in input_paths: - for repo_path in _find_git_repositories(directory): + for repo_path in _find_git_repositories( + directory, progress_callback=progress_callback + ): resolved = repo_path.resolve() if resolved not in seen: seen.add(resolved) @@ -447,7 +462,23 @@ def run_migrate(args: argparse.Namespace) -> int: print(f"Config validation error: {e}", file=sys.stderr) return 2 - repos, input_roots = _collect_all_repos(input_paths) + progress_callback: Optional[Callable[[Path], None]] = None + if not quiet: + last_progress_time: list[float] = [0.0] + + def _progress_cb(path: Path) -> None: + now = time.time() + if now - last_progress_time[0] >= 1.0: + print(f"\rExamining: {path} ", end="", file=sys.stderr, flush=True) + last_progress_time[0] = now + + progress_callback = _progress_cb + + repos, input_roots = _collect_all_repos( + input_paths, progress_callback=progress_callback + ) + if not quiet: + print("\n", file=sys.stderr, end="") if verbose > 0 and not quiet: print(f"Scanning {len(input_paths)} path(s) for repositories...", file=sys.stderr) From 1cf5013460af9a4e9c4b57fb1a27eeabd350e46d Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Wed, 28 Jan 2026 00:11:00 +0300 Subject: [PATCH 4/6] progress improvement --- src/gww/cli/commands/migrate.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 3d7c5c3..7ee34ac 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -468,8 +468,9 @@ def run_migrate(args: argparse.Namespace) -> int: def _progress_cb(path: Path) -> None: now = time.time() - if now - last_progress_time[0] >= 1.0: - print(f"\rExamining: {path} ", end="", file=sys.stderr, flush=True) + if now - last_progress_time[0] >= 1.0 / 3.0: + # \r = carriage return (same line), \033[K = erase to end of line + print(f"\r\033[KExamining: {path}", end="", file=sys.stderr, flush=True) last_progress_time[0] = now progress_callback = _progress_cb @@ -478,7 +479,7 @@ def _progress_cb(path: Path) -> None: input_paths, progress_callback=progress_callback ) if not quiet: - print("\n", file=sys.stderr, end="") + print("\r\033[K\n", file=sys.stderr, end="") if verbose > 0 and not quiet: print(f"Scanning {len(input_paths)} path(s) for repositories...", file=sys.stderr) From 2d98ec6af0d96a4b19f96b955e193a387c30280c Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Wed, 28 Jan 2026 00:16:00 +0300 Subject: [PATCH 5/6] progress improvement --- .../contracts/cli-commands.md | 2 +- specs/001-git-worktree-wrapper/spec.md | 2 +- src/gww/cli/commands/migrate.py | 29 ++++++++-- tests/unit/test_migrate.py | 53 +++++++++++++++++++ 4 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_migrate.py diff --git a/specs/001-git-worktree-wrapper/contracts/cli-commands.md b/specs/001-git-worktree-wrapper/contracts/cli-commands.md index ca1618a..a3b8f16 100644 --- a/specs/001-git-worktree-wrapper/contracts/cli-commands.md +++ b/specs/001-git-worktree-wrapper/contracts/cli-commands.md @@ -234,7 +234,7 @@ gww pull **Behavior**: 1. Verify each `path` exists and is a directory -2. Recursively scan each directory for git repositories (directories containing `.git`; submodules excluded). Merge and deduplicate repo lists by resolved path. During the scan, when not `--quiet`, output the current examining directory to stderr on a single line (overwriting), updating at most once per second; clear the line after the scan completes. +2. Recursively scan each directory for git repositories and worktrees (paths where `.git` exists; submodules excluded). Do not descend into repository or worktree interiors—each repo/worktree is treated as a single unit. Merge and deduplicate repo lists by resolved path. During the scan, when not `--quiet`, output the current examining directory to stderr on a single line (overwriting), updating at most once per second; clear the line after the scan completes. 3. Classify each repo as **source** or **worktree**. Expected path: sources use `resolve_source_path`; worktrees use `resolve_worktree_path` (branch from current branch; detached HEAD worktrees are skipped). 4. **If `--inplace`**: - First pass: for each worktree whose path differs, move to new path and run `git worktree repair` in its source (at current path). diff --git a/specs/001-git-worktree-wrapper/spec.md b/specs/001-git-worktree-wrapper/spec.md index 9a8dcd7..05fb0aa 100644 --- a/specs/001-git-worktree-wrapper/spec.md +++ b/specs/001-git-worktree-wrapper/spec.md @@ -146,7 +146,7 @@ Users can migrate existing repositories from old locations to new locations base **Acceptance Criteria**: - Command: `gww migrate ... [--dry-run] [--copy | --inplace]` - Accept one or more paths; verify each exists and is a directory -- Recursively scan each directory for git repositories (exclude submodules); merge and deduplicate repo lists. During the scan, when not quiet: output current examining folder to stderr on a single line, updating at most once per second; clear the line after the scan. +- Recursively scan each directory for git repositories and worktrees (exclude submodules); do not descend into repository or worktree interiors (treat each as a single unit). Merge and deduplicate repo lists. During the scan, when not quiet: output current examining folder to stderr on a single line, updating at most once per second; clear the line after the scan. - Classify each repo as source or worktree; expected path: sources via `resolve_source_path`, worktrees via `resolve_worktree_path` (branch from current branch; skip detached HEAD worktrees) - **--inplace**: First pass move worktrees and run `git worktree repair` in each source; second pass move sources and run repair in moved sources that had worktrees; then recursively clean empty source folders (vacated dirs and empty parents up to input roots). Dry-run outputs destination or "Already at target". - **--copy** (default): List sources and worktrees; validate destinations; copy sources then worktrees; run `git worktree repair` to recover relations; report summary. No folder cleanup. diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 7ee34ac..5fcf9cf 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -43,7 +43,10 @@ def _find_git_repositories( *, progress_callback: Optional[Callable[[Path], None]] = None, ) -> list[Path]: - """Find all git repositories in a directory tree. + """Find all git repositories and worktrees in a directory tree. + + Repository and worktree interiors are not traversed; each repo or worktree + is treated as a single unit (no descent into subdirectories). Args: directory: Directory to scan. @@ -60,11 +63,11 @@ def _find_git_repositories( if progress_callback is not None: progress_callback(root_path) - # Check if this is a git repository (skip submodules - they move with parent) + # Check if this is a git repository or worktree (skip submodules - they move with parent) if (root_path / ".git").exists() and not is_submodule(root_path): repos.append(root_path) - # Don't descend into the .git directory - dirs[:] = [d for d in dirs if d != ".git"] + # Do not descend into the repository or worktree (treat as single unit) + dirs.clear() return repos @@ -469,8 +472,24 @@ def run_migrate(args: argparse.Namespace) -> int: def _progress_cb(path: Path) -> None: now = time.time() if now - last_progress_time[0] >= 1.0 / 3.0: + path_str = str(path) + prefix = "Examining: " + try: + max_width = shutil.get_terminal_size(fallback=(80, 24)).columns + except Exception: + max_width = 80 + msg = prefix + path_str + if len(msg) > max_width: + available = max_width - len(prefix) - 3 + if available > 0: + end_len = (available - 3) // 2 + start_len = (available - 3) - end_len + path_display = path_str[:start_len] + "..." + path_str[-end_len:] + else: + path_display = "..." + msg = prefix + path_display # \r = carriage return (same line), \033[K = erase to end of line - print(f"\r\033[KExamining: {path}", end="", file=sys.stderr, flush=True) + print(f"\r\033[K{msg}", end="", file=sys.stderr, flush=True) last_progress_time[0] = now progress_callback = _progress_cb diff --git a/tests/unit/test_migrate.py b/tests/unit/test_migrate.py new file mode 100644 index 0000000..bc086ae --- /dev/null +++ b/tests/unit/test_migrate.py @@ -0,0 +1,53 @@ +"""Unit tests for migrate command helpers in src/gww/cli/commands/migrate.py.""" + +import subprocess +from pathlib import Path + +import pytest + +from gww.cli.commands.migrate import _find_git_repositories + + +@pytest.mark.unit +class TestFindGitRepositories: + """Tests for _find_git_repositories.""" + + def test_does_not_descend_into_repository_interior( + self, tmp_path: Path + ) -> None: + """When a repo root is found, the walk does not descend into its subdirectories.""" + root = tmp_path / "root" + root.mkdir() + repo_a = root / "repo_a" + repo_a.mkdir() + subprocess.run(["git", "init"], cwd=repo_a, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo_a, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo_a, + check=True, + capture_output=True, + ) + (repo_a / "src").mkdir() + (repo_a / "src" / "file.txt").write_text("x") + (repo_a / "docs").mkdir() + (repo_a / "docs" / "readme.txt").write_text("y") + + visited: list[Path] = [] + + result = _find_git_repositories( + root, progress_callback=lambda p: visited.append(Path(p)) + ) + + assert result == [repo_a] + # Only root and repo_a should be visited; repo_a/src and repo_a/docs must not + visited_resolved = [p.resolve() for p in visited] + repo_a_resolved = repo_a.resolve() + assert repo_a_resolved in visited_resolved + assert (repo_a / "src").resolve() not in visited_resolved + assert (repo_a / "docs").resolve() not in visited_resolved From 63947235de907f1570594a4b49a78e94a426db2f Mon Sep 17 00:00:00 2001 From: Vadim Volkov Date: Wed, 28 Jan 2026 20:06:51 +0300 Subject: [PATCH 6/6] fix symlinks in migration --- .../contracts/cli-commands.md | 2 +- specs/001-git-worktree-wrapper/quickstart.md | 2 +- specs/001-git-worktree-wrapper/spec.md | 2 +- specs/001-git-worktree-wrapper/tasks.md | 1 + src/gww/cli/commands/migrate.py | 4 +- tests/integration/test_migration.py | 62 +++++++++++++++++++ 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/specs/001-git-worktree-wrapper/contracts/cli-commands.md b/specs/001-git-worktree-wrapper/contracts/cli-commands.md index a3b8f16..868580d 100644 --- a/specs/001-git-worktree-wrapper/contracts/cli-commands.md +++ b/specs/001-git-worktree-wrapper/contracts/cli-commands.md @@ -244,7 +244,7 @@ gww pull 5. **If `--copy`** (default): - Output each found source and worktree (e.g. "Source: \ -> \", "Worktree: ..."). - Validate that each destination does not exist (plans with "destination exists" are skipped; rest are migrated). - - If not dry-run: copy sources, then copy worktrees; fix copied worktrees' `.git` to point to new source and run `git worktree repair`; report summary (N repositories, M worktrees, skipped, already at target). No folder cleanup. + - If not dry-run: copy sources, then copy worktrees (symbolic links are copied as symlinks, not resolved); fix copied worktrees' `.git` to point to new source and run `git worktree repair`; report summary (N repositories, M worktrees, skipped, already at target). No folder cleanup. - Dry-run: list and validate only; output "Would migrate N repositories". 6. Report summary: repositories migrated/moved, repaired, skipped, already at target diff --git a/specs/001-git-worktree-wrapper/quickstart.md b/specs/001-git-worktree-wrapper/quickstart.md index f62cfb5..54433d2 100644 --- a/specs/001-git-worktree-wrapper/quickstart.md +++ b/specs/001-git-worktree-wrapper/quickstart.md @@ -353,7 +353,7 @@ gww migrate ~/old-repos --inplace **Test Case**: - **Given**: Old repositories - **When**: `gww migrate ~/old-repos` -- **Then**: Repositories copied to new locations based on current config +- **Then**: Repositories copied to new locations based on current config; symbolic links are copied as symlinks (not resolved) ## Template Function Examples diff --git a/specs/001-git-worktree-wrapper/spec.md b/specs/001-git-worktree-wrapper/spec.md index 05fb0aa..6622bc9 100644 --- a/specs/001-git-worktree-wrapper/spec.md +++ b/specs/001-git-worktree-wrapper/spec.md @@ -149,7 +149,7 @@ Users can migrate existing repositories from old locations to new locations base - Recursively scan each directory for git repositories and worktrees (exclude submodules); do not descend into repository or worktree interiors (treat each as a single unit). Merge and deduplicate repo lists. During the scan, when not quiet: output current examining folder to stderr on a single line, updating at most once per second; clear the line after the scan. - Classify each repo as source or worktree; expected path: sources via `resolve_source_path`, worktrees via `resolve_worktree_path` (branch from current branch; skip detached HEAD worktrees) - **--inplace**: First pass move worktrees and run `git worktree repair` in each source; second pass move sources and run repair in moved sources that had worktrees; then recursively clean empty source folders (vacated dirs and empty parents up to input roots). Dry-run outputs destination or "Already at target". -- **--copy** (default): List sources and worktrees; validate destinations; copy sources then worktrees; run `git worktree repair` to recover relations; report summary. No folder cleanup. +- **--copy** (default): List sources and worktrees; validate destinations; copy sources then worktrees (symbolic links copied as symlinks, not resolved); run `git worktree repair` to recover relations; report summary. No folder cleanup. - Report summary: repositories migrated/moved, repaired, skipped, already at target - Handle errors: invalid path, migration failed (exit code 1) - Handle configuration errors (exit code 2) diff --git a/specs/001-git-worktree-wrapper/tasks.md b/specs/001-git-worktree-wrapper/tasks.md index 6cb71de..f77c4d0 100644 --- a/specs/001-git-worktree-wrapper/tasks.md +++ b/specs/001-git-worktree-wrapper/tasks.md @@ -184,6 +184,7 @@ - [X] T060 [US5] Add error handling for invalid paths, migration failures, and worktree updates in src/gww/cli/commands/migrate.py - [X] T061 [US5] Add output formatting (print migration summary to stdout, errors to stderr) in src/gww/cli/commands/migrate.py - [X] [US5] Migrate refactor: immediate path output when processing each repo; "Already at target: \" when source equals destination; submodules excluded from scan; dry-run outputs paths first then "Would migrate N repositories" at end; unit test for is_submodule; integration tests for submodules +- [X] [US5] Migrate copy: preserve symbolic links (copy as symlinks, do not resolve); integration test test_migrate_copy_preserves_symlinks **Checkpoint**: At this point, User Stories 1-5 should all work independently. Users can clone, add, remove worktrees, update sources, and migrate repositories. diff --git a/src/gww/cli/commands/migrate.py b/src/gww/cli/commands/migrate.py index 5fcf9cf..bcfcabc 100644 --- a/src/gww/cli/commands/migrate.py +++ b/src/gww/cli/commands/migrate.py @@ -336,7 +336,7 @@ def _run_copy( if not quiet: print(f"Copying repository {plan.old_path} -> {plan.new_path}") plan.new_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(str(plan.old_path), str(plan.new_path)) + shutil.copytree(str(plan.old_path), str(plan.new_path), symlinks=True) migrated_sources += 1 except OSError as e: print(f"Error migrating {plan.old_path}: {e}", file=sys.stderr) @@ -347,7 +347,7 @@ def _run_copy( if not quiet: print(f"Copying worktree {plan.old_path} -> {plan.new_path}") plan.new_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copytree(str(plan.old_path), str(plan.new_path)) + shutil.copytree(str(plan.old_path), str(plan.new_path), symlinks=True) migrated_worktrees += 1 # Recover relation: point copied worktree's .git to new source (if any) and repair if plan.source_path is not None: diff --git a/tests/integration/test_migration.py b/tests/integration/test_migration.py index 648540c..f80891d 100644 --- a/tests/integration/test_migration.py +++ b/tests/integration/test_migration.py @@ -313,6 +313,68 @@ class Args: assert (target_dir / "github" / "user" / "project1").exists() assert (target_dir / "gitlab" / "group" / "project2").exists() + def test_migrate_copy_preserves_symlinks( + self, + tmp_path_factory: pytest.TempPathFactory, + config_dir: Path, + target_dir: Path, + ) -> None: + """Test that migrate --copy copies symbolic links as symlinks, not resolved.""" + old_dir = tmp_path_factory.mktemp("old_repos_symlink") + repo = old_dir / "symlink_repo" + repo.mkdir() + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@test.com"], + cwd=repo, + check=True, + capture_output=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=repo, + check=True, + capture_output=True, + ) + (repo / "README.md").write_text("# Repo with symlink") + (repo / "mylink").symlink_to("README.md") + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Initial"], cwd=repo, check=True, capture_output=True) + subprocess.run( + ["git", "remote", "add", "origin", "https://github.com/user/symlink_repo.git"], + cwd=repo, + check=True, + capture_output=True, + ) + + config_path = config_dir / "gww" / "config.yml" + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text(f""" +default_sources: {target_dir}/default/path(-1) +default_worktrees: {target_dir}/worktrees + +sources: + github: + when: '"github" in host()' + sources: {target_dir}/github/path(-2)/path(-1) +""") + + class Args: + old_repos = str(old_dir) + dry_run = False + inplace = False + verbose = 0 + quiet = False + + result = run_migrate(Args()) + + assert result == 0 + migrated = target_dir / "github" / "user" / "symlink_repo" + assert migrated.exists() + mylink = migrated / "mylink" + assert mylink.is_symlink(), "Symlink should be copied as symlink, not resolved" + assert Path(mylink.readlink()) == Path("README.md") + def test_migrate_moves_repositories( self, old_repos_dir: Path,