|
oct
|
Classes | |
| class | GitError |
| class | GitNotFoundError |
| class | GitNotARepoError |
| class | GitTimeoutError |
| class | GitCommandError |
Functions | |
| str | _redact_git_url (str text) |
| list[str] | _redact_argv (list[str] argv) |
| subprocess.CompletedProcess[str] | git_run (list[str] argv, *, Path cwd, int timeout=DEFAULT_GIT_TIMEOUT, bool check=True) |
| bool | is_git_repo (Path path) |
| Path | get_repo_root (Path path) |
| str | current_branch (Path repo_root) |
| bool | is_detached_head (Path repo_root) |
| bool | has_uncommitted_changes (Path repo_root) |
| bool | has_untracked_files (Path repo_root) |
| list[Path] | _resolve_and_filter (Iterable[str] entries, Path join_base, Path filter_base, Optional[set[str]] extensions) |
| list[Path] | git_changed_files (Path repo_root, Optional[set[str]] extensions=None) |
| list[Path] | git_staged_files (Path repo_root, Optional[set[str]] extensions=None) |
| list[Path] | git_staged_deletions (Path repo_root) |
| str | last_commit_sha (Path repo_root, bool short=False) |
| str | last_commit_message (Path repo_root) |
| dict[str, str] | remote_urls (Path repo_root) |
| list[str] | list_tags (Path repo_root) |
| list[str] | get_commit_log (Path repo_root, str|None since_ref=None, str format_str="%H %s") |
| list[Path] | git_diff_name_only (Path repo_root, str ref, Optional[set[str]] extensions=None) |
| tuple[int, int] | ahead_behind (Path repo_root) |
| bool | branch_exists (Path repo_root, str name) |
| None | create_branch (Path repo_root, str name, str|None start_point=None) |
| None | switch_branch (Path repo_root, str name) |
| None | create_and_switch_branch (Path repo_root, str name, str|None start_point=None) |
| list[str] | list_branches (Path repo_root, *, bool remote=False) |
| None | stage_paths (Path repo_root, list[Path]|list[str] paths, *, bool update=False, bool all_=False) |
| None | unstage_paths (Path repo_root, list[Path]|list[str] paths) |
| None | restore_paths (Path repo_root, list[Path]|list[str] paths, *, bool staged=False) |
| int | stage_paths_interactive (Path repo_root, list[Path]|list[str] paths) |
Variables | |
| int | DEFAULT_GIT_TIMEOUT = 30 |
| re | _CRED_URL_RE |
Purpose
-------
Provide the foundation layer for OCT's Git integration - a pure
subprocess wrapper and a set of read-only state queries. No CLI, no
writes, no hook installation. This module is the sole place where
``subprocess.run`` is allowed to invoke the ``git`` binary; every
higher-level ``oct git`` command (Phase 4B–4E) delegates through
``git_run`` and the helpers below.
Responsibilities
----------------
- Translate raw subprocess errors into a small, typed ``GitError``
hierarchy so callers never see ``FileNotFoundError`` or
``TimeoutExpired``.
- Enforce subprocess discipline: ``shell=False``, argv-only, explicit
timeout, ``text=True``, ``encoding="utf-8"``, ``errors="replace"``.
- Redact ``user:token@`` segments from any git output that crosses a
diagnostics or exception boundary.
- Provide pure read-only query functions for repo state (branch, HEAD,
changed files, staged files, remotes, last commit) each accepting an
explicit ``repo_root``.
- Provide branch mutation primitives (create, switch, create-and-switch)
used by ``oct git branch`` and ``oct git commit`` auto-branching.
- Provide staging primitives (stage, unstage, interactive patch staging)
used by ``oct git add``.
- Filter path-escape attempts in ``git_changed_files`` / ``git_staged_files``
by dropping entries whose resolved target lies outside ``repo_root``
(OI-405 defense-in-depth).
Diagnostics
-----------
Domain: GIT
Levels:
L1 - errors: missing git binary, command failure, path escape
L2 - lifecycle: git_run entry/exit with redacted argv + returncode
L3 - command: each public query function's entry with arg summary
L4 - deep trace: parsed output, per-call timings
Contracts
---------
- This module must not import ``click``.
- Every subprocess invocation goes through :func:`git_run`.
- ``is_git_repo`` is the only function that swallows all exceptions and
returns ``False``; every other public function raises from the
``GitError`` hierarchy on failure.
- Credential redaction is applied inside ``git_run`` - callers that
bubble exception state or log output never see raw credentials.
- Read-only query functions never write to the repo.
- Branch mutation functions (``create_branch``, ``switch_branch``,
``create_and_switch_branch``) and staging functions (``stage_paths``,
``unstage_paths``, ``stage_paths_interactive``) are the only write
operations and are clearly documented as such.
- ``stage_paths_interactive`` is the only function that bypasses
:func:`git_run` because patch-mode staging requires an attached TTY
for the user to answer hunk prompts. It still uses ``shell=False``
and validates argv as a list.
Dependencies
------------
- oct.core.diagnostics (_dbg structured logger; the shim falls back to a no-op when the optional oc_diagnostics package is absent)
- oct.core.project_root (is_within_project_root, the containment predicate that drops path-escape entries)
- git (not imported; the git binary, run as a subprocess by git_run and stage_paths_interactive)
|
protected |
Return ``argv`` with credentials redacted in each URL-bearing element. OI-507 - ``git_run`` and ``GitCommandError`` both stringify argv for ``_dbg`` and exception messages. Any ``https://user:token@host/...`` argument would otherwise land in logs and raised exceptions verbatim. This helper is the single choke point that keeps the redaction invariant (blueprint §11.2) centralised.
Definition at line 175 of file git.py.
|
protected |
Redact ``user:token@`` segments from http(s) URLs in git output. Accepts any string (including multi-line output). Empty strings and ``None`` are returned unchanged. SSH URLs of the form ``git@host:path`` are credential-free (auth is key-based) and are deliberately *not* matched by the regex - they pass through intact. The substitution uses ``\\g<scheme>***@`` so the scheme is preserved and the credential segment is replaced with a stable opaque marker.
Definition at line 158 of file git.py.
|
protected |
Resolve each entry against ``join_base`` and keep those under ``filter_base``. Internal helper used by :func:`git_changed_files` and :func:`git_staged_files`. Git emits ``diff``/``ls-files`` paths relative to the **git top-level** (no ``--relative`` is passed), so entries must be joined onto that top-level (``join_base``) - never onto a subproject ``project_root``, which would double the subproject segment into a non-existent path (Plan 174: ``<repo>/sub`` + ``sub/pkg/x.py`` -> ``<repo>/sub/sub/pkg/x.py``). Every entry is: 1. Joined to ``join_base`` and ``.resolve()``-ed. 2. Kept only when :func:`is_within_project_root` places it inside ``filter_base`` (the caller's ``project_root``). This single predicate is both the symlink-escape guard AND the subproject scope filter: a staged file in a *sibling* subproject resolves outside ``filter_base`` and is dropped with an L1 ``_dbg`` log, exactly like a symlink escape. 3. If ``extensions`` is given, filtered to those suffixes. When ``join_base == filter_base`` (a repo-root ``project_root``) the behaviour is byte-identical to a plain join-against-root. Deduplicates while preserving first-seen order.
Definition at line 407 of file git.py.
| tuple[int, int] git.ahead_behind | ( | Path | repo_root | ) |
| bool git.branch_exists | ( | Path | repo_root, |
| str | name ) |
| None git.create_and_switch_branch | ( | Path | repo_root, |
| str | name, | ||
| str | None | start_point = None ) |
Atomically create and switch to a new branch *name*. Equivalent to ``git checkout -b <name> [start_point]``. The index and working tree are preserved - staged changes carry over to the new branch. Raises :class:`GitCommandError` if the branch already exists.
Definition at line 815 of file git.py.
| None git.create_branch | ( | Path | repo_root, |
| str | name, | ||
| str | None | start_point = None ) |
Create a new branch *name* pointing at *start_point* (default HEAD). Does **not** switch to the new branch - use :func:`switch_branch` or :func:`create_and_switch_branch` for that. Raises :class:`GitCommandError` if the branch already exists or the start-point is invalid.
Definition at line 780 of file git.py.
| str git.current_branch | ( | Path | repo_root | ) |
Return the current branch name, or ``"HEAD"`` if detached. Callers that need to distinguish "on a branch" from "detached" should use :func:`is_detached_head` - this function returns the literal string ``"HEAD"`` in the detached case, mirroring git's own ``--abbrev-ref`` output.
Definition at line 350 of file git.py.
| list[str] git.get_commit_log | ( | Path | repo_root, |
| str | None | since_ref = None, | ||
| str | format_str = "%H %s" ) |
Return git log lines from HEAD back to *since_ref* (exclusive). Each line is formatted according to *format_str* (passed to ``git log --pretty=<format_str>``). If *since_ref* is ``None``, the entire history is returned. Returns an empty list on fresh repos with no commits.
Definition at line 665 of file git.py.
| Path git.get_repo_root | ( | Path | path | ) |
| list[Path] git.git_changed_files | ( | Path | repo_root, |
| Optional[set[str]] | extensions = None ) |
Return absolute paths of files changed since the last commit.
The result is the union of:
- Tracked files with staged changes (``git diff --cached --name-only``).
- Tracked files with unstaged changes (``git diff --name-only``).
- Untracked files honouring .gitignore (``git ls-files --others --exclude-standard``).
If ``extensions`` is supplied (e.g. ``{".py"}``), only files with
those suffixes are returned. Paths whose resolved target lies
outside ``repo_root`` are silently dropped (defense-in-depth against
symlink-escape attacks; each drop is logged at L1).
Definition at line 462 of file git.py.
| list[Path] git.git_diff_name_only | ( | Path | repo_root, |
| str | ref, | ||
| Optional[set[str]] | extensions = None ) |
Return absolute paths of files changed between *ref* and HEAD. Uses ``git diff --name-only <ref>`` and applies the same resolve-and-filter pipeline as :func:`git_changed_files` to drop path-escape attempts and optionally filter by extension. The ``join_base`` argument was missing from the ``_resolve_and_filter`` call here, so every invocation raised ``TypeError`` on arity - three arguments to a four-parameter function. Nothing in the suite covered it, and its one caller is ``oct export-source --diff-ref``, which was therefore broken outright rather than subtly wrong. ``get_repo_root`` is resolved AFTER the return-code check so a non-repository still returns empty quietly, as this function's contract has always promised, instead of raising ``GitNotARepoError``.
Definition at line 692 of file git.py.
| subprocess.CompletedProcess[str] git.git_run | ( | list[str] | argv, |
| * | , | ||
| Path | cwd, | ||
| int | timeout = DEFAULT_GIT_TIMEOUT, | ||
| bool | check = True ) |
Run a git command with the project's subprocess discipline.
Parameters
----------
argv
Git sub-arguments excluding the leading ``git``. For example,
``["rev-parse", "--show-toplevel"]``. Must be a list; joined
strings are rejected to prevent shell injection.
cwd
Working directory for the git invocation. Must exist.
timeout
Maximum seconds to wait before the subprocess is killed and
:class:`GitTimeoutError` is raised. Defaults to
:data:`DEFAULT_GIT_TIMEOUT`.
check
When ``True`` (default), a non-zero exit code raises
:class:`GitCommandError`. When ``False``, the completed process
is returned with its ``returncode`` intact so callers can
inspect it (used by :func:`is_git_repo` to avoid exception
control flow).
Returns
-------
``subprocess.CompletedProcess[str]``
A completed process whose ``stdout`` and ``stderr`` have already
been passed through :func:`_redact_git_url`.
Raises
------
GitNotFoundError
If the ``git`` binary is not on ``PATH``.
GitTimeoutError
If the subprocess exceeds ``timeout`` seconds.
GitCommandError
If ``check`` is True and the command exits non-zero.
Definition at line 192 of file git.py.
| list[Path] git.git_staged_deletions | ( | Path | repo_root | ) |
Return absolute paths of files staged for DELETION in the next commit. :func:`git_staged_files` filters to ``ACMR`` deliberately: its callers lint, format and secret-scan what it returns, and a file staged for deletion has no content to check. That is the right question for "what must the gate inspect" and the wrong one for "does this commit contain anything", and the same list was answering both - so a staged set of pure deletions read as empty and ``oct git commit`` refused it with exit 4, "Nothing to commit". No deletion-only commit could be made through the tool at all: untracking a generated artefact, removing a dead module, retiring a fixture. This asks only the complementary question. The gate keeps inspecting exactly what it inspected before. Joining and scope filtering are :func:`git_staged_files`'s, so a deletion staged in a sibling subproject is dropped by the same predicate. These paths are normally absent from disk, which is correct and harmless: resolution here is textual (``Path.resolve()`` is non-strict and :func:`~oct.core.project_root.is_within_project_root` compares resolved prefixes), so a removed file is still placed inside or outside the root.
Definition at line 523 of file git.py.
| list[Path] git.git_staged_files | ( | Path | repo_root, |
| Optional[set[str]] | extensions = None ) |
| bool git.has_uncommitted_changes | ( | Path | repo_root | ) |
| bool git.has_untracked_files | ( | Path | repo_root | ) |
| bool git.is_detached_head | ( | Path | repo_root | ) |
| bool git.is_git_repo | ( | Path | path | ) |
Return True if ``path`` is inside a git working tree. This is the only public function in the module that swallows every exception - ``GitNotFoundError``, ``GitCommandError``, ``OSError``, and anything else all collapse to ``False``. Use it as a cheap predicate before calling any other query function.
Definition at line 305 of file git.py.
| str git.last_commit_message | ( | Path | repo_root | ) |
| str git.last_commit_sha | ( | Path | repo_root, |
| bool | short = False ) |
| list[str] git.list_branches | ( | Path | repo_root, |
| * | , | ||
| bool | remote = False ) |
| list[str] git.list_tags | ( | Path | repo_root | ) |
| dict[str, str] git.remote_urls | ( | Path | repo_root | ) |
Return a ``{remote_name: url}`` map, with credentials redacted.
Uses ``git remote -v`` and collapses the fetch + push variants into
a single entry per remote (preferring ``fetch`` if both are
present). Returns an empty dict if no remotes are configured.
Definition at line 617 of file git.py.
| None git.restore_paths | ( | Path | repo_root, |
| list[Path] | list[str] | paths, | ||
| * | , | ||
| bool | staged = False ) |
Restore *paths* from the index (working tree) or HEAD (staged).
L3 / Phase 4G-followup. Equivalent to:
- ``staged=False``: ``git restore <paths>`` - drop unstaged
working-tree edits, restoring from the index.
- ``staged=True``: ``git restore --staged <paths>`` - unstage
*paths* without touching the working tree.
Raises
------
GitCommandError
If git rejects the operation.
Definition at line 931 of file git.py.
| None git.stage_paths | ( | Path | repo_root, |
| list[Path] | list[str] | paths, | ||
| * | , | ||
| bool | update = False, | ||
| bool | all_ = False ) |
Stage *paths* for the next commit (whole-file staging).
Equivalent to ``git add [<paths>...]`` with optional ``-u`` (update
tracked only) or ``-A`` (all changes including untracked).
Parameters
----------
repo_root
Git repository root.
paths
Paths to stage. May be empty when ``update`` or ``all_`` is set.
update
Pass ``-u`` so only already-tracked files are staged.
all_
Pass ``-A`` so untracked files are also staged.
Raises
------
GitCommandError
If git rejects the operation (e.g. path outside repo).
Definition at line 854 of file git.py.
| int git.stage_paths_interactive | ( | Path | repo_root, |
| list[Path] | list[str] | paths ) |
Run ``git add -p`` so the user can choose hunks interactively.
This is the only public function in this module that bypasses
:func:`git_run`. Patch-mode staging requires an attached TTY so the
user can answer hunk prompts (``y/n/s/...``); ``git_run`` always
captures stdout/stderr which would break that interaction. The
bypass still uses ``shell=False`` and validates argv.
Parameters
----------
repo_root
Git repository root.
paths
Optional list of paths to scope the patch session to. When
empty, ``git`` walks every modified tracked file.
Returns
-------
int
Process exit code from ``git add -p``. ``0`` on success.
Raises
------
GitNotFoundError
If the ``git`` executable is not on PATH.
| None git.switch_branch | ( | Path | repo_root, |
| str | name ) |
Switch the working tree to an existing branch *name*. Uses ``git checkout`` for maximum git-version compatibility. Raises :class:`GitCommandError` if the branch does not exist or the checkout fails (e.g. uncommitted changes would be overwritten).
Definition at line 802 of file git.py.
| None git.unstage_paths | ( | Path | repo_root, |
| list[Path] | list[str] | paths ) |
Remove *paths* from the index (unstage), leaving working tree intact.
Equivalent to ``git reset HEAD -- <paths>``. Provided for symmetry
with :func:`stage_paths`; not currently exposed via CLI but used in
tests and reserved for a future ``oct git unstage`` command.
Raises
------
GitCommandError
If git rejects the operation.
Definition at line 905 of file git.py.
|
protected |