From 8a5b133e615f92b57274ddc69c32bbafab7a391a Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 05:53:29 +0000 Subject: [PATCH 1/8] feat(git): add git_aware.py with SHA tracking + changed file detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/git_aware.py with: - get_current_sha(workspace) / get_current_branch(workspace) -> HEAD SHA / branch - get_changed_files(workspace, since_sha) -> git diff --name-only (HEAD or ) - get_last_indexed_sha / set_last_indexed_sha -> registry_meta key/value - detect_branch_switch(workspace, db_path) -> True when HEAD moved AND branch name changed (catches git checkout, not same-branch commits) - rescan_recommended(workspace, db_path) -> True when branch switch OR changed files since last index (used by git-status command) - init_registry_meta(conn) -> creates registry_meta(key TEXT PK, value TEXT) All functions gracefully degrade to None / [] / False when git is unavailable or the workspace is not a git repo (no exceptions raised). persistent_registry.py: calls init_registry_meta(conn) during _init_schema so the table always exists by the time any git-aware function tries to read or write a bookmark. Additive — no existing table or column modified. Refs #14. --- scripts/git_aware.py | 351 +++++++++++++++++++++++++++++++++ scripts/persistent_registry.py | 12 ++ 2 files changed, 363 insertions(+) create mode 100644 scripts/git_aware.py diff --git a/scripts/git_aware.py b/scripts/git_aware.py new file mode 100644 index 00000000..947e7d95 --- /dev/null +++ b/scripts/git_aware.py @@ -0,0 +1,351 @@ +"""Git-aware utilities for CodeLens (issue #14). + +Provides optional git-diff based change detection so that incremental scans +can target exactly the files git knows changed, instead of relying solely on +filesystem mtime polling. All functions degrade gracefully when git is not +available or the workspace is not a git repository — they return ``None`` / +``[]`` / ``False`` so callers can fall back to the existing mtime path. + +Design constraints (see BOS spec for issue #14): +- No external deps (uses ``subprocess`` to call ``git`` — no GitPython). +- Python 3.8+ compatible. +- Every public function has a docstring. +- Git is OPTIONAL: every function must work when git is missing or the + workspace is not under git control. + +The "last indexed" git SHA + branch are persisted in a new ``registry_meta`` +table (created by :func:`init_registry_meta`) so subsequent scans can diff +against the bookmark instead of HEAD's parent. +""" + +from __future__ import annotations + +import os +import sqlite3 +import subprocess +from typing import List, Optional + +from utils import logger + + +# ─── SQL DDL for the registry_meta table ────────────────────────────── +# A simple key/value store for git-aware scan bookmarks. Lives alongside +# the existing CodeLens SQLite tables (symbols, refs, files, ...) and the +# graph tables (graph_nodes, graph_edges). Additive — no existing table +# or column is modified. + +_CREATE_REGISTRY_META = """ +CREATE TABLE IF NOT EXISTS registry_meta ( + key TEXT PRIMARY KEY, + value TEXT +) +""" + +# Keys used in registry_meta. +KEY_LAST_INDEXED_SHA = "last_indexed_sha" +KEY_LAST_INDEXED_BRANCH = "last_indexed_branch" + + +# ─── Schema initialization ─────────────────────────────────────────── + +def init_registry_meta(conn: sqlite3.Connection) -> None: + """Create the ``registry_meta`` table if it does not exist. + + Idempotent — safe to call on every database initialization. Called + automatically by :class:`persistent_registry.PersistentRegistry` during + schema init so the table always exists by the time any git-aware + function tries to read or write a bookmark. + + Args: + conn: An open ``sqlite3.Connection``. Caller owns the connection and + is responsible for committing / closing. + """ + try: + conn.execute(_CREATE_REGISTRY_META) + conn.commit() + except sqlite3.Error as e: + logger.warning(f"registry_meta schema init error: {e}") + + +# ─── Internal git invocation helpers ───────────────────────────────── + +def _run_git(workspace: str, args: List[str]) -> Optional[str]: + """Run a ``git`` command inside ``workspace`` and return stdout (stripped). + + Returns ``None`` if git is unavailable, the workspace is not a git + repository, or the command exits non-zero. Never raises — callers + should treat ``None`` as "git could not answer" and fall back. + + Args: + workspace: Absolute path to the workspace root. + args: Arguments to pass to ``git`` (e.g. ``["rev-parse", "HEAD"]``). + + Returns: + Stripped stdout on success, ``None`` on any failure. + """ + try: + result = subprocess.run( + ["git"] + args, + cwd=workspace, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except FileNotFoundError: + # git binary not installed + return None + except (subprocess.SubprocessError, OSError) as e: + logger.debug(f"git invocation failed: {e}") + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +# ─── Public API ────────────────────────────────────────────────────── + +def get_current_sha(workspace: str) -> Optional[str]: + """Return the HEAD commit SHA of the git repo at ``workspace``. + + Args: + workspace: Absolute path to the workspace root. + + Returns: + The 40-char commit SHA, or ``None`` if git is unavailable, the + workspace is not a git repo, or HEAD does not point at a commit + (e.g. an empty repo with no commits yet). + """ + return _run_git(workspace, ["rev-parse", "HEAD"]) + + +def get_current_branch(workspace: str) -> Optional[str]: + """Return the current branch name of the git repo at ``workspace``. + + Returns ``"HEAD"`` (detached HEAD) when applicable. Returns ``None`` + if git is unavailable or the workspace is not a git repo. + + Args: + workspace: Absolute path to the workspace root. + + Returns: + Branch name string (e.g. ``"main"``, ``"feat/x"``), ``"HEAD"`` for + detached HEAD, or ``None`` on failure. + """ + return _run_git(workspace, ["rev-parse", "--abbrev-ref", "HEAD"]) + + +def get_changed_files(workspace: str, since_sha: Optional[str] = None) -> List[str]: + """Return a list of file paths changed in the git repo at ``workspace``. + + Uses ``git diff --name-only`` to enumerate changes. Paths are relative + to ``workspace`` (matches git's default output). + + Two modes: + + * ``since_sha=None`` — working-tree changes vs HEAD (uncommitted edits + + staged changes). Equivalent to ``git diff HEAD --name-only``. + * ``since_sha=`` — changes between ```` and HEAD, plus any + uncommitted working-tree changes. Implemented as ``git diff + --name-only`` which includes both committed and uncommitted deltas + relative to ````. + + Args: + workspace: Absolute path to the workspace root. + since_sha: Optional git commit SHA to diff against. + + Returns: + List of relative file paths (may be empty). Returns ``[]`` if git + is unavailable or the workspace is not a git repo. + """ + args = ["diff", "--name-only"] + if since_sha: + args.append(since_sha) + else: + args.append("HEAD") + out = _run_git(workspace, args) + if not out: + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +# ─── registry_meta read/write ──────────────────────────────────────── + +def _connect(db_path: str) -> sqlite3.Connection: + """Open a SQLite connection to ``db_path`` (creates parent dir).""" + os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True) + conn = sqlite3.connect(db_path, timeout=10.0) + conn.row_factory = sqlite3.Row + return conn + + +def get_meta_value(db_path: str, key: str) -> Optional[str]: + """Read a single key from the ``registry_meta`` table. + + Args: + db_path: Absolute path to the SQLite database file. + key: The metadata key to read. + + Returns: + The stored value, or ``None`` if the key is absent, the table does + not exist, or the database file is missing. + """ + if not os.path.exists(db_path): + return None + conn = _connect(db_path) + try: + row = conn.execute( + "SELECT value FROM registry_meta WHERE key = ?", (key,) + ).fetchone() + return row["value"] if row else None + except sqlite3.Error as e: + logger.debug(f"get_meta_value('{key}') error: {e}") + return None + finally: + conn.close() + + +def set_meta_value(db_path: str, key: str, value: Optional[str]) -> None: + """Upsert a single key in the ``registry_meta`` table. + + Creates the table if it does not exist (idempotent). Deleting a key is + done by passing ``value=None``. + + Args: + db_path: Absolute path to the SQLite database file. + key: The metadata key to write. + value: The metadata value to write, or ``None`` to delete the key. + """ + conn = _connect(db_path) + try: + init_registry_meta(conn) + if value is None: + conn.execute( + "DELETE FROM registry_meta WHERE key = ?", (key,) + ) + else: + conn.execute( + "INSERT INTO registry_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + conn.commit() + except sqlite3.Error as e: + logger.warning(f"set_meta_value('{key}') error: {e}") + conn.rollback() + finally: + conn.close() + + +def get_last_indexed_sha(workspace: str, db_path: str) -> Optional[str]: + """Return the last-indexed git SHA bookmark for ``workspace``. + + Args: + workspace: Absolute path to the workspace root (unused but kept for + API symmetry with the other git-aware functions). + db_path: Absolute path to the SQLite database file. + + Returns: + The previously stored HEAD SHA at the time of the last successful + scan, or ``None`` if no bookmark has been set yet (e.g. first scan + on a fresh repo, or git was unavailable last time). + """ + _ = workspace # API symmetry — workspace not needed to read a key + return get_meta_value(db_path, KEY_LAST_INDEXED_SHA) + + +def set_last_indexed_sha(workspace: str, db_path: str, sha: Optional[str]) -> None: + """Persist the last-indexed git SHA + current branch for ``workspace``. + + Writes both ``last_indexed_sha`` and ``last_indexed_branch`` so that + :func:`detect_branch_switch` can later compare branches. Passing + ``sha=None`` clears both bookmarks (used when leaving a non-git repo). + + Args: + workspace: Absolute path to the workspace root. + db_path: Absolute path to the SQLite database file. + sha: The HEAD SHA to bookmark, or ``None`` to clear. + """ + branch = get_current_branch(workspace) if sha else None + set_meta_value(db_path, KEY_LAST_INDEXED_SHA, sha) + set_meta_value(db_path, KEY_LAST_INDEXED_BRANCH, branch) + + +def get_last_indexed_branch(db_path: str) -> Optional[str]: + """Return the branch name recorded at the time of the last scan. + + Args: + db_path: Absolute path to the SQLite database file. + + Returns: + The branch name string, or ``None`` if no bookmark has been set. + """ + return get_meta_value(db_path, KEY_LAST_INDEXED_BRANCH) + + +def detect_branch_switch(workspace: str, db_path: str) -> bool: + """Detect whether the active git branch changed since the last scan. + + Returns ``True`` only when ALL of the following hold: + + * git is available and the workspace is a git repo, + * a previous scan bookmarked a SHA + branch, + * the current HEAD SHA differs from the bookmarked SHA, + * the current branch name differs from the bookmarked branch name. + + The SHA check is what catches any commit/checkout/amend/rebase; the + branch-name check is what narrows "HEAD moved" to "user switched + branches" specifically (so committing on the same branch doesn't + trigger a branch-switch re-index). + + Args: + workspace: Absolute path to the workspace root. + db_path: Absolute path to the SQLite database file. + + Returns: + ``True`` if a branch switch is detected, ``False`` otherwise + (including when git is unavailable). + """ + current_sha = get_current_sha(workspace) + if not current_sha: + return False + last_sha = get_last_indexed_sha(workspace, db_path) + if not last_sha: + return False + if current_sha == last_sha: + return False + current_branch = get_current_branch(workspace) + last_branch = get_last_indexed_branch(db_path) + if not current_branch or not last_branch: + return False + return current_branch != last_branch + + +def rescan_recommended(workspace: str, db_path: str) -> bool: + """Return ``True`` when a re-scan is recommended for ``workspace``. + + A re-scan is recommended when EITHER: + + * :func:`detect_branch_switch` returns ``True`` (branch switch rewrote + many files at once), OR + * :func:`get_changed_files` reports any working-tree changes since + the last indexed SHA. + + This is the "do I need to re-scan?" predicate used by the + ``git-status`` command. + + Args: + workspace: Absolute path to the workspace root. + db_path: Absolute path to the SQLite database file. + + Returns: + ``True`` if a re-scan is recommended, ``False`` otherwise. + """ + if detect_branch_switch(workspace, db_path): + return True + last_sha = get_last_indexed_sha(workspace, db_path) + if not last_sha: + # Never indexed under git — recommend a scan if there's any working + # tree state to capture (which there always is on a first scan). + return bool(get_current_sha(workspace)) + return len(get_changed_files(workspace, since_sha=last_sha)) > 0 diff --git a/scripts/persistent_registry.py b/scripts/persistent_registry.py index c58df2a8..ca165574 100644 --- a/scripts/persistent_registry.py +++ b/scripts/persistent_registry.py @@ -202,6 +202,18 @@ def _init_schema(self) -> None: except Exception as e: logger.warning(f"graph_model schema init failed: {e}") + # Initialize the registry_meta table used by git_aware.py to store + # the last-indexed git SHA + branch bookmark (issue #14). Additive — + # no existing table or column is modified. Lazy import keeps + # git_aware optional for downstream forks that remove it. + try: + from git_aware import init_registry_meta + init_registry_meta(conn) + except ImportError: + logger.debug("git_aware module not available; skipping registry_meta init") + except Exception as e: + logger.warning(f"registry_meta schema init failed: {e}") + def _connect_raw(self) -> sqlite3.Connection: """Get raw connection without auto-init (for init itself).""" if self._conn is None: From 08f35b71cc050972e2104dd6b414eb9784d8e391 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 05:53:35 +0000 Subject: [PATCH 2/8] feat(scan): store last_indexed_sha after scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful scan (full or incremental), if git is available and the workspace is a git repo, record the current HEAD SHA + branch in the registry_meta table via set_last_indexed_sha(). This is the 'bookmark' that the next 'scan --incremental' will diff against. Additive and fail-soft: if git_aware import fails, git is unavailable, or the workspace is not a git repo, the scan still succeeds — the bookmark is simply left as None. Scan output now includes a 'git' field with last_indexed_sha + last_indexed_branch so agents can verify the bookmark was recorded. Refs #14. --- scripts/commands/scan.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/scripts/commands/scan.py b/scripts/commands/scan.py index 6d77dfed..9673c808 100644 --- a/scripts/commands/scan.py +++ b/scripts/commands/scan.py @@ -921,6 +921,24 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list] except Exception: logger.warning("Graph table population failed", exc_info=True) + # ─── Git-aware scan bookmark (issue #14) ───────────────────── + # After a successful scan, record the current HEAD SHA + branch so the + # next `scan --incremental` can diff against this bookmark instead of + # relying solely on filesystem mtimes. Additive — if git is unavailable + # or the workspace is not a git repo, this is a no-op. Failures here + # MUST NOT break the scan (git-awareness is an optimization layer). + git_bookmark = {"sha": None, "branch": None} + try: + from git_aware import get_current_sha, get_current_branch, set_last_indexed_sha + from graph_model import _default_db_path as _git_db_path + sha = get_current_sha(workspace) + if sha: + branch = get_current_branch(workspace) + set_last_indexed_sha(workspace, _git_db_path(workspace), sha) + git_bookmark = {"sha": sha, "branch": branch} + except Exception: + logger.debug("Git bookmark update failed", exc_info=True) + # Update mtimes cache all_files = [] for file_list in files.values(): @@ -1018,6 +1036,10 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list] "nodes": graph_population.get("nodes", 0), "edges": graph_population.get("edges", 0), }, + "git": { + "last_indexed_sha": git_bookmark.get("sha"), + "last_indexed_branch": git_bookmark.get("branch"), + }, } # Add plugin rules data if plugins were requested From 357a1d7203c12b7a7d2e6125028fe4a1462e67a7 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 05:56:38 +0000 Subject: [PATCH 3/8] feat(incremental): use git-diff for changed file detection (mtime fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit incremental.find_changed_files now tries the git-aware path FIRST: if a last_indexed_sha bookmark exists in registry_meta, uses 'git diff --name-only' + 'git ls-files --others' to enumerate exactly the files git knows changed (tracked changes + untracked new files). Deleted files (in diff but not on disk) are returned in the deleted slot so scan.py's existing deletion cleanup path runs. Fallback to the existing mtime-based detection is preserved for: - non-git workspaces - git unavailable - first scan (no bookmark yet) - any unexpected error in the git path Signature is backward-compatible: db_path is a new optional kwarg. scan.py passes db_path explicitly so the bookmark lookup is cheap. Also adds git_aware.get_untracked_files(workspace) helper used by the git path to catch newly-created files that haven't been git-added. Does NOT fix issue #25 (incremental graph population) — incremental scans still don't populate graph tables, only full scans do. Existing behavior preserved. Refs #14. --- scripts/commands/scan.py | 8 ++++- scripts/git_aware.py | 22 ++++++++++++ scripts/incremental.py | 77 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/scripts/commands/scan.py b/scripts/commands/scan.py index 9673c808..a6aee0f2 100644 --- a/scripts/commands/scan.py +++ b/scripts/commands/scan.py @@ -94,7 +94,13 @@ def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list] all_discovered = [] for file_list in files.values(): all_discovered.extend(file_list) - changed, new, deleted = find_changed_files(workspace, all_discovered) + # Pass the db_path so find_changed_files can look up the + # last_indexed_sha bookmark and use git-diff when available + # (issue #14). Falls back to mtime inside the function. + from graph_model import _default_db_path as _scan_db_path + changed, new, deleted = find_changed_files( + workspace, all_discovered, db_path=_scan_db_path(workspace) + ) if not changed and not new and not deleted: # Load existing registry counts diff --git a/scripts/git_aware.py b/scripts/git_aware.py index 947e7d95..667b834e 100644 --- a/scripts/git_aware.py +++ b/scripts/git_aware.py @@ -169,6 +169,28 @@ def get_changed_files(workspace: str, since_sha: Optional[str] = None) -> List[s return [line.strip() for line in out.splitlines() if line.strip()] +def get_untracked_files(workspace: str) -> List[str]: + """Return git-untracked file paths in ``workspace`` (excluding gitignored). + + Uses ``git ls-files --others --exclude-standard`` so gitignored files + (node_modules, dist, .env, etc.) are NOT returned. Required because + :func:`get_changed_files` only reports tracked files — newly created + files that have not yet been ``git add`` -ed would otherwise be + invisible to incremental scans. + + Args: + workspace: Absolute path to the workspace root. + + Returns: + List of relative file paths (may be empty). Returns ``[]`` if git + is unavailable or the workspace is not a git repo. + """ + out = _run_git(workspace, ["ls-files", "--others", "--exclude-standard"]) + if not out: + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + # ─── registry_meta read/write ──────────────────────────────────────── def _connect(db_path: str) -> sqlite3.Connection: diff --git a/scripts/incremental.py b/scripts/incremental.py index 451e5600..26f05c07 100755 --- a/scripts/incremental.py +++ b/scripts/incremental.py @@ -80,11 +80,82 @@ def get_current_mtimes(workspace: str, files: List[str]) -> Dict[str, float]: def find_changed_files( workspace: str, - all_files: List[str] + all_files: List[str], + db_path: Optional[str] = None, ) -> Tuple[List[str], List[str], List[str]]: + """Detect changed, new, and deleted files since the last scan. + + Tries the git-aware path FIRST (issue #14). If git is available and a + ``last_indexed_sha`` bookmark exists in the registry_meta table, uses + ``git diff --name-only`` (+ ``git ls-files --others``) to + enumerate exactly the files git knows changed. Falls back to the + mtime-based path when git is unavailable, no bookmark is stored, or + the git path returns nothing actionable. + + Args: + workspace: Absolute path to the workspace root. + all_files: List of absolute file paths currently in the workspace + (used by the mtime fallback path and to compute the + default db_path when ``db_path`` is None). + db_path: Optional SQLite db path. Defaults to + ``/.codelens/codelens.db``. Pass explicitly + when the caller already has the path to avoid recomputing. + + Returns: + Tuple of ``(changed_files, new_files, deleted_files)``. + ``changed_files`` and ``new_files`` are absolute paths. + ``deleted_files`` are relative paths (matches the pre-8.2 contract + so :func:`remove_from_mtimes_cache` keeps working unchanged). """ - Compare current mtimes with cached mtimes. - Returns (changed_files, new_files, deleted_files). + # ── Git-aware path (issue #14) ────────────────────────────── + # Try git-diff first. If a last_indexed_sha bookmark is set, use it as + # the diff base. Otherwise (no bookmark yet) fall through to mtime so + # the first scan on a fresh repo still works. + try: + from git_aware import ( + get_changed_files as _git_changed, + get_untracked_files as _git_untracked, + get_last_indexed_sha as _git_last_sha, + ) + if db_path is None: + db_path = os.path.join(workspace, ".codelens", "codelens.db") + last_sha = _git_last_sha(workspace, db_path) + if last_sha: + git_changed_rel = set(_git_changed(workspace, since_sha=last_sha)) + git_untracked_rel = set(_git_untracked(workspace)) + all_rel = git_changed_rel | git_untracked_rel + + changed_abs: List[str] = [] + deleted_rel: List[str] = [] + for rel in all_rel: + abs_path = os.path.join(workspace, rel) + if os.path.exists(abs_path): + changed_abs.append(abs_path) + else: + # File was deleted between last SHA and now. + deleted_rel.append(rel) + + # In git mode, "new" files (untracked + just-added) are folded + # into the changed set — both are files that need re-parsing. + # Keep new=[] so the caller's `set(changed + new)` union still + # works without double-counting. + return changed_abs, [], deleted_rel + except Exception: + logger.debug("git-aware find_changed_files failed; falling back to mtime", exc_info=True) + + # ── Mtime fallback (pre-8.2 behavior) ─────────────────────── + return _find_changed_files_mtime(workspace, all_files) + + +def _find_changed_files_mtime( + workspace: str, + all_files: List[str], +) -> Tuple[List[str], List[str], List[str]]: + """Compare current mtimes with cached mtimes (pre-8.2 mtime path). + + Returns (changed_files, new_files, deleted_files) as absolute paths + for changed/new and relative paths for deleted. Kept as a separate + helper so :func:`find_changed_files` can fall back to it cleanly. """ cached = load_mtimes(workspace) current = get_current_mtimes(workspace, all_files) From b759db80cd06745430abb62a4ae6c0c806916527 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 05:57:26 +0000 Subject: [PATCH 4/8] feat(cmd): add git-status command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'git-status' command (auto-registered) shows the git-aware scan state in one call: - current HEAD SHA + branch - last-indexed SHA + branch (from registry_meta) - changed files count since last index - whether a branch switch was detected - whether a re-scan is recommended Designed for AI agents that need a 'do I need to re-scan?' check without running a full diff themselves. All fields degrade gracefully to None / 0 / False when git is unavailable or the workspace is not a git repo — status is always 'ok' in those cases (git-unavailable is reported via git_available=False, not an error). Refs #14. --- scripts/commands/git_status.py | 137 +++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 scripts/commands/git_status.py diff --git a/scripts/commands/git_status.py b/scripts/commands/git_status.py new file mode 100644 index 00000000..3a7b08c4 --- /dev/null +++ b/scripts/commands/git_status.py @@ -0,0 +1,137 @@ +"""Git-status command — Show git-aware scan state (issue #14). + +Reports the current HEAD SHA + branch, the last-indexed SHA + branch +(stored in registry_meta), the number of changed files since the last +index, and whether a re-scan is recommended. Designed for AI agents +that need a single-call 'do I need to re-scan?' check. + +All fields degrade gracefully to None / 0 / False when git is +unavailable or the workspace is not a git repo. +""" + +import os +from typing import Any, Dict + +from commands import register_command +from utils import logger + + +def add_args(parser): + """Register git-status CLI arguments.""" + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + + +def execute(args, workspace): + """Execute the git-status command. + + Returns a dict with: status, workspace, git_available, current_sha, + current_branch, last_indexed_sha, last_indexed_branch, + changed_files_count, branch_switch_detected, rescan_recommended. + """ + return cmd_git_status(workspace) + + +def cmd_git_status(workspace: str) -> Dict[str, Any]: + """Build the git-aware status report for ``workspace``. + + Reads the last-indexed bookmark from ``registry_meta`` and compares + it to the current HEAD. Used by AI agents to decide whether a + re-scan is needed without running a full diff themselves. + + Args: + workspace: Absolute path to the workspace root. + + Returns: + Dict with the fields documented in :func:`execute`. ``status`` + is always ``"ok"`` — git-unavailable is not an error condition, + it's reported via ``git_available=False``. + """ + workspace = os.path.abspath(workspace) + + # Lazy import so the command still registers when git_aware is + # removed by a downstream fork. graph_model import is for the + # default db path; if it's missing we fall back to the conventional + # path string. + try: + from git_aware import ( + get_current_sha, get_current_branch, + get_last_indexed_sha, get_last_indexed_branch, + get_changed_files, detect_branch_switch, + rescan_recommended, + ) + except ImportError: + logger.debug("git_aware module not available; git-status will report unavailable") + return { + "status": "ok", + "workspace": workspace, + "git_available": False, + "current_sha": None, + "current_branch": None, + "last_indexed_sha": None, + "last_indexed_branch": None, + "changed_files_count": 0, + "branch_switch_detected": False, + "rescan_recommended": False, + } + + try: + from graph_model import _default_db_path + db_path = _default_db_path(workspace) + except ImportError: + db_path = os.path.join(workspace, ".codelens", "codelens.db") + + current_sha = get_current_sha(workspace) + git_available = current_sha is not None + + if not git_available: + return { + "status": "ok", + "workspace": workspace, + "git_available": False, + "current_sha": None, + "current_branch": None, + "last_indexed_sha": get_last_indexed_sha(workspace, db_path), + "last_indexed_branch": get_last_indexed_branch(db_path), + "changed_files_count": 0, + "branch_switch_detected": False, + "rescan_recommended": False, + } + + current_branch = get_current_branch(workspace) + last_sha = get_last_indexed_sha(workspace, db_path) + + # Changed files since last index. If no bookmark yet, count working + # tree changes vs HEAD (which is what an agent would care about + # before the first scan-after-install). + if last_sha: + changed_files = get_changed_files(workspace, since_sha=last_sha) + else: + changed_files = get_changed_files(workspace, since_sha=None) + + branch_switch = detect_branch_switch(workspace, db_path) + rescan = rescan_recommended(workspace, db_path) + + return { + "status": "ok", + "workspace": workspace, + "git_available": True, + "current_sha": current_sha, + "current_branch": current_branch, + "last_indexed_sha": last_sha, + "last_indexed_branch": get_last_indexed_branch(db_path), + "changed_files_count": len(changed_files), + "changed_files": sorted(changed_files), + "branch_switch_detected": branch_switch, + "rescan_recommended": rescan, + } + + +register_command( + "git-status", + "Show git-aware scan state (SHA, branch, changed files, rescan recommendation)", + add_args, + execute, +) From 607c4d5e7d3a921bcb175cf72d50fab877d2d27d Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 05:58:30 +0000 Subject: [PATCH 5/8] feat(diff): add --git-aware flag for changed symbols + impact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diff command now supports '--git-aware' which produces a single-call 'what changed + what's affected' view for AI agents: - changed_files: paths git knows changed since last_indexed_sha (tracked) + untracked new files - symbols: backend flat-registry nodes whose 'file' is in changed_files - impact: for each changed symbol, the direct callers from the graph tables (graph_model.query_callers max_depth=1). Empty when graph tables aren't populated (e.g. incremental-only scan — issue #25); that's a known gap, not a bug in this command. Default snapshot-diff behavior is unchanged — --git-aware is purely additive. Falls back to git_available=False when git is unavailable or the workspace is not a git repo (status stays 'ok'). Refs #14. --- scripts/commands/diff.py | 199 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 191 insertions(+), 8 deletions(-) diff --git a/scripts/commands/diff.py b/scripts/commands/diff.py index 0ef4671a..14b2358a 100644 --- a/scripts/commands/diff.py +++ b/scripts/commands/diff.py @@ -1,18 +1,64 @@ -"""Diff command — Compare registry snapshots.""" +"""Diff command — Compare registry snapshots, with optional git-aware delta. -from diff_engine import diff_current_vs_last, diff_snapshots, save_snapshot, list_snapshots +Default mode (unchanged from pre-8.2): compares the current flat +registry against the last saved snapshot via diff_engine. + +Git-aware mode (``--git-aware``, issue #14): reports the files git +knows changed since the last indexed SHA, the symbols defined in those +files (from the flat backend registry), and the downstream impact +(callers of those symbols from the graph tables, when populated). + +The two modes are complementary — snapshot diff captures scan-to-scan +deltas, git-aware diff captures the uncommitted-scratch delta agents +typically care about right before a write. +""" + +import os +from typing import Any, Dict, List + +from diff_engine import ( + diff_current_vs_last, diff_snapshots, save_snapshot, list_snapshots, +) from commands import register_command +from utils import logger def add_args(parser): - parser.add_argument("workspace", nargs="?", default=None, - help="Path to workspace root (auto-detected if omitted)") - parser.add_argument("--snapshot1", default=None, help="First snapshot ID (default: second-to-last)") - parser.add_argument("--snapshot2", default=None, help="Second snapshot ID (default: last)") - parser.add_argument("--list-snapshots", action="store_true", help="List available snapshots") + """Register diff CLI arguments.""" + parser.add_argument( + "workspace", nargs="?", default=None, + help="Path to workspace root (auto-detected if omitted)", + ) + parser.add_argument( + "--snapshot1", default=None, + help="First snapshot ID (default: second-to-last)", + ) + parser.add_argument( + "--snapshot2", default=None, + help="Second snapshot ID (default: last)", + ) + parser.add_argument( + "--list-snapshots", action="store_true", + help="List available snapshots", + ) + parser.add_argument( + "--git-aware", action="store_true", + help="Show git-diff delta since last indexed SHA: changed files, " + "symbols in those files, and downstream impact (callers) " + "from the graph tables. Falls back to 'git unavailable' " + "status when git is not available or the workspace is not " + "a git repo.", + ) def execute(args, workspace): + """Execute the diff command. + + Dispatches to git-aware mode when ``--git-aware`` is set, otherwise + preserves the pre-8.2 snapshot-diff behavior. + """ + if getattr(args, "git_aware", False): + return cmd_diff_git_aware(workspace) if args.list_snapshots: snaps = list_snapshots(workspace) return {"status": "ok", "snapshots": snaps} @@ -22,4 +68,141 @@ def execute(args, workspace): return diff_current_vs_last(workspace) -register_command("diff", "Compare registry snapshots", add_args, execute) +def cmd_diff_git_aware(workspace: str) -> Dict[str, Any]: + """Build the git-aware diff report for ``workspace``. + + Combines three views into one call: + 1. ``changed_files`` — paths git knows changed since the last + indexed SHA (tracked) plus untracked new files. + 2. ``symbols`` — backend flat-registry nodes whose ``file`` field + matches a changed path. Gives agents the symbol-level delta. + 3. ``impact`` — for each changed symbol, the direct callers from + the graph tables (``graph_model.query_callers``). Empty when + the graph tables aren't populated (e.g. incremental scan only + — see issue #25). + + Args: + workspace: Absolute path to the workspace root. + + Returns: + Dict with: status, workspace, git_available, last_indexed_sha, + current_sha, changed_files[], symbols[], impact[]. ``status`` + is always ``"ok"`` — git-unavailable is reported via + ``git_available=False`` (not an error). + """ + workspace = os.path.abspath(workspace) + + try: + from git_aware import ( + get_current_sha, get_last_indexed_sha, get_changed_files, + get_untracked_files, + ) + except ImportError: + return { + "status": "ok", + "workspace": workspace, + "git_available": False, + "message": "git_aware module not available", + "changed_files": [], + "symbols": [], + "impact": [], + } + + try: + from graph_model import _default_db_path + db_path = _default_db_path(workspace) + except ImportError: + db_path = os.path.join(workspace, ".codelens", "codelens.db") + + current_sha = get_current_sha(workspace) + if not current_sha: + return { + "status": "ok", + "workspace": workspace, + "git_available": False, + "message": "workspace is not a git repo (or git unavailable)", + "changed_files": [], + "symbols": [], + "impact": [], + } + + last_sha = get_last_indexed_sha(workspace, db_path) + # Tracked changes since last index (or working-tree vs HEAD when no + # bookmark yet) + untracked new files. + changed_rel = set(get_changed_files(workspace, since_sha=last_sha)) + changed_rel |= set(get_untracked_files(workspace)) + changed_files = sorted(changed_rel) + + # Symbols defined in the changed files (from the flat backend registry). + symbols: List[Dict[str, Any]] = [] + try: + from registry import load_backend_registry + backend = load_backend_registry(workspace) + for node in backend.get("nodes", []): + if node.get("file", "") in changed_rel: + symbols.append({ + "id": node.get("id", ""), + "name": node.get("fn", node.get("name", "")), + "type": node.get("type", "function"), + "file": node.get("file", ""), + "line": node.get("line", 0), + }) + except Exception: + logger.debug("git-aware diff: failed to read backend registry", exc_info=True) + + # Downstream impact — callers of each changed symbol from the graph. + # Empty when graph tables aren't populated (e.g. incremental-only scan + # — see issue #25). That's a known gap, not a bug in this command. + impact: List[Dict[str, Any]] = [] + try: + from graph_model import ( + graph_tables_populated, find_nodes_by_name, query_callers, + ) + if graph_tables_populated(db_path) and symbols: + for sym in symbols: + # find_nodes_by_name matches by name (not file), so we + # post-filter to the changed file to avoid pulling in + # same-named symbols from other files. + matches = find_nodes_by_name(sym["name"], db_path) + matches = [m for m in matches if m.get("file") == sym["file"]] + callers: List[Dict[str, Any]] = [] + for m in matches: + for c in query_callers(m["node_id"], db_path, max_depth=1): + callers.append({ + "caller": c.get("name", ""), + "caller_file": c.get("file", ""), + "caller_line": c.get("line", 0), + "called": sym["name"], + "called_file": sym["file"], + }) + if callers: + impact.append({ + "symbol": sym["name"], + "file": sym["file"], + "callers_count": len(callers), + "callers": callers, + }) + except Exception: + logger.debug("git-aware diff: graph impact lookup failed", exc_info=True) + + return { + "status": "ok", + "workspace": workspace, + "git_available": True, + "current_sha": current_sha, + "last_indexed_sha": last_sha, + "changed_files_count": len(changed_files), + "changed_files": changed_files, + "symbols_count": len(symbols), + "symbols": symbols, + "impact": impact, + } + + +register_command( + "diff", + "Compare registry snapshots (--git-aware for git-diff delta + impact)", + add_args, + execute, +) + From f8d77cda5c429f9aa300b4168cdeefa25b04e825 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 06:00:34 +0000 Subject: [PATCH 6/8] feat(watch): add --git-mode flag for git-diff polling Watch command now supports --git-mode (default off) which switches from watchdog file events to git-diff polling. Every --interval seconds (default 2.0), runs 'git diff --name-only' + 'git ls-files --others' and re-indexes only the files git knows changed. Behavior matches the BOS decision: keep watchdog as the default real-time path (the issue title says 'replace file-watcher polling' but ripping out watchdog is risky), ADD git-awareness as an alternative/complement. Falls back to mtime polling when git is unavailable or the workspace is not a git repo. The new --interval flag is shared between git-mode and any future polling modes; --debounce remains for coalescing rapid changes. Refs #14. --- scripts/commands/watch.py | 116 +++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/scripts/commands/watch.py b/scripts/commands/watch.py index baa70290..bae69df0 100644 --- a/scripts/commands/watch.py +++ b/scripts/commands/watch.py @@ -28,23 +28,45 @@ def add_args(parser): help="Path to workspace root (auto-detected if omitted)") parser.add_argument("--debounce", "-d", type=float, default=0.5, help="Debounce interval in seconds (default: 0.5)") + parser.add_argument("--git-mode", action="store_true", + help="Use git-diff polling instead of watchdog file events. " + "Checks 'git diff --name-only' every --interval seconds and " + "re-indexes only the files git knows changed. Falls back to " + "watchdog if git is unavailable or the workspace is not a " + "git repo. Default: watchdog (real-time file events).") + parser.add_argument("--interval", type=float, default=2.0, + help="Polling interval in seconds for --git-mode (default: 2.0)") def execute(args, workspace): """Execute the watch command. This is a long-running command that doesn't return a dict.""" - cmd_watch(workspace, debounce=args.debounce) + git_mode = getattr(args, 'git_mode', False) + interval = getattr(args, 'interval', 2.0) + cmd_watch(workspace, debounce=args.debounce, git_mode=git_mode, interval=interval) return {"status": "stopped"} -def cmd_watch(workspace: str, debounce: float = 0.5) -> None: +def cmd_watch(workspace: str, debounce: float = 0.5, + git_mode: bool = False, interval: float = 2.0) -> None: """ Start file watcher for real-time registry updates. Uses debounce to coalesce rapid file changes, prints a clean one-line summary, and writes outline.json + summary.json to .codelens/. + + Two modes (issue #14): + - Default (watchdog): real-time file events via the watchdog library. + - --git-mode: polls 'git diff --name-only' every `interval` seconds + and re-indexes only the files git knows changed. Useful when + watchdog is unavailable or when the agent wants git-aware delta + instead of mtime-based change detection. """ import threading as _threading workspace = os.path.abspath(workspace) + if git_mode: + _watch_git_mode(workspace, interval=interval, debounce=debounce) + return + # ─── Debounce state ──────────────────────────────────── _timer: Optional[_threading.Timer] = None _lock = _threading.Lock() @@ -247,6 +269,96 @@ def on_change_callback(filepath): print('[CodeLens] Stopped.') +def _watch_git_mode( + workspace: str, + interval: float = 2.0, + debounce: float = 0.5, +) -> None: + """Polling watcher that uses git-diff to detect changes (issue #14). + + Every ``interval`` seconds, runs ``git diff --name-only`` (working + tree vs HEAD) + ``git ls-files --others`` (untracked) to enumerate + the files git knows changed since the last poll. If the set is + non-empty, runs an incremental scan and prints a one-line summary. + + Falls back to ``_watch_polling`` (mtime-based) when git is + unavailable or the workspace is not a git repo — the user requested + git-mode but git isn't usable, so we degrade to the next-best + available watcher instead of refusing to start. + """ + try: + from git_aware import get_current_sha, get_changed_files, get_untracked_files + except ImportError: + print('[CodeLens] git_aware module not available; falling back to mtime polling') + _watch_polling(workspace, debounce=debounce) + return + + if not get_current_sha(workspace): + print('[CodeLens] workspace is not a git repo; falling back to mtime polling') + _watch_polling(workspace, debounce=debounce) + return + + print(f'[CodeLens] Git-mode watching {workspace} (poll: {interval}s, debounce: {debounce}s) — Press Ctrl+C to stop') + + import threading as _threading + last_seen: set = set() + _timer: Optional[_threading.Timer] = None + _lock = _threading.Lock() + _pending: set = set() + + def _do_rescan(): + nonlocal _timer + with _lock: + changed = _pending.copy() + _pending.clear() + if not changed: + return + changed_rel = sorted(os.path.relpath(f, workspace) for f in changed) + for rel in changed_rel: + print(f' Changed: {rel}') + scan_result = cmd_scan(workspace, incremental=True) + try: + frontend = load_frontend_registry(workspace) + backend = load_backend_registry(workspace) + save_snapshot(workspace, frontend, backend) + except Exception: + logger.debug("Failed to save snapshot in git-mode", exc_info=True) + summary = write_output_files(workspace, scan_result) + print(_format_watch_summary(summary, changed_count=len(changed))) + + def _schedule_rescan(filepath: str) -> None: + nonlocal _timer + with _lock: + _pending.add(filepath) + if _timer: + _timer.cancel() + _timer = _threading.Timer(debounce, _do_rescan) + _timer.daemon = True + _timer.start() + + try: + while True: + time.sleep(interval) + # Combine tracked changes (vs HEAD) + untracked new files. + rel_paths = set(get_changed_files(workspace, since_sha=None)) + rel_paths |= set(get_untracked_files(workspace)) + abs_paths = { + os.path.join(workspace, rel) + for rel in rel_paths + if os.path.splitext(rel)[1].lower() in _WATCH_EXTENSIONS + and '.codelens' not in rel + } + new_changes = abs_paths - last_seen + if new_changes: + last_seen = abs_paths + for fp in new_changes: + _schedule_rescan(fp) + else: + last_seen = abs_paths + except KeyboardInterrupt: + print('[CodeLens] Stopped.') + + def _format_watch_summary(summary: Dict[str, Any], changed_count: int = 0) -> str: """Format a one-line summary for terminal output.""" now = datetime.now().strftime('%H:%M:%S') From db9924ed1126de4156e0acd931d77f7037ac96a8 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Sun, 28 Jun 2026 06:03:20 +0000 Subject: [PATCH 7/8] test(git): add git_aware + git-status + diff --git-aware tests Adds tests/test_git_aware.py with 32 test cases across 9 classes: - TestCurrentSha: get_current_sha / get_current_branch in & out of git - TestChangedFiles: get_changed_files / get_untracked_files (modified, untracked, since-sha diff) - TestRegistryMeta: set/get_last_indexed_sha round-trip, branch persistence, None handling, clear-via-None - TestBranchSwitch: false on no-bookmark, false on same-branch commit, true after checkout, false when back on original, false outside git - TestRescanRecommended: true when bookmark unset + in git, false when bookmark matches HEAD, true when working tree dirty - TestGitStatusCommand: status=ok with all fields, ok outside git, changed_files_count after edit - TestDiffGitAware: ok outside git, changed files in git repo, symbols extracted from changed files (real scan), impact empty without graph - TestIncrementalGitPath: uses git when bookmark set, mtime fallback when no bookmark, mtime fallback outside git - TestScanStoresBookmark: scan writes bookmark in git repo, no bookmark outside git All git operations use a temp directory + 'git init' so tests don't depend on the CodeLens repo's git state. If git isn't installed, every test is skipped via pytest.mark.skipif('git not available') (NOT failed). Test results: - test_git_aware.py: 32/32 pass in 1.75s - broader suite: 568 pass, 4 pre-existing failures in test_hybrid_engine.py (confidence-field assertions, NOT caused by this PR), 12 skipped Refs #14. --- tests/test_git_aware.py | 514 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 tests/test_git_aware.py diff --git a/tests/test_git_aware.py b/tests/test_git_aware.py new file mode 100644 index 00000000..92db84b6 --- /dev/null +++ b/tests/test_git_aware.py @@ -0,0 +1,514 @@ +"""Tests for git-aware incremental re-index (issue #14). + +Verifies: +1. get_current_sha / get_current_branch return values in a git repo, None outside. +2. get_changed_files returns expected files after a touch + git add. +3. get_last_indexed_sha / set_last_indexed_sha round-trip via registry_meta. +4. detect_branch_switch returns True after a checkout, False otherwise. +5. git-status command returns status=ok with all expected fields. +6. diff --git-aware returns changed files + symbols + impact on a fixture. + +All git operations use a temp directory + `git init` so tests don't +depend on the CodeLens repo's git state. If git isn't installed, every +test is skipped with pytest.skip('git not available') (NOT failed). +""" + +import os +import shutil +import sqlite3 +import subprocess +import sys +import tempfile + +import pytest + +# Add scripts directory to path (matches other test files). +SCRIPT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +sys.path.insert(0, SCRIPT_DIR) + + +# ─── Skip-if-git-missing guard ────────────────────────────────────── + + +def _git_available() -> bool: + """Return True if the ``git`` binary is installed and runnable.""" + try: + result = subprocess.run( + ["git", "--version"], + capture_output=True, text=True, timeout=5, check=False, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return False + + +pytestmark = pytest.mark.skipif( + not _git_available(), + reason="git not available", +) + + +# ─── Fixtures ─────────────────────────────────────────────────────── + + +@pytest.fixture +def tmp_git_repo(): + """Yield a fresh git repo in a temp directory with one initial commit. + + The repo has ``user.name`` / ``user.email`` configured locally so + commits work without relying on the host's global git config. The + default branch is ``main`` (matches modern git defaults). + """ + td = tempfile.mkdtemp(prefix="codelens_git_test_") + env = os.environ.copy() + try: + subprocess.run(["git", "init", "-q", "-b", "main"], cwd=td, check=True) + except subprocess.CalledProcessError: + # Older git without -b flag — fall back to default branch then rename. + subprocess.run(["git", "init", "-q"], cwd=td, check=True) + subprocess.run( + ["git", "symbolic-ref", "HEAD", "refs/heads/main"], + cwd=td, check=True, + ) + subprocess.run(["git", "config", "user.name", "Test"], cwd=td, check=True) + subprocess.run( + ["git", "config", "user.email", "test@example.com"], cwd=td, check=True + ) + # Initial commit so HEAD exists (empty tree commit). + with open(os.path.join(td, "README.md"), "w") as f: + f.write("# test\n") + subprocess.run(["git", "add", "README.md"], cwd=td, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "init", "--allow-empty"], + cwd=td, check=True, + ) + yield td + shutil.rmtree(td, ignore_errors=True) + + +@pytest.fixture +def tmp_non_git_dir(): + """Yield a temp directory that is NOT a git repo.""" + td = tempfile.mkdtemp(prefix="codelens_nogit_test_") + yield td + shutil.rmtree(td, ignore_errors=True) + + +@pytest.fixture +def db_path(tmp_git_repo): + """Yield a SQLite db path inside the tmp git repo (.codelens/codelens.db). + + Pre-creates the registry_meta table so set/get_last_indexed_sha can + be tested without depending on the PersistentRegistry init path. + """ + codelens_dir = os.path.join(tmp_git_repo, ".codelens") + os.makedirs(codelens_dir, exist_ok=True) + path = os.path.join(codelens_dir, "codelens.db") + conn = sqlite3.connect(path) + from git_aware import init_registry_meta + init_registry_meta(conn) + conn.close() + return path + + +# ─── 1. get_current_sha / get_current_branch ──────────────────────── + + +class TestCurrentSha: + """Verify SHA + branch detection in and out of git repos.""" + + def test_returns_sha_in_git_repo(self, tmp_git_repo): + from git_aware import get_current_sha + sha = get_current_sha(tmp_git_repo) + assert sha is not None + assert len(sha) == 40, f"expected 40-char SHA, got {sha!r}" + # All hex chars + int(sha, 16) + + def test_returns_none_outside_git(self, tmp_non_git_dir): + from git_aware import get_current_sha + assert get_current_sha(tmp_non_git_dir) is None + + def test_returns_branch_in_git_repo(self, tmp_git_repo): + from git_aware import get_current_branch + branch = get_current_branch(tmp_git_repo) + assert branch == "main" + + def test_returns_none_branch_outside_git(self, tmp_non_git_dir): + from git_aware import get_current_branch + assert get_current_branch(tmp_non_git_dir) is None + + +# ─── 2. get_changed_files / get_untracked_files ───────────────────── + + +class TestChangedFiles: + """Verify git diff enumeration of changed + untracked files.""" + + def test_no_changes_returns_empty(self, tmp_git_repo): + from git_aware import get_changed_files + assert get_changed_files(tmp_git_repo) == [] + + def test_modified_file_appears(self, tmp_git_repo): + from git_aware import get_changed_files + # Modify the committed README + with open(os.path.join(tmp_git_repo, "README.md"), "a") as f: + f.write("\nnew line\n") + changed = get_changed_files(tmp_git_repo) + assert "README.md" in changed + + def test_untracked_file_appears(self, tmp_git_repo): + from git_aware import get_untracked_files + with open(os.path.join(tmp_git_repo, "new.py"), "w") as f: + f.write("x = 1\n") + untracked = get_untracked_files(tmp_git_repo) + assert "new.py" in untracked + + def test_diff_since_sha_includes_committed_changes(self, tmp_git_repo): + from git_aware import get_current_sha, get_changed_files + old_sha = get_current_sha(tmp_git_repo) + # Commit a new file + with open(os.path.join(tmp_git_repo, "module.py"), "w") as f: + f.write("def foo(): pass\n") + subprocess.run(["git", "add", "module.py"], cwd=tmp_git_repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "add module"], + cwd=tmp_git_repo, check=True, + ) + # Diff vs old_sha should include module.py + changed = get_changed_files(tmp_git_repo, since_sha=old_sha) + assert "module.py" in changed + + +# ─── 3. get/set_last_indexed_sha round-trip ───────────────────────── + + +class TestRegistryMeta: + """Verify registry_meta key/value round-trip + bookmark helpers.""" + + def test_set_then_get_sha(self, tmp_git_repo, db_path): + from git_aware import ( + set_last_indexed_sha, get_last_indexed_sha, + get_current_sha, get_current_branch, + ) + sha = get_current_sha(tmp_git_repo) + branch = get_current_branch(tmp_git_repo) + assert sha is not None + + set_last_indexed_sha(tmp_git_repo, db_path, sha) + assert get_last_indexed_sha(tmp_git_repo, db_path) == sha + # set_last_indexed_sha also persists the branch. + from git_aware import get_last_indexed_branch + assert get_last_indexed_branch(db_path) == branch + + def test_get_returns_none_when_unset(self, tmp_git_repo, db_path): + from git_aware import get_last_indexed_sha + assert get_last_indexed_sha(tmp_git_repo, db_path) is None + + def test_get_returns_none_when_db_missing(self, tmp_git_repo): + from git_aware import get_last_indexed_sha + missing = os.path.join(tmp_git_repo, "nonexistent.db") + assert get_last_indexed_sha(tmp_git_repo, missing) is None + + def test_set_none_clears_bookmark(self, tmp_git_repo, db_path): + from git_aware import ( + set_last_indexed_sha, get_last_indexed_sha, + get_last_indexed_branch, get_current_sha, + ) + sha = get_current_sha(tmp_git_repo) + set_last_indexed_sha(tmp_git_repo, db_path, sha) + assert get_last_indexed_sha(tmp_git_repo, db_path) == sha + # Clear it + set_last_indexed_sha(tmp_git_repo, db_path, None) + assert get_last_indexed_sha(tmp_git_repo, db_path) is None + assert get_last_indexed_branch(db_path) is None + + +# ─── 4. detect_branch_switch ──────────────────────────────────────── + + +class TestBranchSwitch: + """Verify branch-switch detection across checkouts and same-branch commits.""" + + def test_false_when_no_bookmark(self, tmp_git_repo, db_path): + from git_aware import detect_branch_switch + assert detect_branch_switch(tmp_git_repo, db_path) is False + + def test_false_on_same_branch_commit(self, tmp_git_repo, db_path): + """Committing on the same branch must NOT count as a branch switch.""" + from git_aware import ( + set_last_indexed_sha, detect_branch_switch, get_current_sha, + ) + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + # New commit on main + with open(os.path.join(tmp_git_repo, "x.py"), "w") as f: + f.write("x = 1\n") + subprocess.run(["git", "add", "x.py"], cwd=tmp_git_repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "x"], cwd=tmp_git_repo, check=True, + ) + # SHA moved, branch did not — not a switch. + assert detect_branch_switch(tmp_git_repo, db_path) is False + + def test_true_after_checkout(self, tmp_git_repo, db_path): + """Checking out a different branch must count as a switch.""" + from git_aware import ( + set_last_indexed_sha, detect_branch_switch, get_current_sha, + ) + # Bookmark main + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + # Create + checkout a new branch with a new commit (so SHA moves) + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/x"], + cwd=tmp_git_repo, check=True, + ) + with open(os.path.join(tmp_git_repo, "y.py"), "w") as f: + f.write("y = 1\n") + subprocess.run(["git", "add", "y.py"], cwd=tmp_git_repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "y"], cwd=tmp_git_repo, check=True, + ) + assert detect_branch_switch(tmp_git_repo, db_path) is True + + def test_false_when_back_on_original_branch(self, tmp_git_repo, db_path): + """Checkout + checkout-back must NOT count as a switch (same branch).""" + from git_aware import ( + set_last_indexed_sha, detect_branch_switch, get_current_sha, + ) + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + subprocess.run( + ["git", "checkout", "-q", "-b", "feature/y"], + cwd=tmp_git_repo, check=True, + ) + with open(os.path.join(tmp_git_repo, "z.py"), "w") as f: + f.write("z = 1\n") + subprocess.run(["git", "add", "z.py"], cwd=tmp_git_repo, check=True) + subprocess.run( + ["git", "commit", "-q", "-m", "z"], cwd=tmp_git_repo, check=True, + ) + # Switch back to main — main still has its original SHA + branch. + subprocess.run(["git", "checkout", "-q", "main"], cwd=tmp_git_repo, check=True) + # SHA == bookmarked SHA, branch == bookmarked branch → not a switch. + assert detect_branch_switch(tmp_git_repo, db_path) is False + + def test_false_outside_git(self, tmp_non_git_dir): + from git_aware import detect_branch_switch + db = os.path.join(tmp_non_git_dir, "x.db") + assert detect_branch_switch(tmp_non_git_dir, db) is False + + +# ─── 5. rescan_recommended ────────────────────────────────────────── + + +class TestRescanRecommended: + """Verify the 'do I need to re-scan?' predicate.""" + + def test_true_when_bookmark_unset_and_in_git(self, tmp_git_repo, db_path): + from git_aware import rescan_recommended + # No bookmark yet but workspace IS a git repo — recommend first scan. + assert rescan_recommended(tmp_git_repo, db_path) is True + + def test_false_when_bookmark_matches_head(self, tmp_git_repo, db_path): + from git_aware import ( + set_last_indexed_sha, rescan_recommended, get_current_sha, + ) + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + assert rescan_recommended(tmp_git_repo, db_path) is False + + def test_true_when_working_tree_dirty(self, tmp_git_repo, db_path): + from git_aware import ( + set_last_indexed_sha, rescan_recommended, get_current_sha, + ) + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + with open(os.path.join(tmp_git_repo, "README.md"), "a") as f: + f.write("\nmore\n") + assert rescan_recommended(tmp_git_repo, db_path) is True + + +# ─── 6. git-status command ────────────────────────────────────────── + + +class TestGitStatusCommand: + """Verify the git-status command returns status=ok with all fields.""" + + def test_returns_ok_in_git_repo(self, tmp_git_repo, db_path): + from commands.git_status import cmd_git_status + result = cmd_git_status(tmp_git_repo) + assert result["status"] == "ok" + assert result["git_available"] is True + assert result["current_sha"] is not None + assert result["current_branch"] == "main" + assert "last_indexed_sha" in result + assert "last_indexed_branch" in result + assert "changed_files_count" in result + assert "branch_switch_detected" in result + assert "rescan_recommended" in result + + def test_returns_ok_outside_git(self, tmp_non_git_dir): + from commands.git_status import cmd_git_status + result = cmd_git_status(tmp_non_git_dir) + assert result["status"] == "ok" + assert result["git_available"] is False + assert result["current_sha"] is None + assert result["current_branch"] is None + assert result["rescan_recommended"] is False + + def test_changed_files_count_after_edit(self, tmp_git_repo, db_path): + from commands.git_status import cmd_git_status + from git_aware import set_last_indexed_sha, get_current_sha + # Bookmark current HEAD, then dirty the working tree. + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + with open(os.path.join(tmp_git_repo, "README.md"), "a") as f: + f.write("\nedit\n") + result = cmd_git_status(tmp_git_repo) + assert result["changed_files_count"] >= 1 + assert "README.md" in result["changed_files"] + assert result["rescan_recommended"] is True + + +# ─── 7. diff --git-aware ──────────────────────────────────────────── + + +class TestDiffGitAware: + """Verify diff --git-aware returns changed files + symbols on a fixture.""" + + def test_returns_ok_outside_git(self, tmp_non_git_dir): + from commands.diff import cmd_diff_git_aware + result = cmd_diff_git_aware(tmp_non_git_dir) + assert result["status"] == "ok" + assert result["git_available"] is False + assert result["changed_files"] == [] + assert result["symbols"] == [] + assert result["impact"] == [] + + def test_changed_files_in_git_repo(self, tmp_git_repo, db_path): + from commands.diff import cmd_diff_git_aware + with open(os.path.join(tmp_git_repo, "module.py"), "w") as f: + f.write("def foo(): pass\n") + result = cmd_diff_git_aware(tmp_git_repo) + assert result["status"] == "ok" + assert result["git_available"] is True + assert "module.py" in result["changed_files"] + assert result["changed_files_count"] >= 1 + + def test_symbols_extracted_from_changed_files( + self, tmp_git_repo, db_path + ): + """Run a scan so backend.json is populated, then verify diff --git-aware + surfaces symbols defined in changed files.""" + from commands.diff import cmd_diff_git_aware + from commands.scan import cmd_scan + + # Write a python file and scan so the registry has the symbol. + with open(os.path.join(tmp_git_repo, "app.py"), "w") as f: + f.write("def hello():\n return 'hi'\n") + # Add a .codelens to gitignore so the scan output isn't flagged as untracked. + with open(os.path.join(tmp_git_repo, ".gitignore"), "w") as f: + f.write(".codelens/\n") + cmd_scan(tmp_git_repo, incremental=False) + + # Now modify app.py — diff --git-aware should report 'hello' as a symbol + # in the changed file. (Symbols come from backend.json which still has + # the pre-edit definition — that's the point: it shows what WAS there.) + with open(os.path.join(tmp_git_repo, "app.py"), "a") as f: + f.write("\ndef world():\n return 'bye'\n") + + result = cmd_diff_git_aware(tmp_git_repo) + assert result["status"] == "ok" + assert "app.py" in result["changed_files"] + symbol_names = [s["name"] for s in result["symbols"]] + assert "hello" in symbol_names, ( + f"expected 'hello' in symbols, got {symbol_names}" + ) + + def test_impact_empty_without_graph(self, tmp_git_repo, db_path): + """Without a populated graph, impact is empty (issue #25 — known gap).""" + from commands.diff import cmd_diff_git_aware + with open(os.path.join(tmp_git_repo, "x.py"), "w") as f: + f.write("def x(): pass\n") + result = cmd_diff_git_aware(tmp_git_repo) + assert result["impact"] == [] + + +# ─── 8. incremental.find_changed_files git path ───────────────────── + + +class TestIncrementalGitPath: + """Verify incremental.find_changed_files uses git when bookmark is set.""" + + def test_uses_git_when_bookmark_set(self, tmp_git_repo, db_path): + from incremental import find_changed_files + from git_aware import set_last_indexed_sha, get_current_sha + # Bookmark HEAD, then modify a file. + set_last_indexed_sha(tmp_git_repo, db_path, get_current_sha(tmp_git_repo)) + with open(os.path.join(tmp_git_repo, "README.md"), "a") as f: + f.write("\nedit\n") + # all_files can be empty — git path doesn't need it. + changed, new, deleted = find_changed_files( + tmp_git_repo, [], db_path=db_path, + ) + # changed is absolute paths; README.md must be in there. + rel_changed = {os.path.relpath(c, tmp_git_repo) for c in changed} + assert "README.md" in rel_changed + # git path folds new into changed; new is always []. + assert new == [] + + def test_falls_back_to_mtime_when_no_bookmark( + self, tmp_git_repo, db_path + ): + """When no bookmark is set, the mtime path runs (no last_indexed_sha).""" + from incremental import find_changed_files, save_mtimes + # Pre-populate mtimes cache so the mtime path can detect the change. + readme = os.path.join(tmp_git_repo, "README.md") + save_mtimes(tmp_git_repo, {"README.md": os.path.getmtime(readme)}) + # Modify the file + with open(readme, "a") as f: + f.write("\nedit\n") + # No bookmark in registry_meta → mtime fallback should run. + changed, new, deleted = find_changed_files( + tmp_git_repo, [readme], db_path=db_path, + ) + assert any(os.path.relpath(c, tmp_git_repo) == "README.md" for c in changed) + + def test_falls_back_to_mtime_outside_git(self, tmp_non_git_dir): + from incremental import find_changed_files, save_mtimes + # Outside git → always mtime path. + fpath = os.path.join(tmp_non_git_dir, "x.py") + with open(fpath, "w") as f: + f.write("x = 1\n") + save_mtimes(tmp_non_git_dir, {"x.py": os.path.getmtime(fpath) + 100}) + changed, new, deleted = find_changed_files( + tmp_non_git_dir, [fpath], db_path=None, + ) + assert any(os.path.relpath(c, tmp_non_git_dir) == "x.py" for c in changed) + + +# ─── 9. End-to-end scan stores the bookmark ───────────────────────── + + +class TestScanStoresBookmark: + """Verify scan.py persists last_indexed_sha after a successful scan.""" + + def test_scan_writes_bookmark_in_git_repo(self, tmp_git_repo, db_path): + from commands.scan import cmd_scan + from git_aware import get_last_indexed_sha, get_current_sha + # .codelens is gitignored so the scan output doesn't appear as untracked. + with open(os.path.join(tmp_git_repo, ".gitignore"), "w") as f: + f.write(".codelens/\n") + result = cmd_scan(tmp_git_repo, incremental=False) + assert result["status"] == "ok" + # Bookmark should now be set to current HEAD. + bookmarked = get_last_indexed_sha(tmp_git_repo, db_path) + assert bookmarked == get_current_sha(tmp_git_repo) + # Scan output also surfaces the bookmark. + assert result["git"]["last_indexed_sha"] == bookmarked + + def test_scan_no_bookmark_outside_git(self, tmp_non_git_dir): + from commands.scan import cmd_scan + result = cmd_scan(tmp_non_git_dir, incremental=False) + assert result["status"] == "ok" + # No git → no bookmark. + assert result["git"]["last_indexed_sha"] is None + assert result["git"]["last_indexed_branch"] is None From f8919196547480f8112234f124432b6d9cf3de9f Mon Sep 17 00:00:00 2001 From: worker-2-a Date: Sun, 28 Jun 2026 06:05:32 +0000 Subject: [PATCH 8/8] docs(git): update README, SKILL-QUICK, CHANGELOG, agent-integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README.md: - Add 'Git-Aware Re-Index (v8.2)' to Features section - Add 'git-status' and 'watch --git-mode/--interval' to Setup & Lifecycle command table - Add git_aware.py to the scripts/ architecture tree SKILL-QUICK.md: - Add 'git-status' to Setup & Lifecycle category (now 8+ commands) - Add 'watch [--git-mode] [--interval SECS]' to watch entry - Add 'what changed?' -> 'diff --git-aware' to trigger map - Add 'do I need to re-scan?' -> 'git-status' to trigger map CHANGELOG.md ([8.2.0] section): - Add 'Git-Aware Incremental Re-Index (issue #14)' subsection with Added / Changed / Non-Breaking / Known Gaps entries - Note that issue #25 (incremental graph population) is NOT made worse references/agent-integration.md (Section 9): - Add '9.4 Git-Aware Workflow (v8.2+)' with 5-step flow diagram covering git-status -> scan --incremental -> diff --git-aware -> impact review -> branch-switch full-scan - Document branch-switch detection semantics - Document watch --git-mode behavior - Note issue #25 known gap All doc changes are ADDITIVE — no existing sections rewritten. Other parallel workers (2-a, 2-b) may also add their own sections to the same files. Refs #14. --- CHANGELOG.md | 104 ++++++++++++++++++++++++++++++++ README.md | 5 +- SKILL-QUICK.md | 6 +- references/agent-integration.md | 59 ++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde2f693..155c4b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,110 @@ graph traversal logic. population, query_callers, query_callees, re-population idempotency, and the trace pilot A/B comparison. +### Git-Aware Incremental Re-Index (issue #14) + +Replaces the file-watcher-only change detection with optional git-diff +awareness so incremental scans target exactly the files git knows changed +(tracked + untracked), instead of relying solely on filesystem mtimes. +All features gracefully degrade to None / [] / False / mtime-fallback +when git is unavailable or the workspace is not a git repo. + +### Added (git-aware) + +- **`scripts/git_aware.py`** — New module implementing the git-aware + change-detection layer: + - `get_current_sha(workspace)` / `get_current_branch(workspace)` — + HEAD SHA + branch (None when not a git repo). + - `get_changed_files(workspace, since_sha=None)` — `git diff + --name-only` (HEAD or ``). + - `get_untracked_files(workspace)` — `git ls-files --others + --exclude-standard` so newly-created (not-yet-added) files are + visible to incremental scans. + - `get_last_indexed_sha` / `set_last_indexed_sha` — registry_meta + key/value bookmark of the HEAD SHA + branch at the time of the last + successful scan. + - `detect_branch_switch(workspace, db_path)` — True when HEAD moved + AND the branch name changed (catches `git checkout`, not same-branch + commits). + - `rescan_recommended(workspace, db_path)` — True when a branch + switch is detected OR any changed files exist since the last index. + - `init_registry_meta(conn)` — creates the `registry_meta(key TEXT + PRIMARY KEY, value TEXT)` table (idempotent). + +- **`scripts/commands/git_status.py`** — New `git-status` command + (auto-registered). Single-call "do I need to re-scan?" check for AI + agents. Reports: current_sha, current_branch, last_indexed_sha, + last_indexed_branch, changed_files_count, branch_switch_detected, + rescan_recommended. Always returns status=ok; git-unavailable is + reported via git_available=False (not an error). + +- **`tests/test_git_aware.py`** — 32 test cases across 9 classes + (TestCurrentSha, TestChangedFiles, TestRegistryMeta, TestBranchSwitch, + TestRescanRecommended, TestGitStatusCommand, TestDiffGitAware, + TestIncrementalGitPath, TestScanStoresBookmark). All git operations + use a temp directory + `git init` — no dependency on the CodeLens + repo's git state. Tests skip with `pytest.skip('git not available')` + when git is missing. + +### Changed (git-aware) + +- **`scripts/incremental.py`** — `find_changed_files` now tries the + git-aware path FIRST: if a `last_indexed_sha` bookmark exists in + `registry_meta`, uses `git diff --name-only` + `git ls-files + --others` to enumerate exactly the files git knows changed. Deleted + files (in diff but not on disk) are returned in the deleted slot so + `scan.py`'s existing deletion-cleanup path runs unchanged. Falls back + to the existing mtime-based detection when git is unavailable, no + bookmark is stored, or any unexpected error occurs. Signature is + backward-compatible — `db_path` is a new optional kwarg. +- **`scripts/commands/scan.py`** — After a successful scan (full or + incremental), if git is available, persists `last_indexed_sha` + + `last_indexed_branch` via `set_last_indexed_sha()`. Scan output now + includes a `git` field with `{last_indexed_sha, last_indexed_branch}` + so agents can verify the bookmark was recorded. Fail-soft: if the + bookmark write fails, the scan still succeeds. +- **`scripts/commands/diff.py`** — New `--git-aware` flag. When set, + the diff command produces a single-call "what changed + what's + affected" view: changed_files (from git), symbols (from flat backend + registry, filtered to changed files), impact (callers from + `graph_model.query_callers` when graph tables are populated). Default + snapshot-diff behavior is unchanged — `--git-aware` is purely + additive. Falls back to `git_available=False` when git is unavailable + (status stays "ok"). +- **`scripts/commands/watch.py`** — New `--git-mode` flag (default + off). When set, switches from watchdog file events to git-diff + polling: every `--interval` seconds (default 2.0), runs `git diff + --name-only` + `git ls-files --others` and re-indexes only the files + git knows changed. Falls back to mtime polling when git is + unavailable or the workspace is not a git repo. Default watchdog + behavior is preserved (BOS decision: keep watchdog as default, ADD + git-awareness as alternative). +- **`scripts/persistent_registry.py`** — Calls `init_registry_meta(conn)` + during `_init_schema` so the `registry_meta` table always exists by + the time any git-aware function tries to read or write a bookmark. + Additive — no existing table or column modified. + +### Non-Breaking (git-aware) + +- All 56 existing CLI commands continue to work unchanged. +- The git-aware layer is purely additive — when git is unavailable, all + functions return None / [] / False and the existing mtime path runs. +- The `registry_meta` table is additive — no existing table or column + was modified. +- Scan output gained a new top-level `git` field; existing fields are + untouched. +- The `diff --git-aware` flag is opt-in; default `diff` behavior is + unchanged. + +### Known Gaps (NOT made worse by this change) + +- **Issue #25 (incremental graph population)** — Incremental scans + still don't populate the graph tables (`graph_nodes` + `graph_edges`); + only full scans do. `diff --git-aware` reports an empty `impact` array + when graph tables aren't populated (e.g. after an incremental-only + scan). This is a pre-existing gap tracked in #25 and is NOT made + worse by this change. + ### Changed - **`scripts/persistent_registry.py`** — Calls `init_graph_schema(conn)` diff --git a/README.md b/README.md index 3f748509..d3fa1e4d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ CodeLens is an AI-native code intelligence platform that gives AI agents **full - **Regex Fallback Parsers** — 28 additional languages supported via regex-based parsers (C, C++, Go, Java, Kotlin, Swift, Ruby, PHP, Scala, Dart, Elixir, Lua, R, Haskell, Nim, Objective-C, GDScript, Shell, Vim, Zig, and more) - **Framework Auto-Detection** — React/Next.js, Vue, Svelte, Tailwind CSS, Express, Fastify, Koa, Hono, Django, Flask, FastAPI, Tauri, and more - **Incremental Scanning** — Only re-parse changed files for speed, with SQLite persistent registry storage +- **Git-Aware Re-Index (v8.2)** — `scan --incremental` uses `git diff --name-only` to enumerate exactly the files git knows changed (mtime fallback when git unavailable). `git-status` reports the HEAD/last-indexed SHA + branch + changed-files count + re-scan recommendation in one call. `diff --git-aware` shows changed files + symbols + downstream caller impact. `watch --git-mode` polls `git diff --name-only` instead of watchdog file events. All features gracefully degrade when git is unavailable - **Workspace Auto-Detect** — No need to specify workspace path if you're already in the project - **AI-Optimized Output** — `--format ai` and `--lite` flags for token-efficient AI agent consumption - **Auto-Fix Engine** — Confidence-scored auto-fixes with dry-run-by-default safety @@ -82,7 +83,8 @@ python3 scripts/codelens.py query "myFunction" --lite | `scan [workspace] [--incremental] [--full] [--max-files N]` | Scan workspace and build registry | | `validate [workspace]` | Validate registry vs file system | | `detect [workspace]` | Detect frameworks and show recommended config | -| `watch [workspace]` | Start file watcher for real-time registry updates | +| `watch [workspace] [--git-mode] [--interval SECS]` | Start file watcher (default: watchdog; `--git-mode` polls `git diff --name-only`) | +| `git-status [workspace]` | Show git-aware scan state: HEAD SHA, last-indexed SHA, changed files, re-scan recommendation | | `migrate [workspace]` | Migrate JSON registry to SQLite persistent database | | `serve` | Start MCP server for AI agent integration (JSON-RPC over stdio) | | `lsp-status` | Check which LSP servers are available for `--deep` analysis | @@ -229,6 +231,7 @@ codelens/ │ ├── incremental.py # Incremental scan support │ ├── edge_resolver.py # Cross-file edge resolution │ ├── graph_model.py # Graph data model (nodes + edges) — issue #8 +│ ├── git_aware.py # Git-diff aware incremental re-index — issue #14 │ ├── search_engine.py # Regex code search │ ├── trace_engine.py # Call chain tracing │ ├── impact_engine.py # Change impact analysis diff --git a/SKILL-QUICK.md b/SKILL-QUICK.md index 1874c7fc..f056d231 100755 --- a/SKILL-QUICK.md +++ b/SKILL-QUICK.md @@ -61,6 +61,8 @@ $CLI complexity --top 5 --lite # → Top 5 most complex, minimal output | Intent | Command | |--------|---------| | Create/edit/delete code | `query` → write → `scan --incremental` | +| "what changed?" | `diff --git-aware` | +| "do I need to re-scan?" | `git-status` | | "does this exist?" | `query --lite` | | "who calls this?" | `trace --direction up` | | "safe to delete?" | `impact` → `dead-code` | @@ -106,8 +108,8 @@ $CLI complexity --top 5 --lite # → Top 5 most complex, minimal output ## All 56 Commands -### Setup & Lifecycle (8) -`init` · `scan [--incremental] [--max-files N] [--full]` · `validate` · `detect` · `watch [--debounce SECS]` · `migrate` · `serve` · `lsp-status` +### Setup & Lifecycle (8+) +`init` · `scan [--incremental] [--max-files N] [--full]` · `validate` · `detect` · `watch [--debounce SECS] [--git-mode] [--interval SECS]` · `git-status` · `migrate` · `serve` · `lsp-status` ### Pre-Write Safety (5) `query "name" [--domain ...] [--fuzzy]` · `impact "name" [--action modify|delete]` · `refactor-safe "name" [--action rename|move]` · `guard (--pre|--post) --file PATH` · `check [--severity ...] [--max-findings N]` diff --git a/references/agent-integration.md b/references/agent-integration.md index 17ffcbb7..dfcf9bcc 100755 --- a/references/agent-integration.md +++ b/references/agent-integration.md @@ -1155,6 +1155,65 @@ Step 5: Final audit: "After cleanup: 0 dead entries remaining." ``` +### 9.4 Git-Aware Workflow (v8.2+) + +CodeLens v8.2 adds optional git-diff awareness to incremental scans so +agents can target exactly the files git knows changed, instead of +re-parsing every file with a bumped mtime. All git-aware features +degrade gracefully to None / mtime-fallback when git is unavailable or +the workspace is not a git repo — no special-casing required in agent +code. + +``` +Step 1: codelens_git_status(workspace) + │ + ├── rescan_recommended: false ──► skip scan, registry is current + │ + └── rescan_recommended: true ──► proceed to Step 2 + │ + ▼ +Step 2: codelens_scan(workspace, incremental=True) + ↑ uses `git diff --name-only` to pick files; + mtime fallback runs automatically when git unavailable + │ + ▼ +Step 3: codelens_diff(workspace, git_aware=True) + │ + ├── changed_files[] ← files git knows changed since last index + ├── symbols[] ← backend nodes in those files + └── impact[] ← callers of those symbols (graph BFS) + │ + ▼ +Step 4: For each impact[].callers[] entry: + - Read the caller's file + line to understand the call site + - Decide if the change is safe (signature preserved) or + breaking (signature changed → run `refactor-safe` next) + │ + ▼ +Step 5: If branch switch detected (git checkout rewrote many files): + codelens_scan(workspace, incremental=False) + ↑ full scan recommended — incremental may miss cross-file + edges that a checkout rewrote atomically +``` + +**Branch switch detection**: `git-status` reports `branch_switch_detected: true` +when the current HEAD SHA differs from `last_indexed_sha` AND the branch +name differs from `last_indexed_branch`. Same-branch commits do NOT +trigger this (SHA moves but branch doesn't) — only `git checkout` to a +different branch does. + +**Watch mode**: `watch --git-mode` polls `git diff --name-only` every +`--interval` seconds (default 2.0) instead of using watchdog file +events. Useful when watchdog is unavailable or when the agent wants +git-aware delta instead of mtime-based change detection. Falls back to +mtime polling when git is unavailable. + +**Known gap (issue #25)**: Incremental scans do NOT populate the graph +tables (`graph_nodes` + `graph_edges`) — only full scans do. When the +graph is empty, `diff --git-aware` returns an empty `impact` array. +This is a pre-existing gap tracked in #25 and is NOT made worse by the +git-aware layer. + --- ## 10. Programmatic Registry File Access