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.
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
+ )