git-worktree-wrapper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,147 @@
1
+ """Init commands implementation (config and shell)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from gww.cli.context import CommandContext, CommandExit, exit_on_error
10
+ from gww.config.loader import config_exists, get_default_config
11
+ from gww.utils.shell import (
12
+ generate_bash_aliases,
13
+ generate_fish_aliases,
14
+ generate_zsh_aliases,
15
+ get_aliases_path,
16
+ get_installation_instructions,
17
+ install_aliases,
18
+ install_completion,
19
+ )
20
+ from gww.utils.xdg import get_config_path
21
+
22
+
23
+ @exit_on_error
24
+ def run_init_config(ctx: CommandContext) -> int:
25
+ """Execute the init config command.
26
+
27
+ Args:
28
+ ctx: Per-invocation command context.
29
+
30
+ Returns:
31
+ Exit code (0 for success, 1 for error).
32
+ """
33
+ config_path = get_config_path()
34
+
35
+ if config_exists():
36
+ print(
37
+ f"Config file already exists at: {config_path}\n"
38
+ "Not overwriting.",
39
+ file=sys.stderr,
40
+ )
41
+ return 1
42
+
43
+ default_content = get_default_config(config_path)
44
+
45
+ try:
46
+ config_path.parent.mkdir(parents=True, exist_ok=True)
47
+ config_path.write_text(default_content)
48
+ except OSError as e:
49
+ raise CommandExit(1, f"Error creating config file: {e}") from e
50
+
51
+ ctx.say(f"Created config file: {config_path}")
52
+
53
+ return 0
54
+
55
+
56
+ @exit_on_error
57
+ def run_init_shell(ctx: CommandContext) -> int:
58
+ """Execute the init shell command.
59
+
60
+ Args:
61
+ ctx: Per-invocation command context.
62
+
63
+ Returns:
64
+ Exit code (0 for success, 1 for error).
65
+ """
66
+ if ctx.shell is None:
67
+ raise CommandExit(1, "Error: Missing shell name.")
68
+
69
+ valid_shells = {"bash", "zsh", "fish"}
70
+ if ctx.shell not in valid_shells:
71
+ raise CommandExit(
72
+ 1,
73
+ f"Error: Invalid shell '{ctx.shell}'. "
74
+ f"Must be one of: {', '.join(sorted(valid_shells))}",
75
+ )
76
+
77
+ try:
78
+ completion_path = install_completion(ctx.shell)
79
+ except (ValueError, OSError) as e:
80
+ raise CommandExit(1, f"Error: {e}") from e
81
+
82
+ try:
83
+ aliases_path = install_aliases(ctx.shell)
84
+ except (ValueError, OSError) as e:
85
+ raise CommandExit(1, f"Error: {e}") from e
86
+
87
+ if not ctx.quiet:
88
+ instructions = get_installation_instructions(ctx.shell, completion_path, aliases_path)
89
+ print(instructions)
90
+
91
+ return 0
92
+
93
+
94
+ def detect_user_shell() -> str | None:
95
+ """Return the user's current shell name, or ``None`` if undetectable.
96
+
97
+ Reads ``$SHELL`` and extracts the basename (``/bin/bash`` → ``bash``).
98
+ Returns ``None`` for unknown shells so callers can silently skip
99
+ staleness checks rather than guess.
100
+ """
101
+ shell_path = os.environ.get("SHELL", "")
102
+ basename = os.path.basename(shell_path)
103
+ if basename in {"bash", "zsh", "fish"}:
104
+ return basename
105
+ return None
106
+
107
+
108
+ def warn_if_alias_is_stale(shell: str) -> None:
109
+ """Warn on stderr if the installed alias file predates the current source.
110
+
111
+ Compares the on-disk alias file to what :func:`gww.utils.shell` would
112
+ generate today. If they differ, prints a one-line reminder to re-run
113
+ ``gww init shell <shell>``. Silently does nothing when:
114
+
115
+ * no alias file exists yet — we don't pester first-time installers;
116
+ * the file already matches the current generator output.
117
+
118
+ Args:
119
+ shell: Shell name (``"bash"``, ``"zsh"``, or ``"fish"``).
120
+ """
121
+ aliases_path = get_aliases_path(shell)
122
+
123
+ if shell == "fish":
124
+ assert isinstance(aliases_path, dict)
125
+ target = aliases_path["gwa"]
126
+ if not target.exists():
127
+ return
128
+ installed = target.read_text()
129
+ expected = generate_fish_aliases()["gwa"]
130
+ location = str(target)
131
+ else:
132
+ assert isinstance(aliases_path, Path)
133
+ if not aliases_path.exists():
134
+ return
135
+ installed = aliases_path.read_text()
136
+ expected = (
137
+ generate_bash_aliases() if shell == "bash" else generate_zsh_aliases()
138
+ )
139
+ location = str(aliases_path)
140
+
141
+ if installed != expected:
142
+ print(
143
+ f"gww: shell aliases at {location} are out of date — "
144
+ f"re-run 'gww init shell {shell}' to pick up the latest "
145
+ f"gwc/gwa/gwr.",
146
+ file=sys.stderr,
147
+ )
@@ -0,0 +1,81 @@
1
+ """Migrate command implementation.
2
+
3
+ Thin wrapper over :mod:`gww.migration`: the command validates inputs,
4
+ calls the planner, prints the result, and delegates execution to the
5
+ executor. All planning and movement logic lives in the migration package
6
+ where it can be unit-tested directly.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from gww.cli.context import CommandContext, CommandExit, exit_on_error, load_config_or_exit
15
+ from gww.migration import (
16
+ Blocked,
17
+ Migration,
18
+ Mode,
19
+ collect_repositories,
20
+ execute,
21
+ plan_migration,
22
+ )
23
+
24
+
25
+ @exit_on_error
26
+ def run_migrate(ctx: CommandContext) -> int:
27
+ """Execute the migrate command.
28
+
29
+ Args:
30
+ ctx: Per-invocation command context.
31
+
32
+ Returns:
33
+ Exit code (0 for success, 1 for error, 2 for config error).
34
+ """
35
+ input_paths = [Path(p).expanduser().resolve() for p in ctx.old_repos]
36
+
37
+ for p in input_paths:
38
+ if not p.exists():
39
+ raise CommandExit(1, f"Error: Path does not exist: {p}")
40
+ if not p.is_dir():
41
+ raise CommandExit(1, f"Error: Not a directory: {p}")
42
+
43
+ config = load_config_or_exit()
44
+
45
+ repos, input_roots = collect_repositories(input_paths)
46
+ ctx.verbose_msg(f"Scanning {len(input_paths)} path(s) for repositories...")
47
+
48
+ result = plan_migration(
49
+ repos,
50
+ config,
51
+ inplace=ctx.inplace,
52
+ verbose=ctx.verbose,
53
+ tags=ctx.tags,
54
+ )
55
+
56
+ if isinstance(result, Blocked):
57
+ for path in result.destinations:
58
+ print(f"Error: Destination already exists: {path}", file=sys.stderr)
59
+ count = len(result.destinations)
60
+ print(
61
+ f"Cannot proceed: {count} destination(s) already exist in copy mode",
62
+ file=sys.stderr,
63
+ )
64
+ return 1
65
+
66
+ migration: Migration = result
67
+
68
+ if not migration.plans and not migration.info_skips and not migration.already_at_target:
69
+ if not ctx.quiet:
70
+ print("No repositories to migrate.")
71
+ return 0
72
+
73
+ mode: Mode = "inplace" if ctx.inplace else "copy"
74
+ return execute(
75
+ migration,
76
+ input_roots=input_roots,
77
+ mode=mode,
78
+ dry_run=ctx.dry_run,
79
+ quiet=ctx.quiet,
80
+ verbose=ctx.verbose,
81
+ )
@@ -0,0 +1,62 @@
1
+ """Pull command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from gww.cli.context import (
8
+ CommandContext,
9
+ CommandExit,
10
+ exit_on_error,
11
+ resolve_source_repo,
12
+ )
13
+ from gww.git.branch import is_main_branch
14
+ from gww.git.repository import (
15
+ GitCommandError,
16
+ get_current_branch,
17
+ is_clean,
18
+ pull_repository,
19
+ )
20
+
21
+
22
+ @exit_on_error
23
+ def run_pull(ctx: CommandContext) -> int:
24
+ """Execute the pull command.
25
+
26
+ Args:
27
+ ctx: Per-invocation command context.
28
+
29
+ Returns:
30
+ Exit code (0 for success, 1 for error).
31
+ """
32
+ source_path = resolve_source_repo(Path.cwd())
33
+
34
+ try:
35
+ current_branch = get_current_branch(source_path)
36
+ except GitCommandError as e:
37
+ raise CommandExit(1, f"Error getting current branch: {e}") from e
38
+
39
+ if not is_main_branch(current_branch):
40
+ raise CommandExit(
41
+ 1,
42
+ f"Error: Source repository must be on 'main' or 'master' branch. "
43
+ f"Current branch: {current_branch}",
44
+ )
45
+
46
+ if not is_clean(source_path):
47
+ raise CommandExit(
48
+ 1,
49
+ "Error: Source repository has uncommitted changes. "
50
+ "Commit or stash changes first.",
51
+ )
52
+
53
+ ctx.verbose_msg(f"Pulling updates for {source_path}...")
54
+
55
+ try:
56
+ pull_repository(source_path, pass_through_stdout=not ctx.quiet)
57
+ except GitCommandError as e:
58
+ raise CommandExit(1, f"Error pulling updates: {e}") from e
59
+
60
+ ctx.say(f"Updated source repository: {source_path}")
61
+
62
+ return 0
@@ -0,0 +1,153 @@
1
+ """Remove worktree command implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from gww.actions import ActionError, MatcherError, apply_actions
8
+ from gww.cli.context import (
9
+ CommandContext,
10
+ CommandExit,
11
+ RuleFailure,
12
+ exit_on_error,
13
+ load_config_or_exit,
14
+ parse_uri_or_exit,
15
+ print_action_failure_summary,
16
+ resolve_source_repo,
17
+ )
18
+ from gww.git.repository import (
19
+ GitCommandError,
20
+ NotGitRepositoryError,
21
+ detect_repository,
22
+ get_source_repository,
23
+ try_get_current_branch,
24
+ )
25
+ from gww.git.worktree import (
26
+ WorktreeDirtyError,
27
+ WorktreeNotFoundError,
28
+ find_worktree_by_branch,
29
+ remove_worktree,
30
+ )
31
+ from gww.template.functions import TemplateContext
32
+
33
+
34
+ @exit_on_error
35
+ def run_remove(ctx: CommandContext) -> int:
36
+ """Execute the remove worktree command.
37
+
38
+ Resolves the target worktree (by branch or absolute path), runs any
39
+ ``before_remove`` actions from project rules against the worktree, and
40
+ finally invokes ``git worktree remove``. A critical ``before_remove``
41
+ failure aborts before ``git worktree remove`` is called and exits with
42
+ code 1; a non-critical failure is reported but the remove proceeds.
43
+
44
+ Args:
45
+ ctx: Per-invocation command context.
46
+
47
+ Returns:
48
+ Exit code (0 for success, 1 for runtime/action failure, 2 for config
49
+ error).
50
+ """
51
+ if ctx.branch_or_path is None:
52
+ raise CommandExit(1, "Error: Missing branch or path.")
53
+
54
+ branch_or_path = ctx.branch_or_path
55
+ is_path = "/" in branch_or_path and Path(branch_or_path).is_absolute()
56
+
57
+ if is_path:
58
+ worktree_path = Path(branch_or_path).resolve()
59
+
60
+ try:
61
+ repo = detect_repository(worktree_path)
62
+ except NotGitRepositoryError as e:
63
+ raise CommandExit(1, f"Error: Not a git repository: {worktree_path}") from e
64
+
65
+ if not repo.is_worktree:
66
+ raise CommandExit(1, f"Error: Not a worktree: {worktree_path}")
67
+
68
+ try:
69
+ source_path = get_source_repository(worktree_path)
70
+ except (NotGitRepositoryError, GitCommandError) as e:
71
+ raise CommandExit(1, f"Error finding source repository: {e}") from e
72
+ else:
73
+ branch = branch_or_path
74
+ source_path = resolve_source_repo(Path.cwd())
75
+
76
+ wt = find_worktree_by_branch(source_path, branch)
77
+ if not wt:
78
+ raise CommandExit(1, f"Error: No worktree found for branch '{branch}'")
79
+ worktree_path = wt.path
80
+
81
+ config = load_config_or_exit()
82
+
83
+ # Resolve branch + URI for the template context.
84
+ if is_path:
85
+ branch = try_get_current_branch(worktree_path)
86
+ else:
87
+ branch = branch_or_path
88
+
89
+ try:
90
+ source_repo = detect_repository(source_path)
91
+ except NotGitRepositoryError:
92
+ remote_uri_str = None
93
+ else:
94
+ remote_uri_str = source_repo.remote_uri
95
+
96
+ uri = parse_uri_or_exit(remote_uri_str) if remote_uri_str else None
97
+
98
+ if ctx.verbose > 0:
99
+ if ctx.force:
100
+ ctx.verbose_msg(f"Force removing worktree: {worktree_path}...")
101
+ else:
102
+ ctx.verbose_msg(f"Removing worktree: {worktree_path}...")
103
+
104
+ failures: list[RuleFailure] = []
105
+ if config.actions:
106
+ context = TemplateContext(
107
+ uri=uri,
108
+ branch=branch,
109
+ source_path=source_path,
110
+ dest_path=worktree_path,
111
+ tags=ctx.tags,
112
+ )
113
+ try:
114
+ rule_bundles = apply_actions(
115
+ config.actions, context, kind="before_remove",
116
+ )
117
+ except MatcherError as e:
118
+ raise CommandExit(2, f"Config error: {e}") from e
119
+
120
+ if rule_bundles:
121
+ ctx.verbose_msg(f"Executing {len(rule_bundles)} rule(s)...")
122
+ for bundle in rule_bundles:
123
+ for action in bundle.actions:
124
+ try:
125
+ action.run(
126
+ source_dir=source_path,
127
+ target_dir=worktree_path,
128
+ pass_through_stdout=not ctx.quiet,
129
+ )
130
+ except ActionError as e:
131
+ failures.append(RuleFailure(bundle, action, e))
132
+ if bundle.critical:
133
+ break
134
+
135
+ if failures:
136
+ print_action_failure_summary(failures)
137
+
138
+ if any(f.bundle.critical for f in failures):
139
+ raise CommandExit(1, "")
140
+
141
+ try:
142
+ remove_worktree(
143
+ source_path,
144
+ worktree_path,
145
+ force=ctx.force,
146
+ pass_through_stdout=not ctx.quiet,
147
+ )
148
+ except (WorktreeNotFoundError, WorktreeDirtyError, GitCommandError) as e:
149
+ raise CommandExit(1, f"Error: {e}") from e
150
+
151
+ ctx.say(f"Removed worktree: {worktree_path}")
152
+
153
+ return 0