git-worktree-wrapper 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_worktree_wrapper-0.1.0.dist-info/METADATA +473 -0
- git_worktree_wrapper-0.1.0.dist-info/RECORD +35 -0
- git_worktree_wrapper-0.1.0.dist-info/WHEEL +4 -0
- git_worktree_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- gww/__init__.py +3 -0
- gww/actions/__init__.py +224 -0
- gww/actions/types.py +187 -0
- gww/cli/__init__.py +1 -0
- gww/cli/commands/__init__.py +1 -0
- gww/cli/commands/add.py +122 -0
- gww/cli/commands/clone.py +97 -0
- gww/cli/commands/init.py +147 -0
- gww/cli/commands/migrate.py +81 -0
- gww/cli/commands/pull.py +62 -0
- gww/cli/commands/remove.py +153 -0
- gww/cli/context.py +382 -0
- gww/cli/main.py +285 -0
- gww/config/__init__.py +1 -0
- gww/config/loader.py +305 -0
- gww/config/resolver.py +188 -0
- gww/config/validator.py +344 -0
- gww/git/__init__.py +1 -0
- gww/git/branch.py +264 -0
- gww/git/repository.py +403 -0
- gww/git/worktree.py +395 -0
- gww/migration/__init__.py +44 -0
- gww/migration/executor.py +342 -0
- gww/migration/planner.py +260 -0
- gww/template/__init__.py +1 -0
- gww/template/evaluator.py +281 -0
- gww/template/functions.py +378 -0
- gww/utils/__init__.py +1 -0
- gww/utils/shell.py +894 -0
- gww/utils/uri.py +171 -0
- gww/utils/xdg.py +71 -0
gww/cli/commands/init.py
ADDED
|
@@ -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
|
+
)
|
gww/cli/commands/pull.py
ADDED
|
@@ -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
|