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/git/repository.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Git repository detection and operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitError(Exception):
|
|
12
|
+
"""Base exception for git-related errors."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotGitRepositoryError(GitError):
|
|
18
|
+
"""Raised when path is not a git repository."""
|
|
19
|
+
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitCommandError(GitError):
|
|
24
|
+
"""Raised when a git command fails."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Repository:
|
|
31
|
+
"""Represents a git repository (source or worktree).
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
path: Absolute path to repository root.
|
|
35
|
+
is_worktree: Whether this is a worktree (not main repository).
|
|
36
|
+
remote_uri: Remote origin URI (if available).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
path: Path
|
|
40
|
+
is_worktree: bool
|
|
41
|
+
remote_uri: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_git(
|
|
45
|
+
args: list[str],
|
|
46
|
+
cwd: Path,
|
|
47
|
+
check: bool = True,
|
|
48
|
+
pass_through_stdout: bool = False,
|
|
49
|
+
) -> subprocess.CompletedProcess[str]:
|
|
50
|
+
"""Run a git command.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
args: Git command arguments (without 'git').
|
|
54
|
+
cwd: Working directory for the command.
|
|
55
|
+
check: Whether to raise on non-zero exit.
|
|
56
|
+
pass_through_stdout: If ``True``, the subprocess inherits both stdout
|
|
57
|
+
and stderr from the parent so the user sees git's progress in real
|
|
58
|
+
time (this includes messages git writes to stderr such as
|
|
59
|
+
``Cloning into '…'`` and ``Preparing worktree (new branch 'X')``).
|
|
60
|
+
If ``False`` (default), both streams are captured — the historic
|
|
61
|
+
silent behavior used by quick internal checks. When streaming, the
|
|
62
|
+
embedded stderr in :class:`GitCommandError` will be empty on
|
|
63
|
+
failure because the user already saw it scroll past.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
CompletedProcess with command results.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
GitCommandError: If command fails and check=True.
|
|
70
|
+
"""
|
|
71
|
+
cmd = ["git"] + args
|
|
72
|
+
try:
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
cmd,
|
|
75
|
+
cwd=cwd,
|
|
76
|
+
stdout=None if pass_through_stdout else subprocess.PIPE,
|
|
77
|
+
stderr=None if pass_through_stdout else subprocess.PIPE,
|
|
78
|
+
text=True,
|
|
79
|
+
check=False,
|
|
80
|
+
)
|
|
81
|
+
if check and result.returncode != 0:
|
|
82
|
+
raise GitCommandError(
|
|
83
|
+
f"Git command failed: {' '.join(cmd)}\n"
|
|
84
|
+
f"Exit code: {result.returncode}\n"
|
|
85
|
+
f"Stderr: {result.stderr.strip()}"
|
|
86
|
+
)
|
|
87
|
+
return result
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
raise GitCommandError("Git is not installed or not in PATH")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_git_repository(path: Path) -> bool:
|
|
93
|
+
"""Check if a path is inside a git repository.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
path: Path to check.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
True if path is inside a git repository.
|
|
100
|
+
"""
|
|
101
|
+
if not path.exists():
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
result = _run_git(
|
|
105
|
+
["rev-parse", "--git-dir"],
|
|
106
|
+
cwd=path,
|
|
107
|
+
check=False,
|
|
108
|
+
)
|
|
109
|
+
return result.returncode == 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_repository_root(path: Path) -> Path:
|
|
113
|
+
"""Get the root directory of a git repository.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
path: Path inside the repository.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Absolute path to repository root.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
NotGitRepositoryError: If path is not in a git repository.
|
|
123
|
+
"""
|
|
124
|
+
if not path.exists():
|
|
125
|
+
raise NotGitRepositoryError(f"Path does not exist: {path}")
|
|
126
|
+
|
|
127
|
+
result = _run_git(
|
|
128
|
+
["rev-parse", "--show-toplevel"],
|
|
129
|
+
cwd=path,
|
|
130
|
+
check=False,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if result.returncode != 0:
|
|
134
|
+
raise NotGitRepositoryError(f"Not a git repository: {path}")
|
|
135
|
+
|
|
136
|
+
return Path(result.stdout.strip()).resolve()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_worktree(path: Path) -> bool:
|
|
140
|
+
"""Check if a path is a git worktree (not main repository).
|
|
141
|
+
|
|
142
|
+
A worktree has a .git file pointing to the main repository's
|
|
143
|
+
worktrees directory, while the main repository has a .git directory.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
path: Path to repository root.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
True if path is a worktree.
|
|
150
|
+
"""
|
|
151
|
+
git_path = path / ".git"
|
|
152
|
+
return git_path.is_file()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def is_submodule(path: Path) -> bool:
|
|
156
|
+
"""Check if a path is a git submodule (not a standalone repository).
|
|
157
|
+
|
|
158
|
+
A submodule has a .git file pointing to the parent's .git/modules/<name>/,
|
|
159
|
+
while a worktree has a .git file pointing to .git/worktrees/<name>/.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
path: Path to repository root (directory containing .git).
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
True if path is a submodule.
|
|
166
|
+
"""
|
|
167
|
+
git_path = path / ".git"
|
|
168
|
+
if not git_path.is_file():
|
|
169
|
+
return False
|
|
170
|
+
try:
|
|
171
|
+
content = git_path.read_text().strip()
|
|
172
|
+
except OSError:
|
|
173
|
+
return False
|
|
174
|
+
if not content.startswith("gitdir:"):
|
|
175
|
+
return False
|
|
176
|
+
gitdir = content.split(":", 1)[1].strip()
|
|
177
|
+
resolved = (path / gitdir).resolve()
|
|
178
|
+
# Submodules point to parent's .git/modules/<name>; worktrees point to .git/worktrees/<name>
|
|
179
|
+
path_str = str(resolved).replace("\\", "/")
|
|
180
|
+
return ".git/modules" in path_str
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def get_source_repository(worktree_path: Path) -> Path:
|
|
184
|
+
"""Get the source (main) repository for a worktree.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
worktree_path: Path to worktree.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Path to source repository.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
NotGitRepositoryError: If path is not a valid worktree.
|
|
194
|
+
GitCommandError: If git command fails.
|
|
195
|
+
"""
|
|
196
|
+
from gww.git.worktree import read_gitdir
|
|
197
|
+
|
|
198
|
+
repo_root = get_repository_root(worktree_path)
|
|
199
|
+
|
|
200
|
+
if not is_worktree(repo_root):
|
|
201
|
+
# Already at source repository
|
|
202
|
+
return repo_root
|
|
203
|
+
|
|
204
|
+
git_file = repo_root / ".git"
|
|
205
|
+
gitdir = read_gitdir(git_file)
|
|
206
|
+
if gitdir is None:
|
|
207
|
+
raise NotGitRepositoryError(f"Invalid .git file in worktree: {worktree_path}")
|
|
208
|
+
|
|
209
|
+
gitdir_path = Path(gitdir).resolve()
|
|
210
|
+
|
|
211
|
+
# Navigate from .git/worktrees/<name> to the repository root
|
|
212
|
+
# Structure: /repo/.git/worktrees/<name>
|
|
213
|
+
if gitdir_path.parent.name == "worktrees" and gitdir_path.parent.parent.name == ".git":
|
|
214
|
+
source_git = gitdir_path.parent.parent
|
|
215
|
+
return source_git.parent
|
|
216
|
+
|
|
217
|
+
raise NotGitRepositoryError(
|
|
218
|
+
f"Could not determine source repository for worktree: {worktree_path}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_remote_uri(repo_path: Path) -> Optional[str]:
|
|
223
|
+
"""Get the remote origin URI for a repository.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
repo_path: Path to repository.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Remote URI string, or None if not configured.
|
|
230
|
+
"""
|
|
231
|
+
result = _run_git(
|
|
232
|
+
["remote", "get-url", "origin"],
|
|
233
|
+
cwd=repo_path,
|
|
234
|
+
check=False,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if result.returncode != 0:
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
return result.stdout.strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_current_branch(repo_path: Path) -> str:
|
|
244
|
+
"""Get the current branch name.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
repo_path: Path to repository.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Branch name.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
GitCommandError: If command fails or HEAD is detached.
|
|
254
|
+
"""
|
|
255
|
+
result = _run_git(
|
|
256
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
257
|
+
cwd=repo_path,
|
|
258
|
+
check=True,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
branch = result.stdout.strip()
|
|
262
|
+
if branch == "HEAD":
|
|
263
|
+
raise GitCommandError("HEAD is detached, not on a branch")
|
|
264
|
+
|
|
265
|
+
return branch
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def try_get_current_branch(repo_path: Path) -> str:
|
|
269
|
+
"""Get the current branch name, returning ``""`` on failure.
|
|
270
|
+
|
|
271
|
+
Soft-fail variant of :func:`get_current_branch` for callers (notably
|
|
272
|
+
``clone``) that need a branch value to populate a template context but
|
|
273
|
+
must not abort when HEAD is detached or the repo is in an unexpected
|
|
274
|
+
state. Used so that predicates referencing ``branch()`` evaluate to a
|
|
275
|
+
defined but non-matching value rather than raising.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
repo_path: Path to repository.
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Branch name, or ``""`` if HEAD is detached or the git command fails.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
return get_current_branch(repo_path)
|
|
285
|
+
except GitCommandError:
|
|
286
|
+
return ""
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def is_clean(repo_path: Path) -> bool:
|
|
290
|
+
"""Check if repository has no uncommitted changes.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
repo_path: Path to repository.
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
True if repository is clean (no changes).
|
|
297
|
+
"""
|
|
298
|
+
result = _run_git(
|
|
299
|
+
["status", "--porcelain"],
|
|
300
|
+
cwd=repo_path,
|
|
301
|
+
check=False,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return not bool(result.stdout.strip())
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def get_current_commit(repo_path: Path) -> str:
|
|
308
|
+
"""Get the current commit hash.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
repo_path: Path to repository.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Full commit hash.
|
|
315
|
+
|
|
316
|
+
Raises:
|
|
317
|
+
GitCommandError: If command fails.
|
|
318
|
+
"""
|
|
319
|
+
result = _run_git(
|
|
320
|
+
["rev-parse", "HEAD"],
|
|
321
|
+
cwd=repo_path,
|
|
322
|
+
check=True,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return result.stdout.strip()
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def detect_repository(path: Path) -> Repository:
|
|
329
|
+
"""Detect repository type and properties at a path.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
path: Path to check.
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
Repository object with detected properties.
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
NotGitRepositoryError: If path is not a git repository.
|
|
339
|
+
"""
|
|
340
|
+
repo_root = get_repository_root(path)
|
|
341
|
+
is_wt = is_worktree(repo_root)
|
|
342
|
+
remote = get_remote_uri(repo_root)
|
|
343
|
+
|
|
344
|
+
return Repository(
|
|
345
|
+
path=repo_root,
|
|
346
|
+
is_worktree=is_wt,
|
|
347
|
+
remote_uri=remote,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def clone_repository(
|
|
352
|
+
uri: str,
|
|
353
|
+
target_path: Path,
|
|
354
|
+
pass_through_stdout: bool = False,
|
|
355
|
+
) -> Path:
|
|
356
|
+
"""Clone a repository to the specified path.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
uri: Repository URI to clone.
|
|
360
|
+
target_path: Path where repository should be cloned.
|
|
361
|
+
pass_through_stdout: Forwarded to :func:`_run_git`. When ``True``,
|
|
362
|
+
git's progress messages (``Cloning into '…'``, ``Receiving
|
|
363
|
+
objects: 100%``, …) stream to the parent process's stderr.
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
Path to cloned repository.
|
|
367
|
+
|
|
368
|
+
Raises:
|
|
369
|
+
GitCommandError: If clone fails.
|
|
370
|
+
"""
|
|
371
|
+
# Ensure parent directory exists
|
|
372
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
373
|
+
|
|
374
|
+
_run_git(
|
|
375
|
+
["clone", uri, str(target_path)],
|
|
376
|
+
cwd=target_path.parent,
|
|
377
|
+
check=True,
|
|
378
|
+
pass_through_stdout=pass_through_stdout,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
return target_path
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def pull_repository(
|
|
385
|
+
repo_path: Path,
|
|
386
|
+
pass_through_stdout: bool = False,
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Pull changes from remote.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
repo_path: Path to repository.
|
|
392
|
+
pass_through_stdout: Forwarded to :func:`_run_git`. When ``True``,
|
|
393
|
+
git's pull progress streams to the parent process's stdout.
|
|
394
|
+
|
|
395
|
+
Raises:
|
|
396
|
+
GitCommandError: If pull fails.
|
|
397
|
+
"""
|
|
398
|
+
_run_git(
|
|
399
|
+
["pull"],
|
|
400
|
+
cwd=repo_path,
|
|
401
|
+
check=True,
|
|
402
|
+
pass_through_stdout=pass_through_stdout,
|
|
403
|
+
)
|