From d8e6079a2c79e2706e3896704526e17dd36099c3 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 4 Apr 2026 14:08:25 -0700 Subject: [PATCH 1/2] feat: workspace git detection with branch/status badge When the workspace root is a git repo, a badge in the panel header shows the current branch name, dirty file count, and ahead/behind status. Updates on every root directory load. Backend: - git_info_for_workspace() in api/workspace.py runs lightweight git commands (rev-parse, status --porcelain, rev-list) with 3s timeout - New GET /api/git-info endpoint returns branch, dirty count, modified, untracked, ahead, behind Frontend: - _refreshGitBadge() in workspace.js fetches git info on root load - Git badge element in panel header shows branch + status - Badge turns gold when workspace has uncommitted changes Inspired by PR #75 (@MartinNielsenDev). Co-Authored-By: Claude Opus 4.6 (1M context) --- api/routes.py | 12 ++++++++++++ api/workspace.py | 41 +++++++++++++++++++++++++++++++++++++++++ static/index.html | 1 + static/style.css | 2 ++ static/workspace.js | 23 +++++++++++++++++++++++ 5 files changed, 79 insertions(+) diff --git a/api/routes.py b/api/routes.py index 7a6dd6f..115cb34 100644 --- a/api/routes.py +++ b/api/routes.py @@ -214,6 +214,18 @@ def handle_get(handler, parsed): if parsed.path == '/api/list': return _handle_list_dir(handler, parsed) + if parsed.path == '/api/git-info': + qs = parse_qs(parsed.query) + sid = qs.get('session_id', [''])[0] + if not sid: + return bad(handler, 'session_id required') + s = get_session(sid) + if not s: + return bad(handler, 'Session not found', 404) + from api.workspace import git_info_for_workspace + info = git_info_for_workspace(Path(s.workspace)) + return j(handler, {'git': info}) + if parsed.path == '/api/chat/stream/status': stream_id = parse_qs(parsed.query).get('stream_id', [''])[0] return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id}) diff --git a/api/workspace.py b/api/workspace.py index 757174b..628f7f3 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -9,6 +9,7 @@ paths are used as fallback when no profile module is available. """ import json import os +import subprocess from pathlib import Path from api.config import ( @@ -243,3 +244,43 @@ def read_file_content(workspace: Path, rel: str): raise ValueError(f"File too large ({size} bytes, max {MAX_FILE_BYTES})") content = target.read_text(encoding='utf-8', errors='replace') return {'path': rel, 'content': content, 'size': size, 'lines': content.count('\n') + 1} + + +# ── Git detection ────────────────────────────────────────────────────────── + +def _run_git(args, cwd, timeout=3): + """Run a git command and return stdout, or None on failure.""" + try: + r = subprocess.run( + ['git'] + args, cwd=str(cwd), capture_output=True, + text=True, timeout=timeout, + ) + return r.stdout.strip() if r.returncode == 0 else None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + + +def git_info_for_workspace(workspace: Path) -> dict: + """Return git info for a workspace directory, or None if not a git repo.""" + if not (workspace / '.git').exists(): + return None + branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], workspace) + if branch is None: + return None + # Status counts + status_out = _run_git(['status', '--porcelain'], workspace) or '' + modified = sum(1 for l in status_out.splitlines() if l and l[0:2].strip() in ('M', 'MM', 'AM')) + untracked = sum(1 for l in status_out.splitlines() if l.startswith('??')) + dirty = len(status_out.splitlines()) if status_out else 0 + # Ahead/behind + ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace) + behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace) + return { + 'branch': branch, + 'dirty': dirty, + 'modified': modified, + 'untracked': untracked, + 'ahead': int(ahead) if ahead and ahead.isdigit() else 0, + 'behind': int(behind) if behind and behind.isdigit() else 0, + 'is_git': True, + } diff --git a/static/index.html b/static/index.html index a435eea..c8a3ad5 100644 --- a/static/index.html +++ b/static/index.html @@ -278,6 +278,7 @@
Workspace +
diff --git a/static/style.css b/static/style.css index db72b53..e85b2d8 100644 --- a/static/style.css +++ b/static/style.css @@ -204,6 +204,8 @@ .upload-bar{height:100%;background:linear-gradient(90deg,var(--blue),#a0d0ff);width:0%;transition:width .3s ease;} .rightpanel{width:300px;background:var(--sidebar);border-left:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;} .panel-header{padding:12px 16px;border-bottom:1px solid var(--border);font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;display:flex;align-items:center;justify-content:space-between;} + .git-badge{font-size:9px;font-weight:600;color:var(--muted);background:rgba(255,255,255,.06);padding:2px 7px;border-radius:4px;letter-spacing:.02em;margin-left:auto;margin-right:4px;white-space:nowrap;font-family:'SF Mono',ui-monospace,monospace;} + .git-badge.dirty{color:var(--gold);background:rgba(201,168,76,.1);} .panel-actions{display:flex;gap:4px;} .panel-icon-btn{width:24px;height:24px;background:none;border:none;color:var(--muted);cursor:pointer;border-radius:5px;font-size:13px;display:flex;align-items:center;justify-content:center;transition:all .15s;} .panel-icon-btn:hover{background:rgba(255,255,255,.08);color:var(--text);} diff --git a/static/workspace.js b/static/workspace.js index 9b432cd..4873b73 100644 --- a/static/workspace.js +++ b/static/workspace.js @@ -59,9 +59,32 @@ async function loadDir(path){ clearPreview(); } } + // Fetch git info for workspace root (non-blocking) + if(!path||path==='.') _refreshGitBadge(); }catch(e){console.warn('loadDir',e);} } +async function _refreshGitBadge(){ + const badge=$('gitBadge'); + if(!badge||!S.session)return; + try{ + const data=await api(`/api/git-info?session_id=${encodeURIComponent(S.session.session_id)}`); + if(data.git&&data.git.is_git){ + const g=data.git; + let text=g.branch||'git'; + if(g.dirty>0) text+=` \u00b7 ${g.dirty}\u2206`; // middot + delta + if(g.behind>0) text+=` \u2193${g.behind}`; + if(g.ahead>0) text+=` \u2191${g.ahead}`; + badge.textContent=text; + badge.className='git-badge'+(g.dirty>0?' dirty':''); + badge.style.display=''; + } else { + badge.style.display='none'; + badge.textContent=''; + } + }catch(e){badge.style.display='none';} +} + function navigateUp(){ if(!S.session||S.currentDir==='.')return; const parts=S.currentDir.split('/'); From e184eb5ff5a25724b45931b7d505690b56ffc7b0 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sat, 4 Apr 2026 14:25:07 -0700 Subject: [PATCH 2/2] fix: correct modified/untracked counting in git status parser Agent review: l[0:2].strip() produced incorrect matches for git status --porcelain XY format. Now checks both X (index) and Y (worktree) columns for M/A/R status codes independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/workspace.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/workspace.py b/api/workspace.py index 628f7f3..5f507e1 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -269,9 +269,11 @@ def git_info_for_workspace(workspace: Path) -> dict: return None # Status counts status_out = _run_git(['status', '--porcelain'], workspace) or '' - modified = sum(1 for l in status_out.splitlines() if l and l[0:2].strip() in ('M', 'MM', 'AM')) - untracked = sum(1 for l in status_out.splitlines() if l.startswith('??')) - dirty = len(status_out.splitlines()) if status_out else 0 + lines = [l for l in status_out.splitlines() if l] + # git status --porcelain: XY format where X=index, Y=worktree + modified = sum(1 for l in lines if len(l) >= 2 and (l[0] in 'MAR' or l[1] in 'MAR')) + untracked = sum(1 for l in lines if l.startswith('??')) + dirty = len(lines) # Ahead/behind ahead = _run_git(['rev-list', '--count', '@{u}..HEAD'], workspace) behind = _run_git(['rev-list', '--count', 'HEAD..@{u}'], workspace)