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.
17- Translate raw subprocess errors into a small, typed ``GitError``
18 hierarchy so callers never see ``FileNotFoundError`` or
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).
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
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.
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)
72from pathlib
import Path
73from typing
import Iterable, Optional
75from oct.core.diagnostics
import _dbg
77from oct.core.project_root
import is_within_project_root
87DEFAULT_GIT_TIMEOUT: int = 30
94_CRED_URL_RE: re.Pattern[str] = re.compile(
95 r"(?P<scheme>https?://)[^/@\s]+@",
105 """Base class for every error raised by :mod:`oct.core.git`.
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.
115 """Raised when the ``git`` binary is not found on ``PATH``."""
119 """Raised when a target path is not inside a git working tree."""
123 """Raised when a git subprocess exceeds its configured timeout."""
127 """Raised when ``git`` exits with a non-zero status.
129 Exposes ``returncode``, ``stdout``, and ``stderr`` attributes. Both
130 ``stdout`` and ``stderr`` are already credential-redacted.
147 f
"git command failed (exit {returncode}): "
148 f
"{' '.join(self.argv)!r}\nstderr: {stderr.strip() or '<empty>'}"
159 """Redact ``user:token@`` segments from http(s) URLs in git output.
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.
166 The substitution uses ``\\g<scheme>***@`` so the scheme is preserved
167 and the credential segment is replaced with a stable opaque marker.
169 func =
"_redact_git_url"
172 return _CRED_URL_RE.sub(
r"\g<scheme>***@", text)
176 """Return ``argv`` with credentials redacted in each URL-bearing element.
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.
196 timeout: int = DEFAULT_GIT_TIMEOUT,
198) -> subprocess.CompletedProcess[str]:
199 """Run a git command with the project's subprocess discipline.
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.
208 Working directory for the git invocation. Must exist.
210 Maximum seconds to wait before the subprocess is killed and
211 :class:`GitTimeoutError` is raised. Defaults to
212 :data:`DEFAULT_GIT_TIMEOUT`.
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
222 ``subprocess.CompletedProcess[str]``
223 A completed process whose ``stdout`` and ``stderr`` have already
224 been passed through :func:`_redact_git_url`.
229 If the ``git`` binary is not on ``PATH``.
231 If the subprocess exceeds ``timeout`` seconds.
233 If ``check`` is True and the command exits non-zero.
236 if not isinstance(argv, list):
237 raise GitError(f
"argv must be a list, got {type(argv).__name__}")
238 full_argv = [
"git", *argv]
240 _dbg(
"GIT", func, f
"argv={_redact_argv(full_argv)!r} cwd={cwd}", 2)
242 proc = subprocess.run(
252 except FileNotFoundError
as exc:
253 _dbg(
"GIT", func, f
"git binary not found: {exc}", 1, override=
True)
255 "The 'git' executable was not found on PATH. "
256 "Install git or adjust PATH."
258 except subprocess.TimeoutExpired
as exc:
264 f
"git timed out after {timeout}s: {' '.join(redacted_argv)!r}",
268 f
"git command timed out after {timeout}s: {' '.join(redacted_argv)!r}"
275 proc.stdout = redacted_out
276 proc.stderr = redacted_err
280 f
"returncode={proc.returncode} stdout_len={len(redacted_out)} "
281 f
"stderr_len={len(redacted_err)}",
285 if check
and proc.returncode != 0:
288 f
"non-zero exit: {proc.returncode} stderr={redacted_err.strip()!r}",
293 returncode=proc.returncode,
306 """Return True if ``path`` is inside a git working tree.
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.
314 _dbg(
"GIT", func, f
"path={path}", 3)
317 [
"rev-parse",
"--is-inside-work-tree"],
325 result = proc.returncode == 0
and proc.stdout.strip() ==
"true"
326 _dbg(
"GIT", func, f
"result={result}", 4)
331 """Return the absolute path of the top-level git repo containing ``path``.
333 Raises :class:`GitNotARepoError` if ``path`` is not inside a repo.
335 func =
"get_repo_root"
336 _dbg(
"GIT", func, f
"path={path}", 3)
338 [
"rev-parse",
"--show-toplevel"],
342 if proc.returncode != 0:
343 _dbg(
"GIT", func, f
"not a repo: {path}", 1, override=
True)
345 root = Path(proc.stdout.strip()).resolve()
346 _dbg(
"GIT", func, f
"root={root}", 4)
351 """Return the current branch name, or ``"HEAD"`` if detached.
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.
358 func =
"current_branch"
359 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
361 [
"rev-parse",
"--abbrev-ref",
"HEAD"],
364 branch = proc.stdout.strip()
365 _dbg(
"GIT", func, f
"branch={branch!r}", 4)
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)
377 """Return True if the working tree has staged or unstaged changes.
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``.
383 func =
"has_uncommitted_changes"
384 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
386 [
"status",
"--porcelain",
"--untracked-files=no"],
389 result = bool(proc.stdout.strip())
390 _dbg(
"GIT", func, f
"result={result}", 4)
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)
399 [
"ls-files",
"--others",
"--exclude-standard"],
402 result = bool(proc.stdout.strip())
403 _dbg(
"GIT", func, f
"result={result}", 4)
408 entries: Iterable[str],
411 extensions: Optional[set[str]],
413 """Resolve each entry against ``join_base`` and keep those under ``filter_base``.
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:
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.
431 When ``join_base == filter_base`` (a repo-root ``project_root``) the
432 behaviour is byte-identical to a plain join-against-root.
434 Deduplicates while preserving first-seen order.
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()
445 candidate = (join_base_resolved / rel).resolve()
446 if not is_within_project_root(candidate, filter_base_resolved):
449 f
"dropping out-of-scope/escape candidate: {rel!r} -> {candidate}",
453 if extensions
is not None and candidate.suffix
not in extensions:
455 if candidate
in seen:
458 result.append(candidate)
464 extensions: Optional[set[str]] =
None,
466 """Return absolute paths of files changed since the last commit.
468 The result is the union of:
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``).
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).
479 func =
"git_changed_files"
480 _dbg(
"GIT", func, f
"repo_root={repo_root} extensions={extensions}", 3)
485 entries: list[str] = []
487 [
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR"],
488 [
"diff",
"--name-only",
"--diff-filter=ACMR"],
489 [
"ls-files",
"--others",
"--exclude-standard"],
491 proc =
git_run(argv, cwd=Path(repo_root))
492 entries.extend(proc.stdout.splitlines())
494 _dbg(
"GIT", func, f
"returning {len(result)} path(s)", 4)
500 extensions: Optional[set[str]] =
None,
502 """Return absolute paths of files staged for the next commit.
504 Same contract as :func:`git_changed_files` but scoped to the index
505 only - untracked and unstaged-but-modified files are excluded.
507 func =
"git_staged_files"
508 _dbg(
"GIT", func, f
"repo_root={repo_root} extensions={extensions}", 3)
514 [
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR"],
517 entries = proc.stdout.splitlines()
519 _dbg(
"GIT", func, f
"returning {len(result)} path(s)", 4)
524 """Return absolute paths of files staged for DELETION in the next commit.
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.
535 This asks only the complementary question. The gate keeps inspecting
536 exactly what it inspected before.
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.
545 func =
"git_staged_deletions"
546 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
549 [
"diff",
"--cached",
"--name-only",
"--diff-filter=D"],
552 entries = proc.stdout.splitlines()
554 _dbg(
"GIT", func, f
"returning {len(result)} path(s)", 4)
559 """Return the SHA of HEAD, or the empty string on a fresh repo.
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`.
565 func =
"last_commit_sha"
566 _dbg(
"GIT", func, f
"repo_root={repo_root} short={short}", 3)
569 argv.append(
"--short")
571 proc =
git_run(argv, cwd=Path(repo_root), check=
False)
572 if proc.returncode != 0:
574 if "unknown revision" in proc.stderr
or "bad revision" in proc.stderr:
575 _dbg(
"GIT", func,
"no commits in repo", 4)
579 returncode=proc.returncode,
583 sha = proc.stdout.strip()
584 _dbg(
"GIT", func, f
"sha={sha}", 4)
589 """Return the subject + body of HEAD, or the empty string on fresh repos.
591 Uses ``git log -1 --pretty=%B`` so both the subject and the body are
592 returned together (trailing newline stripped).
594 func =
"last_commit_message"
595 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
597 [
"log",
"-1",
"--pretty=%B"],
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)
607 [
"git",
"log",
"-1",
"--pretty=%B"],
608 returncode=proc.returncode,
612 message = proc.stdout.rstrip(
"\n")
613 _dbg(
"GIT", func, f
"message_len={len(message)}", 4)
618 """Return a ``{remote_name: url}`` map, with credentials redacted.
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.
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():
636 name, url = parts[0], parts[1]
638 if name
not in remotes:
640 _dbg(
"GIT", func, f
"remotes={list(remotes.keys())}", 4)
645 """Return tag names sorted by commit date (newest first).
647 Uses ``git tag --sort=-creatordate``. Returns an empty list if no
648 tags exist or if the repo has no commits yet.
651 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
653 [
"tag",
"--sort=-creatordate"],
657 if proc.returncode != 0:
658 _dbg(
"GIT", func,
"no tags or git error", 4)
660 tags = [t.strip()
for t
in proc.stdout.splitlines()
if t.strip()]
661 _dbg(
"GIT", func, f
"returning {len(tags)} tag(s)", 4)
667 since_ref: str |
None =
None,
668 format_str: str =
"%H %s",
670 """Return git log lines from HEAD back to *since_ref* (exclusive).
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.
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:
685 _dbg(
"GIT", func,
"no log output", 4)
687 lines = [ln
for ln
in proc.stdout.splitlines()
if ln.strip()]
688 _dbg(
"GIT", func, f
"returning {len(lines)} line(s)", 4)
695 extensions: Optional[set[str]] =
None,
697 """Return absolute paths of files changed between *ref* and HEAD.
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.
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``.
712 func =
"git_diff_name_only"
713 _dbg(
"GIT", func, f
"repo_root={repo_root} ref={ref!r}", 3)
715 [
"diff",
"--name-only", ref],
719 if proc.returncode != 0:
720 _dbg(
"GIT", func,
"diff failed - returning empty", 4)
722 entries = proc.stdout.splitlines()
725 _dbg(
"GIT", func, f
"returning {len(result)} path(s)", 4)
730 """Return ``(ahead, behind)`` counts relative to the upstream branch.
732 Uses ``git rev-list --left-right --count HEAD...@{upstream}``.
733 Returns ``(0, 0)`` if no upstream is configured or on a fresh repo.
735 func =
"ahead_behind"
736 _dbg(
"GIT", func, f
"repo_root={repo_root}", 3)
738 [
"rev-list",
"--left-right",
"--count",
"HEAD...@{upstream}"],
742 if proc.returncode != 0:
743 _dbg(
"GIT", func,
"no upstream or error - returning (0, 0)", 4)
745 parts = proc.stdout.strip().split()
749 ahead = int(parts[0])
750 behind = int(parts[1])
753 _dbg(
"GIT", func, f
"ahead={ahead} behind={behind}", 4)
763 """Return True if a local branch *name* exists.
765 Uses ``git rev-parse --verify refs/heads/<name>`` with
766 ``check=False`` so it never raises on a missing branch.
768 func =
"branch_exists"
769 _dbg(
"GIT", func, f
"repo_root={repo_root} name={name!r}", 3)
771 [
"rev-parse",
"--verify", f
"refs/heads/{name}"],
775 result = proc.returncode == 0
776 _dbg(
"GIT", func, f
"result={result}", 4)
783 start_point: str |
None =
None,
785 """Create a new branch *name* pointing at *start_point* (default HEAD).
787 Does **not** switch to the new branch - use :func:`switch_branch` or
788 :func:`create_and_switch_branch` for that.
790 Raises :class:`GitCommandError` if the branch already exists or the
791 start-point is invalid.
793 func =
"create_branch"
794 _dbg(
"GIT", func, f
"name={name!r} start_point={start_point!r}", 3)
795 argv = [
"branch", name]
797 argv.append(start_point)
798 git_run(argv, cwd=Path(repo_root))
799 _dbg(
"GIT", func, f
"created branch {name!r}", 4)
803 """Switch the working tree to an existing branch *name*.
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).
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)
818 start_point: str |
None =
None,
820 """Atomically create and switch to a new branch *name*.
822 Equivalent to ``git checkout -b <name> [start_point]``. The index
823 and working tree are preserved - staged changes carry over to the
826 Raises :class:`GitCommandError` if the branch already exists.
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]
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)
838 """Return a list of local (or remote) branch names.
840 Uses ``git branch --format=%(refname:short)`` for clean output
841 without the ``*`` marker or whitespace prefix.
843 func =
"list_branches"
844 _dbg(
"GIT", func, f
"repo_root={repo_root} remote={remote}", 3)
845 argv = [
"branch",
"--format=%(refname:short)"]
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)
856 paths: list[Path] | list[str],
858 update: bool =
False,
861 """Stage *paths* for the next commit (whole-file staging).
863 Equivalent to ``git add [<paths>...]`` with optional ``-u`` (update
864 tracked only) or ``-A`` (all changes including untracked).
871 Paths to stage. May be empty when ``update`` or ``all_`` is set.
873 Pass ``-u`` so only already-tracked files are staged.
875 Pass ``-A`` so untracked files are also staged.
880 If git rejects the operation (e.g. path outside repo).
885 f
"repo_root={repo_root} paths={len(paths)} update={update} all={all_}",
888 argv: list[str] = [
"add"]
895 argv.extend(str(p)
for p
in paths)
896 elif not (update
or all_):
899 _dbg(
"GIT", func,
"no paths and no -u/-A; nothing to stage", 4)
901 git_run(argv, cwd=Path(repo_root))
902 _dbg(
"GIT", func,
"stage complete", 4)
907 paths: list[Path] | list[str],
909 """Remove *paths* from the index (unstage), leaving working tree intact.
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.
918 If git rejects the operation.
920 func =
"unstage_paths"
921 _dbg(
"GIT", func, f
"repo_root={repo_root} paths={len(paths)}", 3)
923 _dbg(
"GIT", func,
"no paths; nothing to unstage", 4)
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)
933 paths: list[Path] | list[str],
935 staged: bool =
False,
937 """Restore *paths* from the index (working tree) or HEAD (staged).
939 L3 / Phase 4G-followup. Equivalent to:
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.
949 If git rejects the operation.
951 func =
"restore_paths"
954 f
"repo_root={repo_root} paths={len(paths)} staged={staged}",
958 _dbg(
"GIT", func,
"no paths; nothing to restore", 4)
960 argv: list[str] = [
"restore"]
962 argv.append(
"--staged")
964 argv.extend(str(p)
for p
in paths)
965 git_run(argv, cwd=Path(repo_root))
966 _dbg(
"GIT", func,
"restore complete", 4)
971 paths: list[Path] | list[str],
973 """Run ``git add -p`` so the user can choose hunks interactively.
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.
986 Optional list of paths to scope the patch session to. When
987 empty, ``git`` walks every modified tracked file.
992 Process exit code from ``git add -p``. ``0`` on success.
997 If the ``git`` executable is not on PATH.
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"]
1004 argv.extend(str(p)
for p
in paths)
1005 if not isinstance(argv, list):
1007 f
"argv must be a list, got {type(argv).__name__}",
1010 proc = subprocess.run(
1016 except FileNotFoundError
as exc:
1017 _dbg(
"GIT", func, f
"git binary not found: {exc}", 1, override=
True)
1019 "The 'git' executable was not found on PATH. "
1020 "Install git or adjust PATH."
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)
tuple[int, int] ahead_behind(Path repo_root)
None stage_paths(Path repo_root, list[Path]|list[str] paths, *, bool update=False, bool all_=False)
bool is_detached_head(Path repo_root)
None restore_paths(Path repo_root, list[Path]|list[str] paths, *, bool staged=False)
bool branch_exists(Path repo_root, str name)
list[str] _redact_argv(list[str] argv)
None unstage_paths(Path repo_root, list[Path]|list[str] paths)
str _redact_git_url(str text)
list[str] list_branches(Path repo_root, *, bool remote=False)
bool is_git_repo(Path path)
list[Path] git_changed_files(Path repo_root, Optional[set[str]] extensions=None)
bool has_uncommitted_changes(Path repo_root)
dict[str, str] remote_urls(Path repo_root)
list[Path] git_staged_deletions(Path repo_root)
str current_branch(Path repo_root)
list[Path] _resolve_and_filter(Iterable[str] entries, Path join_base, Path filter_base, Optional[set[str]] extensions)
Path get_repo_root(Path path)
subprocess.CompletedProcess[str] git_run(list[str] argv, *, Path cwd, int timeout=DEFAULT_GIT_TIMEOUT, bool check=True)
list[Path] git_staged_files(Path repo_root, Optional[set[str]] extensions=None)
list[Path] git_diff_name_only(Path repo_root, str ref, Optional[set[str]] extensions=None)
list[str] list_tags(Path repo_root)
None switch_branch(Path repo_root, str name)
int stage_paths_interactive(Path repo_root, list[Path]|list[str] paths)
None create_branch(Path repo_root, str name, str|None start_point=None)
str last_commit_sha(Path repo_root, bool short=False)
list[str] get_commit_log(Path repo_root, str|None since_ref=None, str format_str="%H %s")
str last_commit_message(Path repo_root)
None create_and_switch_branch(Path repo_root, str name, str|None start_point=None)
bool has_untracked_files(Path repo_root)