oct
Loading...
Searching...
No Matches
git.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/core/git.py
4
5"""
6Purpose
7-------
8Provide the foundation layer for OCT's Git integration - a pure
9subprocess wrapper and a set of read-only state queries. No CLI, no
10writes, no hook installation. This module is the sole place where
11``subprocess.run`` is allowed to invoke the ``git`` binary; every
12higher-level ``oct git`` command (Phase 4B–4E) delegates through
13``git_run`` and the helpers below.
14
15Responsibilities
16----------------
17- Translate raw subprocess errors into a small, typed ``GitError``
18 hierarchy so callers never see ``FileNotFoundError`` or
19 ``TimeoutExpired``.
20- Enforce subprocess discipline: ``shell=False``, argv-only, explicit
21 timeout, ``text=True``, ``encoding="utf-8"``, ``errors="replace"``.
22- Redact ``user:token@`` segments from any git output that crosses a
23 diagnostics or exception boundary.
24- Provide pure read-only query functions for repo state (branch, HEAD,
25 changed files, staged files, remotes, last commit) each accepting an
26 explicit ``repo_root``.
27- Provide branch mutation primitives (create, switch, create-and-switch)
28 used by ``oct git branch`` and ``oct git commit`` auto-branching.
29- Provide staging primitives (stage, unstage, interactive patch staging)
30 used by ``oct git add``.
31- Filter path-escape attempts in ``git_changed_files`` / ``git_staged_files``
32 by dropping entries whose resolved target lies outside ``repo_root``
33 (OI-405 defense-in-depth).
34
35Diagnostics
36-----------
37Domain: GIT
38Levels:
39 L1 - errors: missing git binary, command failure, path escape
40 L2 - lifecycle: git_run entry/exit with redacted argv + returncode
41 L3 - command: each public query function's entry with arg summary
42 L4 - deep trace: parsed output, per-call timings
43
44Contracts
45---------
46- This module must not import ``click``.
47- Every subprocess invocation goes through :func:`git_run`.
48- ``is_git_repo`` is the only function that swallows all exceptions and
49 returns ``False``; every other public function raises from the
50 ``GitError`` hierarchy on failure.
51- Credential redaction is applied inside ``git_run`` - callers that
52 bubble exception state or log output never see raw credentials.
53- Read-only query functions never write to the repo.
54- Branch mutation functions (``create_branch``, ``switch_branch``,
55 ``create_and_switch_branch``) and staging functions (``stage_paths``,
56 ``unstage_paths``, ``stage_paths_interactive``) are the only write
57 operations and are clearly documented as such.
58- ``stage_paths_interactive`` is the only function that bypasses
59 :func:`git_run` because patch-mode staging requires an attached TTY
60 for the user to answer hunk prompts. It still uses ``shell=False``
61 and validates argv as a list.
62
63Dependencies
64------------
65- oct.core.diagnostics (_dbg structured logger; the shim falls back to a no-op when the optional oc_diagnostics package is absent)
66- oct.core.project_root (is_within_project_root, the containment predicate that drops path-escape entries)
67- git (not imported; the git binary, run as a subprocess by git_run and stage_paths_interactive)
68"""
69
70import re
71import subprocess
72from pathlib import Path
73from typing import Iterable, Optional
74
75from oct.core.diagnostics import _dbg
76
77from oct.core.project_root import is_within_project_root
78
79
80# ---------------------------------------------------------------------
81# Module constants
82# ---------------------------------------------------------------------
83
84#: Default timeout for read-only git queries, in seconds. Intentionally
85#: generous so that cold-cache operations on large repositories do not
86#: spuriously raise :class:`GitTimeoutError`.
87DEFAULT_GIT_TIMEOUT: int = 30
88
89#: Regex matching the credential segment of an http(s) URL. Captures the
90#: scheme in group ``scheme`` so we can substitute the credentials with
91#: ``***@`` while preserving the rest of the URL. Intentionally narrow
92#: (no backslashes, no spaces, no slashes, no ``@``) so we only match a
93#: ``user[:pass]@`` prefix and never a trailing ``@`` inside a path.
94_CRED_URL_RE: re.Pattern[str] = re.compile(
95 r"(?P<scheme>https?://)[^/@\s]+@",
96)
97
98
99# ---------------------------------------------------------------------
100# Exception hierarchy (G-A1)
101# ---------------------------------------------------------------------
102
103
104class GitError(Exception):
105 """Base class for every error raised by :mod:`oct.core.git`.
106
107 Callers that want a catch-all should catch ``GitError``; the
108 specific subclasses exist to distinguish "missing binary" from
109 "timeout" from "command failed" so that higher-level CLI commands
110 can render tailored error messages.
111 """
112
113
115 """Raised when the ``git`` binary is not found on ``PATH``."""
116
117
119 """Raised when a target path is not inside a git working tree."""
120
121
123 """Raised when a git subprocess exceeds its configured timeout."""
124
125
127 """Raised when ``git`` exits with a non-zero status.
128
129 Exposes ``returncode``, ``stdout``, and ``stderr`` attributes. Both
130 ``stdout`` and ``stderr`` are already credential-redacted.
131 """
132
134 self,
135 argv: list[str],
136 returncode: int,
137 stdout: str,
138 stderr: str,
139 ) -> None:
140 # OI-507: redact argv before storing or stringifying so neither the
141 # ``.argv`` attribute nor the exception message can leak credentials.
142 self.argv = _redact_argv(argv)
143 self.returncode = returncode
144 self.stdout = stdout
145 self.stderr = stderr
146 msg = (
147 f"git command failed (exit {returncode}): "
148 f"{' '.join(self.argv)!r}\nstderr: {stderr.strip() or '<empty>'}"
149 )
150 super().__init__(msg)
151
152
153# ---------------------------------------------------------------------
154# Credential redaction (G-A5)
155# ---------------------------------------------------------------------
156
157
158def _redact_git_url(text: str) -> str:
159 """Redact ``user:token@`` segments from http(s) URLs in git output.
160
161 Accepts any string (including multi-line output). Empty strings and
162 ``None`` are returned unchanged. SSH URLs of the form
163 ``git@host:path`` are credential-free (auth is key-based) and are
164 deliberately *not* matched by the regex - they pass through intact.
165
166 The substitution uses ``\\g<scheme>***@`` so the scheme is preserved
167 and the credential segment is replaced with a stable opaque marker.
168 """
169 func = "_redact_git_url"
170 if not text:
171 return text
172 return _CRED_URL_RE.sub(r"\g<scheme>***@", text)
173
174
175def _redact_argv(argv: list[str]) -> list[str]:
176 """Return ``argv`` with credentials redacted in each URL-bearing element.
177
178 OI-507 - ``git_run`` and ``GitCommandError`` both stringify argv for
179 ``_dbg`` and exception messages. Any ``https://user:token@host/...``
180 argument would otherwise land in logs and raised exceptions verbatim.
181 This helper is the single choke point that keeps the redaction
182 invariant (blueprint §11.2) centralised.
183 """
184 return [_redact_git_url(a) for a in argv]
185
186
187# ---------------------------------------------------------------------
188# Subprocess wrapper (G-A2)
189# ---------------------------------------------------------------------
190
191
193 argv: list[str],
194 *,
195 cwd: Path,
196 timeout: int = DEFAULT_GIT_TIMEOUT,
197 check: bool = True,
198) -> subprocess.CompletedProcess[str]:
199 """Run a git command with the project's subprocess discipline.
200
201 Parameters
202 ----------
203 argv
204 Git sub-arguments excluding the leading ``git``. For example,
205 ``["rev-parse", "--show-toplevel"]``. Must be a list; joined
206 strings are rejected to prevent shell injection.
207 cwd
208 Working directory for the git invocation. Must exist.
209 timeout
210 Maximum seconds to wait before the subprocess is killed and
211 :class:`GitTimeoutError` is raised. Defaults to
212 :data:`DEFAULT_GIT_TIMEOUT`.
213 check
214 When ``True`` (default), a non-zero exit code raises
215 :class:`GitCommandError`. When ``False``, the completed process
216 is returned with its ``returncode`` intact so callers can
217 inspect it (used by :func:`is_git_repo` to avoid exception
218 control flow).
219
220 Returns
221 -------
222 ``subprocess.CompletedProcess[str]``
223 A completed process whose ``stdout`` and ``stderr`` have already
224 been passed through :func:`_redact_git_url`.
225
226 Raises
227 ------
228 GitNotFoundError
229 If the ``git`` binary is not on ``PATH``.
230 GitTimeoutError
231 If the subprocess exceeds ``timeout`` seconds.
232 GitCommandError
233 If ``check`` is True and the command exits non-zero.
234 """
235 func = "git_run"
236 if not isinstance(argv, list):
237 raise GitError(f"argv must be a list, got {type(argv).__name__}")
238 full_argv = ["git", *argv]
239 # OI-507: redact argv before logging so token-bearing URLs don't land in _dbg.
240 _dbg("GIT", func, f"argv={_redact_argv(full_argv)!r} cwd={cwd}", 2)
241 try:
242 proc = subprocess.run(
243 full_argv,
244 cwd=str(cwd),
245 shell=False,
246 capture_output=True,
247 text=True,
248 encoding="utf-8",
249 errors="replace",
250 timeout=timeout,
251 )
252 except FileNotFoundError as exc:
253 _dbg("GIT", func, f"git binary not found: {exc}", 1, override=True)
254 raise GitNotFoundError(
255 "The 'git' executable was not found on PATH. "
256 "Install git or adjust PATH."
257 ) from exc
258 except subprocess.TimeoutExpired as exc:
259 # OI-507: both the _dbg line and the raised exception message are
260 # credential-safe via _redact_argv.
261 redacted_argv = _redact_argv(full_argv)
262 _dbg(
263 "GIT", func,
264 f"git timed out after {timeout}s: {' '.join(redacted_argv)!r}",
265 1, override=True,
266 )
267 raise GitTimeoutError(
268 f"git command timed out after {timeout}s: {' '.join(redacted_argv)!r}"
269 ) from exc
270
271 redacted_out = _redact_git_url(proc.stdout or "")
272 redacted_err = _redact_git_url(proc.stderr or "")
273 # Replace the captured streams with their redacted form so any
274 # caller that logs or re-raises cannot accidentally leak credentials.
275 proc.stdout = redacted_out
276 proc.stderr = redacted_err
277
278 _dbg(
279 "GIT", func,
280 f"returncode={proc.returncode} stdout_len={len(redacted_out)} "
281 f"stderr_len={len(redacted_err)}",
282 4,
283 )
284
285 if check and proc.returncode != 0:
286 _dbg(
287 "GIT", func,
288 f"non-zero exit: {proc.returncode} stderr={redacted_err.strip()!r}",
289 1, override=True,
290 )
291 raise GitCommandError(
292 full_argv,
293 returncode=proc.returncode,
294 stdout=redacted_out,
295 stderr=redacted_err,
296 )
297 return proc
298
299
300# ---------------------------------------------------------------------
301# State query functions (G-A3)
302# ---------------------------------------------------------------------
303
304
305def is_git_repo(path: Path) -> bool:
306 """Return True if ``path`` is inside a git working tree.
307
308 This is the only public function in the module that swallows every
309 exception - ``GitNotFoundError``, ``GitCommandError``, ``OSError``,
310 and anything else all collapse to ``False``. Use it as a cheap
311 predicate before calling any other query function.
312 """
313 func = "is_git_repo"
314 _dbg("GIT", func, f"path={path}", 3)
315 try:
316 proc = git_run(
317 ["rev-parse", "--is-inside-work-tree"],
318 cwd=Path(path),
319 check=False,
320 )
321 except GitError:
322 return False
323 except OSError:
324 return False
325 result = proc.returncode == 0 and proc.stdout.strip() == "true"
326 _dbg("GIT", func, f"result={result}", 4)
327 return result
328
329
330def get_repo_root(path: Path) -> Path:
331 """Return the absolute path of the top-level git repo containing ``path``.
332
333 Raises :class:`GitNotARepoError` if ``path`` is not inside a repo.
334 """
335 func = "get_repo_root"
336 _dbg("GIT", func, f"path={path}", 3)
337 proc = git_run(
338 ["rev-parse", "--show-toplevel"],
339 cwd=Path(path),
340 check=False,
341 )
342 if proc.returncode != 0:
343 _dbg("GIT", func, f"not a repo: {path}", 1, override=True)
344 raise GitNotARepoError(f"{path} is not inside a git repository.")
345 root = Path(proc.stdout.strip()).resolve()
346 _dbg("GIT", func, f"root={root}", 4)
347 return root
348
349
350def current_branch(repo_root: Path) -> str:
351 """Return the current branch name, or ``"HEAD"`` if detached.
352
353 Callers that need to distinguish "on a branch" from "detached"
354 should use :func:`is_detached_head` - this function returns the
355 literal string ``"HEAD"`` in the detached case, mirroring git's own
356 ``--abbrev-ref`` output.
357 """
358 func = "current_branch"
359 _dbg("GIT", func, f"repo_root={repo_root}", 3)
360 proc = git_run(
361 ["rev-parse", "--abbrev-ref", "HEAD"],
362 cwd=Path(repo_root),
363 )
364 branch = proc.stdout.strip()
365 _dbg("GIT", func, f"branch={branch!r}", 4)
366 return branch
367
368
369def is_detached_head(repo_root: Path) -> bool:
370 """Return True if HEAD is detached (not pointing at any branch)."""
371 func = "is_detached_head"
372 _dbg("GIT", func, f"repo_root={repo_root}", 3)
373 return current_branch(repo_root) == "HEAD"
374
375
376def has_uncommitted_changes(repo_root: Path) -> bool:
377 """Return True if the working tree has staged or unstaged changes.
378
379 Untracked files are ignored - use :func:`has_untracked_files` for
380 that. This function checks only tracked files via
381 ``git status --porcelain -- uno``.
382 """
383 func = "has_uncommitted_changes"
384 _dbg("GIT", func, f"repo_root={repo_root}", 3)
385 proc = git_run(
386 ["status", "--porcelain", "--untracked-files=no"],
387 cwd=Path(repo_root),
388 )
389 result = bool(proc.stdout.strip())
390 _dbg("GIT", func, f"result={result}", 4)
391 return result
392
393
394def has_untracked_files(repo_root: Path) -> bool:
395 """Return True if there are any untracked files not ignored by .gitignore."""
396 func = "has_untracked_files"
397 _dbg("GIT", func, f"repo_root={repo_root}", 3)
398 proc = git_run(
399 ["ls-files", "--others", "--exclude-standard"],
400 cwd=Path(repo_root),
401 )
402 result = bool(proc.stdout.strip())
403 _dbg("GIT", func, f"result={result}", 4)
404 return result
405
406
408 entries: Iterable[str],
409 join_base: Path,
410 filter_base: Path,
411 extensions: Optional[set[str]],
412) -> list[Path]:
413 """Resolve each entry against ``join_base`` and keep those under ``filter_base``.
414
415 Internal helper used by :func:`git_changed_files` and
416 :func:`git_staged_files`. Git emits ``diff``/``ls-files`` paths relative
417 to the **git top-level** (no ``--relative`` is passed), so entries must be
418 joined onto that top-level (``join_base``) - never onto a subproject
419 ``project_root``, which would double the subproject segment into a
420 non-existent path (Plan 174: ``<repo>/sub`` + ``sub/pkg/x.py`` ->
421 ``<repo>/sub/sub/pkg/x.py``). Every entry is:
422
423 1. Joined to ``join_base`` and ``.resolve()``-ed.
424 2. Kept only when :func:`is_within_project_root` places it inside
425 ``filter_base`` (the caller's ``project_root``). This single predicate
426 is both the symlink-escape guard AND the subproject scope filter: a
427 staged file in a *sibling* subproject resolves outside ``filter_base``
428 and is dropped with an L1 ``_dbg`` log, exactly like a symlink escape.
429 3. If ``extensions`` is given, filtered to those suffixes.
430
431 When ``join_base == filter_base`` (a repo-root ``project_root``) the
432 behaviour is byte-identical to a plain join-against-root.
433
434 Deduplicates while preserving first-seen order.
435 """
436 func = "_resolve_and_filter"
437 join_base_resolved = Path(join_base).resolve()
438 filter_base_resolved = Path(filter_base).resolve()
439 result: list[Path] = []
440 seen: set[Path] = set()
441 for rel in entries:
442 rel = rel.strip()
443 if not rel:
444 continue
445 candidate = (join_base_resolved / rel).resolve()
446 if not is_within_project_root(candidate, filter_base_resolved):
447 _dbg(
448 "GIT", func,
449 f"dropping out-of-scope/escape candidate: {rel!r} -> {candidate}",
450 1, override=True,
451 )
452 continue
453 if extensions is not None and candidate.suffix not in extensions:
454 continue
455 if candidate in seen:
456 continue
457 seen.add(candidate)
458 result.append(candidate)
459 return result
460
461
463 repo_root: Path,
464 extensions: Optional[set[str]] = None,
465) -> list[Path]:
466 """Return absolute paths of files changed since the last commit.
467
468 The result is the union of:
469
470 - Tracked files with staged changes (``git diff --cached --name-only``).
471 - Tracked files with unstaged changes (``git diff --name-only``).
472 - Untracked files honouring .gitignore (``git ls-files --others --exclude-standard``).
473
474 If ``extensions`` is supplied (e.g. ``{".py"}``), only files with
475 those suffixes are returned. Paths whose resolved target lies
476 outside ``repo_root`` are silently dropped (defense-in-depth against
477 symlink-escape attacks; each drop is logged at L1).
478 """
479 func = "git_changed_files"
480 _dbg("GIT", func, f"repo_root={repo_root} extensions={extensions}", 3)
481 # Plan 174: git emits top-relative paths, so join onto the git top-level
482 # and filter to ``repo_root``. ``get_repo_root`` raises GitNotARepoError
483 # (fails loudly) when ``repo_root`` is not inside a work tree.
484 top_level = get_repo_root(Path(repo_root))
485 entries: list[str] = []
486 for argv in (
487 ["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
488 ["diff", "--name-only", "--diff-filter=ACMR"],
489 ["ls-files", "--others", "--exclude-standard"],
490 ):
491 proc = git_run(argv, cwd=Path(repo_root))
492 entries.extend(proc.stdout.splitlines())
493 result = _resolve_and_filter(entries, top_level, Path(repo_root), extensions)
494 _dbg("GIT", func, f"returning {len(result)} path(s)", 4)
495 return result
496
497
499 repo_root: Path,
500 extensions: Optional[set[str]] = None,
501) -> list[Path]:
502 """Return absolute paths of files staged for the next commit.
503
504 Same contract as :func:`git_changed_files` but scoped to the index
505 only - untracked and unstaged-but-modified files are excluded.
506 """
507 func = "git_staged_files"
508 _dbg("GIT", func, f"repo_root={repo_root} extensions={extensions}", 3)
509 # Plan 174: git emits top-relative paths, so join onto the git top-level
510 # and filter to ``repo_root``. ``get_repo_root`` raises GitNotARepoError
511 # (fails loudly) when ``repo_root`` is not inside a work tree.
512 top_level = get_repo_root(Path(repo_root))
513 proc = git_run(
514 ["diff", "--cached", "--name-only", "--diff-filter=ACMR"],
515 cwd=Path(repo_root),
516 )
517 entries = proc.stdout.splitlines()
518 result = _resolve_and_filter(entries, top_level, Path(repo_root), extensions)
519 _dbg("GIT", func, f"returning {len(result)} path(s)", 4)
520 return result
521
522
523def git_staged_deletions(repo_root: Path) -> list[Path]:
524 """Return absolute paths of files staged for DELETION in the next commit.
525
526 :func:`git_staged_files` filters to ``ACMR`` deliberately: its callers lint,
527 format and secret-scan what it returns, and a file staged for deletion has
528 no content to check. That is the right question for "what must the gate
529 inspect" and the wrong one for "does this commit contain anything", and the
530 same list was answering both - so a staged set of pure deletions read as
531 empty and ``oct git commit`` refused it with exit 4, "Nothing to commit".
532 No deletion-only commit could be made through the tool at all: untracking a
533 generated artefact, removing a dead module, retiring a fixture.
534
535 This asks only the complementary question. The gate keeps inspecting
536 exactly what it inspected before.
537
538 Joining and scope filtering are :func:`git_staged_files`'s, so a deletion
539 staged in a sibling subproject is dropped by the same predicate. These paths
540 are normally absent from disk, which is correct and harmless: resolution
541 here is textual (``Path.resolve()`` is non-strict and
542 :func:`~oct.core.project_root.is_within_project_root` compares resolved
543 prefixes), so a removed file is still placed inside or outside the root.
544 """
545 func = "git_staged_deletions"
546 _dbg("GIT", func, f"repo_root={repo_root}", 3)
547 top_level = get_repo_root(Path(repo_root))
548 proc = git_run(
549 ["diff", "--cached", "--name-only", "--diff-filter=D"],
550 cwd=Path(repo_root),
551 )
552 entries = proc.stdout.splitlines()
553 result = _resolve_and_filter(entries, top_level, Path(repo_root), None)
554 _dbg("GIT", func, f"returning {len(result)} path(s)", 4)
555 return result
556
557
558def last_commit_sha(repo_root: Path, short: bool = False) -> str:
559 """Return the SHA of HEAD, or the empty string on a fresh repo.
560
561 A fresh repo is one where ``git rev-parse HEAD`` fails because no
562 commits exist yet. Any other failure still propagates as
563 :class:`GitCommandError`.
564 """
565 func = "last_commit_sha"
566 _dbg("GIT", func, f"repo_root={repo_root} short={short}", 3)
567 argv = ["rev-parse"]
568 if short:
569 argv.append("--short")
570 argv.append("HEAD")
571 proc = git_run(argv, cwd=Path(repo_root), check=False)
572 if proc.returncode != 0:
573 # Distinguish "no commits yet" from real failures.
574 if "unknown revision" in proc.stderr or "bad revision" in proc.stderr:
575 _dbg("GIT", func, "no commits in repo", 4)
576 return ""
577 raise GitCommandError(
578 ["git", *argv],
579 returncode=proc.returncode,
580 stdout=proc.stdout,
581 stderr=proc.stderr,
582 )
583 sha = proc.stdout.strip()
584 _dbg("GIT", func, f"sha={sha}", 4)
585 return sha
586
587
588def last_commit_message(repo_root: Path) -> str:
589 """Return the subject + body of HEAD, or the empty string on fresh repos.
590
591 Uses ``git log -1 --pretty=%B`` so both the subject and the body are
592 returned together (trailing newline stripped).
593 """
594 func = "last_commit_message"
595 _dbg("GIT", func, f"repo_root={repo_root}", 3)
596 proc = git_run(
597 ["log", "-1", "--pretty=%B"],
598 cwd=Path(repo_root),
599 check=False,
600 )
601 if proc.returncode != 0:
602 if "does not have any commits" in proc.stderr or \
603 "bad default revision" in proc.stderr:
604 _dbg("GIT", func, "no commits in repo", 4)
605 return ""
606 raise GitCommandError(
607 ["git", "log", "-1", "--pretty=%B"],
608 returncode=proc.returncode,
609 stdout=proc.stdout,
610 stderr=proc.stderr,
611 )
612 message = proc.stdout.rstrip("\n")
613 _dbg("GIT", func, f"message_len={len(message)}", 4)
614 return message
615
616
617def remote_urls(repo_root: Path) -> dict[str, str]:
618 """Return a ``{remote_name: url}`` map, with credentials redacted.
619
620 Uses ``git remote -v`` and collapses the fetch + push variants into
621 a single entry per remote (preferring ``fetch`` if both are
622 present). Returns an empty dict if no remotes are configured.
623 """
624 func = "remote_urls"
625 _dbg("GIT", func, f"repo_root={repo_root}", 3)
626 proc = git_run(["remote", "-v"], cwd=Path(repo_root))
627 remotes: dict[str, str] = {}
628 for line in proc.stdout.splitlines():
629 # Format: "<name>\t<url> (fetch|push)"
630 line = line.strip()
631 if not line:
632 continue
633 parts = line.split()
634 if len(parts) < 2:
635 continue
636 name, url = parts[0], parts[1]
637 # Prefer the first-seen URL per remote (git outputs fetch then push).
638 if name not in remotes:
639 remotes[name] = _redact_git_url(url)
640 _dbg("GIT", func, f"remotes={list(remotes.keys())}", 4)
641 return remotes
642
643
644def list_tags(repo_root: Path) -> list[str]:
645 """Return tag names sorted by commit date (newest first).
646
647 Uses ``git tag --sort=-creatordate``. Returns an empty list if no
648 tags exist or if the repo has no commits yet.
649 """
650 func = "list_tags"
651 _dbg("GIT", func, f"repo_root={repo_root}", 3)
652 proc = git_run(
653 ["tag", "--sort=-creatordate"],
654 cwd=Path(repo_root),
655 check=False,
656 )
657 if proc.returncode != 0:
658 _dbg("GIT", func, "no tags or git error", 4)
659 return []
660 tags = [t.strip() for t in proc.stdout.splitlines() if t.strip()]
661 _dbg("GIT", func, f"returning {len(tags)} tag(s)", 4)
662 return tags
663
664
666 repo_root: Path,
667 since_ref: str | None = None,
668 format_str: str = "%H %s",
669) -> list[str]:
670 """Return git log lines from HEAD back to *since_ref* (exclusive).
671
672 Each line is formatted according to *format_str* (passed to
673 ``git log --pretty=<format_str>``). If *since_ref* is ``None``,
674 the entire history is returned. Returns an empty list on fresh
675 repos with no commits.
676 """
677 func = "get_commit_log"
678 _dbg("GIT", func, f"repo_root={repo_root} since_ref={since_ref!r}", 3)
679 argv = ["log", f"--pretty={format_str}"]
680 if since_ref is not None:
681 argv.append(f"{since_ref}..HEAD")
682 proc = git_run(argv, cwd=Path(repo_root), check=False)
683 if proc.returncode != 0:
684 # Fresh repo or invalid ref - return empty.
685 _dbg("GIT", func, "no log output", 4)
686 return []
687 lines = [ln for ln in proc.stdout.splitlines() if ln.strip()]
688 _dbg("GIT", func, f"returning {len(lines)} line(s)", 4)
689 return lines
690
691
693 repo_root: Path,
694 ref: str,
695 extensions: Optional[set[str]] = None,
696) -> list[Path]:
697 """Return absolute paths of files changed between *ref* and HEAD.
698
699 Uses ``git diff --name-only <ref>`` and applies the same
700 resolve-and-filter pipeline as :func:`git_changed_files` to drop
701 path-escape attempts and optionally filter by extension.
702
703 The ``join_base`` argument was missing from the ``_resolve_and_filter`` call
704 here, so every invocation raised ``TypeError`` on arity - three arguments to
705 a four-parameter function. Nothing in the suite covered it, and its one
706 caller is ``oct export-source --diff-ref``, which was therefore broken
707 outright rather than subtly wrong. ``get_repo_root`` is resolved AFTER the
708 return-code check so a non-repository still returns empty quietly, as this
709 function's contract has always promised, instead of raising
710 ``GitNotARepoError``.
711 """
712 func = "git_diff_name_only"
713 _dbg("GIT", func, f"repo_root={repo_root} ref={ref!r}", 3)
714 proc = git_run(
715 ["diff", "--name-only", ref],
716 cwd=Path(repo_root),
717 check=False,
718 )
719 if proc.returncode != 0:
720 _dbg("GIT", func, "diff failed - returning empty", 4)
721 return []
722 entries = proc.stdout.splitlines()
723 top_level = get_repo_root(Path(repo_root))
724 result = _resolve_and_filter(entries, top_level, Path(repo_root), extensions)
725 _dbg("GIT", func, f"returning {len(result)} path(s)", 4)
726 return result
727
728
729def ahead_behind(repo_root: Path) -> tuple[int, int]:
730 """Return ``(ahead, behind)`` counts relative to the upstream branch.
731
732 Uses ``git rev-list --left-right --count HEAD...@{upstream}``.
733 Returns ``(0, 0)`` if no upstream is configured or on a fresh repo.
734 """
735 func = "ahead_behind"
736 _dbg("GIT", func, f"repo_root={repo_root}", 3)
737 proc = git_run(
738 ["rev-list", "--left-right", "--count", "HEAD...@{upstream}"],
739 cwd=Path(repo_root),
740 check=False,
741 )
742 if proc.returncode != 0:
743 _dbg("GIT", func, "no upstream or error - returning (0, 0)", 4)
744 return 0, 0
745 parts = proc.stdout.strip().split()
746 if len(parts) != 2:
747 return 0, 0
748 try:
749 ahead = int(parts[0])
750 behind = int(parts[1])
751 except ValueError:
752 return 0, 0
753 _dbg("GIT", func, f"ahead={ahead} behind={behind}", 4)
754 return ahead, behind
755
756
757# ---------------------------------------------------------------------
758# Branch mutation functions (G-F1)
759# ---------------------------------------------------------------------
760
761
762def branch_exists(repo_root: Path, name: str) -> bool:
763 """Return True if a local branch *name* exists.
764
765 Uses ``git rev-parse --verify refs/heads/<name>`` with
766 ``check=False`` so it never raises on a missing branch.
767 """
768 func = "branch_exists"
769 _dbg("GIT", func, f"repo_root={repo_root} name={name!r}", 3)
770 proc = git_run(
771 ["rev-parse", "--verify", f"refs/heads/{name}"],
772 cwd=Path(repo_root),
773 check=False,
774 )
775 result = proc.returncode == 0
776 _dbg("GIT", func, f"result={result}", 4)
777 return result
778
779
781 repo_root: Path,
782 name: str,
783 start_point: str | None = None,
784) -> None:
785 """Create a new branch *name* pointing at *start_point* (default HEAD).
786
787 Does **not** switch to the new branch - use :func:`switch_branch` or
788 :func:`create_and_switch_branch` for that.
789
790 Raises :class:`GitCommandError` if the branch already exists or the
791 start-point is invalid.
792 """
793 func = "create_branch"
794 _dbg("GIT", func, f"name={name!r} start_point={start_point!r}", 3)
795 argv = ["branch", name]
796 if start_point:
797 argv.append(start_point)
798 git_run(argv, cwd=Path(repo_root))
799 _dbg("GIT", func, f"created branch {name!r}", 4)
800
801
802def switch_branch(repo_root: Path, name: str) -> None:
803 """Switch the working tree to an existing branch *name*.
804
805 Uses ``git checkout`` for maximum git-version compatibility.
806 Raises :class:`GitCommandError` if the branch does not exist or
807 the checkout fails (e.g. uncommitted changes would be overwritten).
808 """
809 func = "switch_branch"
810 _dbg("GIT", func, f"name={name!r}", 3)
811 git_run(["checkout", name], cwd=Path(repo_root))
812 _dbg("GIT", func, f"switched to {name!r}", 4)
813
814
816 repo_root: Path,
817 name: str,
818 start_point: str | None = None,
819) -> None:
820 """Atomically create and switch to a new branch *name*.
821
822 Equivalent to ``git checkout -b <name> [start_point]``. The index
823 and working tree are preserved - staged changes carry over to the
824 new branch.
825
826 Raises :class:`GitCommandError` if the branch already exists.
827 """
828 func = "create_and_switch_branch"
829 _dbg("GIT", func, f"name={name!r} start_point={start_point!r}", 3)
830 argv = ["checkout", "-b", name]
831 if start_point:
832 argv.append(start_point)
833 git_run(argv, cwd=Path(repo_root))
834 _dbg("GIT", func, f"created and switched to {name!r}", 4)
835
836
837def list_branches(repo_root: Path, *, remote: bool = False) -> list[str]:
838 """Return a list of local (or remote) branch names.
839
840 Uses ``git branch --format=%(refname:short)`` for clean output
841 without the ``*`` marker or whitespace prefix.
842 """
843 func = "list_branches"
844 _dbg("GIT", func, f"repo_root={repo_root} remote={remote}", 3)
845 argv = ["branch", "--format=%(refname:short)"]
846 if remote:
847 argv.insert(1, "-r")
848 proc = git_run(argv, cwd=Path(repo_root))
849 branches = [b.strip() for b in proc.stdout.splitlines() if b.strip()]
850 _dbg("GIT", func, f"returning {len(branches)} branch(es)", 4)
851 return branches
852
853
855 repo_root: Path,
856 paths: list[Path] | list[str],
857 *,
858 update: bool = False,
859 all_: bool = False,
860) -> None:
861 """Stage *paths* for the next commit (whole-file staging).
862
863 Equivalent to ``git add [<paths>...]`` with optional ``-u`` (update
864 tracked only) or ``-A`` (all changes including untracked).
865
866 Parameters
867 ----------
868 repo_root
869 Git repository root.
870 paths
871 Paths to stage. May be empty when ``update`` or ``all_`` is set.
872 update
873 Pass ``-u`` so only already-tracked files are staged.
874 all_
875 Pass ``-A`` so untracked files are also staged.
876
877 Raises
878 ------
879 GitCommandError
880 If git rejects the operation (e.g. path outside repo).
881 """
882 func = "stage_paths"
883 _dbg(
884 "GIT", func,
885 f"repo_root={repo_root} paths={len(paths)} update={update} all={all_}",
886 3,
887 )
888 argv: list[str] = ["add"]
889 if update:
890 argv.append("-u")
891 if all_:
892 argv.append("-A")
893 if paths:
894 argv.append("--")
895 argv.extend(str(p) for p in paths)
896 elif not (update or all_):
897 # Nothing to do; refuse silently rather than running ``git add``
898 # with no arguments which is a no-op in modern git but warns.
899 _dbg("GIT", func, "no paths and no -u/-A; nothing to stage", 4)
900 return
901 git_run(argv, cwd=Path(repo_root))
902 _dbg("GIT", func, "stage complete", 4)
903
904
906 repo_root: Path,
907 paths: list[Path] | list[str],
908) -> None:
909 """Remove *paths* from the index (unstage), leaving working tree intact.
910
911 Equivalent to ``git reset HEAD -- <paths>``. Provided for symmetry
912 with :func:`stage_paths`; not currently exposed via CLI but used in
913 tests and reserved for a future ``oct git unstage`` command.
914
915 Raises
916 ------
917 GitCommandError
918 If git rejects the operation.
919 """
920 func = "unstage_paths"
921 _dbg("GIT", func, f"repo_root={repo_root} paths={len(paths)}", 3)
922 if not paths:
923 _dbg("GIT", func, "no paths; nothing to unstage", 4)
924 return
925 argv: list[str] = ["reset", "HEAD", "--"]
926 argv.extend(str(p) for p in paths)
927 git_run(argv, cwd=Path(repo_root))
928 _dbg("GIT", func, "unstage complete", 4)
929
930
932 repo_root: Path,
933 paths: list[Path] | list[str],
934 *,
935 staged: bool = False,
936) -> None:
937 """Restore *paths* from the index (working tree) or HEAD (staged).
938
939 L3 / Phase 4G-followup. Equivalent to:
940
941 - ``staged=False``: ``git restore <paths>`` - drop unstaged
942 working-tree edits, restoring from the index.
943 - ``staged=True``: ``git restore --staged <paths>`` - unstage
944 *paths* without touching the working tree.
945
946 Raises
947 ------
948 GitCommandError
949 If git rejects the operation.
950 """
951 func = "restore_paths"
952 _dbg(
953 "GIT", func,
954 f"repo_root={repo_root} paths={len(paths)} staged={staged}",
955 3,
956 )
957 if not paths:
958 _dbg("GIT", func, "no paths; nothing to restore", 4)
959 return
960 argv: list[str] = ["restore"]
961 if staged:
962 argv.append("--staged")
963 argv.append("--")
964 argv.extend(str(p) for p in paths)
965 git_run(argv, cwd=Path(repo_root))
966 _dbg("GIT", func, "restore complete", 4)
967
968
970 repo_root: Path,
971 paths: list[Path] | list[str],
972) -> int:
973 """Run ``git add -p`` so the user can choose hunks interactively.
974
975 This is the only public function in this module that bypasses
976 :func:`git_run`. Patch-mode staging requires an attached TTY so the
977 user can answer hunk prompts (``y/n/s/...``); ``git_run`` always
978 captures stdout/stderr which would break that interaction. The
979 bypass still uses ``shell=False`` and validates argv.
980
981 Parameters
982 ----------
983 repo_root
984 Git repository root.
985 paths
986 Optional list of paths to scope the patch session to. When
987 empty, ``git`` walks every modified tracked file.
988
989 Returns
990 -------
991 int
992 Process exit code from ``git add -p``. ``0`` on success.
993
994 Raises
995 ------
996 GitNotFoundError
997 If the ``git`` executable is not on PATH.
998 """
999 func = "stage_paths_interactive"
1000 _dbg("GIT", func, f"repo_root={repo_root} paths={len(paths)}", 3)
1001 argv: list[str] = ["git", "add", "-p"]
1002 if paths:
1003 argv.append("--")
1004 argv.extend(str(p) for p in paths)
1005 if not isinstance(argv, list):
1006 raise GitError(
1007 f"argv must be a list, got {type(argv).__name__}",
1008 )
1009 try:
1010 proc = subprocess.run(
1011 argv,
1012 cwd=str(repo_root),
1013 shell=False,
1014 check=False,
1015 )
1016 except FileNotFoundError as exc:
1017 _dbg("GIT", func, f"git binary not found: {exc}", 1, override=True)
1018 raise GitNotFoundError(
1019 "The 'git' executable was not found on PATH. "
1020 "Install git or adjust PATH."
1021 ) from exc
1022 _dbg("GIT", func, f"interactive stage exit={proc.returncode}", 4)
1023 return proc.returncode
None __init__(self, list[str] argv, int returncode, str stdout, str stderr)
Definition git.py:139
tuple[int, int] ahead_behind(Path repo_root)
Definition git.py:729
None stage_paths(Path repo_root, list[Path]|list[str] paths, *, bool update=False, bool all_=False)
Definition git.py:860
bool is_detached_head(Path repo_root)
Definition git.py:369
None restore_paths(Path repo_root, list[Path]|list[str] paths, *, bool staged=False)
Definition git.py:936
bool branch_exists(Path repo_root, str name)
Definition git.py:762
list[str] _redact_argv(list[str] argv)
Definition git.py:175
None unstage_paths(Path repo_root, list[Path]|list[str] paths)
Definition git.py:908
str _redact_git_url(str text)
Definition git.py:158
list[str] list_branches(Path repo_root, *, bool remote=False)
Definition git.py:837
bool is_git_repo(Path path)
Definition git.py:305
list[Path] git_changed_files(Path repo_root, Optional[set[str]] extensions=None)
Definition git.py:465
bool has_uncommitted_changes(Path repo_root)
Definition git.py:376
dict[str, str] remote_urls(Path repo_root)
Definition git.py:617
list[Path] git_staged_deletions(Path repo_root)
Definition git.py:523
str current_branch(Path repo_root)
Definition git.py:350
list[Path] _resolve_and_filter(Iterable[str] entries, Path join_base, Path filter_base, Optional[set[str]] extensions)
Definition git.py:412
Path get_repo_root(Path path)
Definition git.py:330
subprocess.CompletedProcess[str] git_run(list[str] argv, *, Path cwd, int timeout=DEFAULT_GIT_TIMEOUT, bool check=True)
Definition git.py:198
list[Path] git_staged_files(Path repo_root, Optional[set[str]] extensions=None)
Definition git.py:501
list[Path] git_diff_name_only(Path repo_root, str ref, Optional[set[str]] extensions=None)
Definition git.py:696
list[str] list_tags(Path repo_root)
Definition git.py:644
None switch_branch(Path repo_root, str name)
Definition git.py:802
int stage_paths_interactive(Path repo_root, list[Path]|list[str] paths)
Definition git.py:972
None create_branch(Path repo_root, str name, str|None start_point=None)
Definition git.py:784
str last_commit_sha(Path repo_root, bool short=False)
Definition git.py:558
list[str] get_commit_log(Path repo_root, str|None since_ref=None, str format_str="%H %s")
Definition git.py:669
str last_commit_message(Path repo_root)
Definition git.py:588
None create_and_switch_branch(Path repo_root, str name, str|None start_point=None)
Definition git.py:819
bool has_untracked_files(Path repo_root)
Definition git.py:394