oc_chat
Loading...
Searching...
No Matches
git_helpers.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/git_helpers.py
4
5"""
6Cohesive git helpers for prompt composition — plan 135 §3.5.
7
8Purpose
9-------
10Provide the git facts the ``messages[1]`` environment sublayer needs —
11``git_head_hash`` (branch/commit identity, may be cached by callers) and
12``git_status_summary`` (the working-tree dirty summary) — behind a short
13subprocess timeout that degrades gracefully and never raises into composition.
14
15Responsibilities
16----------------
17- :func:`git_head_hash` — current ``HEAD`` short identity, ``""`` on any error.
18- :func:`git_current_branch` — current branch name (``"HEAD"`` when detached,
19 ``""`` on any error), for the ``messages[1]`` ``Git branch:`` field.
20- :func:`git_status_summary` — modified/untracked/staged (+ ahead/behind)
21 computed FRESH on each call (never HEAD-cached — working-tree status changes
22 while ``HEAD`` does not), degrading to ``"(not a git repo)"`` on any error.
23
24Diagnostics
25-----------
26Domain: OC_CHAT.MAIN
27Levels:
28 L2 — lifecycle events
29 L3 — semantic details
30 L4 — deep tracing
31
32Contracts
33---------
34- Neither function raises; both degrade to a sentinel string / ``""``.
35- A short timeout bounds slow I/O (detached network drives, hung git).
36- ``git_status_summary`` is never cached by the caller — it is recomputed on
37 each (infrequent) variable-context render.
38
39Dependencies
40------------
41- oc_diagnostics (structured debug logging via _dbg)
42"""
43from __future__ import annotations
44
45import subprocess
46
47from oc_diagnostics import _dbg
48
49_dbg("OC_CHAT.MAIN", "git_helpers", "module", 4)
50
51_NOT_A_REPO = "(not a git repo)"
52_GIT_TIMEOUT_S = 3.0
53
54
55def git_head_hash() -> str:
56 """Return the current git ``HEAD`` hash, or ``""`` if unavailable."""
57 try:
58 return subprocess.check_output(
59 ["git", "rev-parse", "HEAD"],
60 text=True, stderr=subprocess.DEVNULL, timeout=_GIT_TIMEOUT_S,
61 ).strip()
62 except (OSError, subprocess.SubprocessError):
63 return ""
64
65
66def git_current_branch() -> str:
67 """Return the current branch name, ``"HEAD"`` when detached, or ``""`` on error."""
68 try:
69 return subprocess.check_output(
70 ["git", "rev-parse", "--abbrev-ref", "HEAD"],
71 text=True, stderr=subprocess.DEVNULL, timeout=_GIT_TIMEOUT_S,
72 ).strip()
73 except (OSError, subprocess.SubprocessError):
74 return ""
75
76
77def git_status_summary() -> str:
78 """Return a compact working-tree summary, or ``"(not a git repo)"`` on any error.
79
80 Format: ``"<N> modified, <M> untracked, <K> staged"`` (+ ``", <P> ahead/<Q>
81 behind origin"`` when the branch tracks a remote), or ``"clean"`` when there
82 are no changes. Computed fresh every call — never cached by ``HEAD`` (the
83 dirty summary changes while ``HEAD`` does not). Never raises.
84 """
85 func = "git_status_summary"
86 try:
87 proc = subprocess.run(
88 ["git", "status", "--porcelain=v1", "-b"],
89 capture_output=True, text=True, timeout=_GIT_TIMEOUT_S, check=False,
90 )
91 except (OSError, subprocess.SubprocessError) as exc:
92 _dbg("OC_CHAT.MAIN", func, f"git status failed/timeout: {type(exc).__name__}", 3)
93 return _NOT_A_REPO
94 if proc.returncode != 0:
95 return _NOT_A_REPO
96 modified = untracked = staged = 0
97 ahead = behind = 0
98 for line in proc.stdout.splitlines():
99 if line.startswith("## "):
100 if "[" in line and "]" in line:
101 tail = line[line.index("[") + 1: line.index("]")]
102 for part in tail.split(","):
103 part = part.strip()
104 if part.startswith("ahead "):
105 ahead = int(part[6:] or 0)
106 elif part.startswith("behind "):
107 behind = int(part[7:] or 0)
108 continue
109 if not line:
110 continue
111 x, y = (line[0] if line else " "), (line[1] if len(line) > 1 else " ")
112 if line[:2] == "??":
113 untracked += 1
114 continue
115 if x not in (" ", "?"):
116 staged += 1
117 if y in ("M", "D"):
118 modified += 1
119 if not (modified or untracked or staged or ahead or behind):
120 return "clean"
121 summary = f"{modified} modified, {untracked} untracked, {staged} staged"
122 if ahead or behind:
123 summary += f", {ahead} ahead/{behind} behind origin"
124 return summary
125
126
127__all__ = ["git_head_hash", "git_current_branch", "git_status_summary"]