oct
Loading...
Searching...
No Matches
git Namespace Reference

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

Detailed Description

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)

Function Documentation

◆ _redact_argv()

list[str] git._redact_argv ( list[str] argv)
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.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ _redact_git_url()

str git._redact_git_url ( str text)
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.

Here is the caller graph for this function:

◆ _resolve_and_filter()

list[Path] git._resolve_and_filter ( Iterable[str] entries,
Path join_base,
Path filter_base,
Optional[set[str]] extensions )
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.

Here is the caller graph for this function:

◆ ahead_behind()

tuple[int, int] git.ahead_behind ( Path repo_root)
Return ``(ahead, behind)`` counts relative to the upstream branch.

Uses ``git rev-list --left-right --count HEAD...@{upstream}``.
Returns ``(0, 0)`` if no upstream is configured or on a fresh repo.

Definition at line 729 of file git.py.

Here is the call graph for this function:

◆ branch_exists()

bool git.branch_exists ( Path repo_root,
str name )
Return True if a local branch *name* exists.

Uses ``git rev-parse --verify refs/heads/<name>`` with
``check=False`` so it never raises on a missing branch.

Definition at line 762 of file git.py.

Here is the call graph for this function:

◆ create_and_switch_branch()

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.

Here is the call graph for this function:

◆ create_branch()

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.

Here is the call graph for this function:

◆ current_branch()

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.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ get_commit_log()

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.

Here is the call graph for this function:

◆ get_repo_root()

Path git.get_repo_root ( Path path)
Return the absolute path of the top-level git repo containing ``path``.

Raises :class:`GitNotARepoError` if ``path`` is not inside a repo.

Definition at line 330 of file git.py.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ git_changed_files()

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.

Here is the call graph for this function:

◆ git_diff_name_only()

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.

Here is the call graph for this function:

◆ git_run()

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.

Here is the call graph for this function:
Here is the caller graph for this function:

◆ git_staged_deletions()

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.

Here is the call graph for this function:

◆ git_staged_files()

list[Path] git.git_staged_files ( Path repo_root,
Optional[set[str]] extensions = None )
Return absolute paths of files staged for the next commit.

Same contract as :func:`git_changed_files` but scoped to the index
only - untracked and unstaged-but-modified files are excluded.

Definition at line 498 of file git.py.

Here is the call graph for this function:

◆ has_uncommitted_changes()

bool git.has_uncommitted_changes ( Path repo_root)
Return True if the working tree has staged or unstaged changes.

Untracked files are ignored - use :func:`has_untracked_files` for
that. This function checks only tracked files via
``git status --porcelain -- uno``.

Definition at line 376 of file git.py.

Here is the call graph for this function:

◆ has_untracked_files()

bool git.has_untracked_files ( Path repo_root)
Return True if there are any untracked files not ignored by .gitignore.

Definition at line 394 of file git.py.

Here is the call graph for this function:

◆ is_detached_head()

bool git.is_detached_head ( Path repo_root)
Return True if HEAD is detached (not pointing at any branch).

Definition at line 369 of file git.py.

Here is the call graph for this function:

◆ is_git_repo()

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.

Here is the call graph for this function:

◆ last_commit_message()

str git.last_commit_message ( Path repo_root)
Return the subject + body of HEAD, or the empty string on fresh repos.

Uses ``git log -1 --pretty=%B`` so both the subject and the body are
returned together (trailing newline stripped).

Definition at line 588 of file git.py.

Here is the call graph for this function:

◆ last_commit_sha()

str git.last_commit_sha ( Path repo_root,
bool short = False )
Return the SHA of HEAD, or the empty string on a fresh repo.

A fresh repo is one where ``git rev-parse HEAD`` fails because no
commits exist yet. Any other failure still propagates as
:class:`GitCommandError`.

Definition at line 558 of file git.py.

Here is the call graph for this function:

◆ list_branches()

list[str] git.list_branches ( Path repo_root,
* ,
bool remote = False )
Return a list of local (or remote) branch names.

Uses ``git branch --format=%(refname:short)`` for clean output
without the ``*`` marker or whitespace prefix.

Definition at line 837 of file git.py.

Here is the call graph for this function:

◆ list_tags()

list[str] git.list_tags ( Path repo_root)
Return tag names sorted by commit date (newest first).

Uses ``git tag --sort=-creatordate``. Returns an empty list if no
tags exist or if the repo has no commits yet.

Definition at line 644 of file git.py.

Here is the call graph for this function:

◆ remote_urls()

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.

Here is the call graph for this function:

◆ restore_paths()

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.

Here is the call graph for this function:

◆ stage_paths()

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.

Here is the call graph for this function:

◆ stage_paths_interactive()

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.

Definition at line 969 of file git.py.

◆ switch_branch()

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.

Here is the call graph for this function:

◆ unstage_paths()

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.

Here is the call graph for this function:

Variable Documentation

◆ _CRED_URL_RE

re git._CRED_URL_RE
protected
Initial value:
1= re.compile(
2 r"(?P<scheme>https?://)[^/@\s]+@",
3)

Definition at line 94 of file git.py.

◆ DEFAULT_GIT_TIMEOUT

int git.DEFAULT_GIT_TIMEOUT = 30

Definition at line 87 of file git.py.