gitcortex 0.2.2

Git-aware code knowledge graph — incremental AST indexing on every commit, MCP server for AI assistants
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::{fs, path::Path};

use anyhow::{Context, Result};
use gitcortex_core::store::GraphStore;
use gitcortex_indexer::IncrementalIndexer;
use gitcortex_store::kuzu::KuzuGraphStore;

use super::helpers::current_branch;

const HOOK_NAMES: &[(&str, &str)] = &[
    ("post-commit", "gcx hook\n"),
    ("post-merge", "gcx hook\n"),
    ("post-rewrite", "gcx hook\n"),
    ("post-checkout", "gcx hook --branch-switch\n"),
];

const HOOK_SHEBANG: &str =
    "#!/usr/bin/env sh\nset -e\nexport PATH=\"$HOME/.cargo/bin:$HOME/.local/bin:/usr/local/bin:$PATH\"\n";

const AGENT_GUIDE: &str = r#"# GitCortex Agent Guide

This repository is indexed by [GitCortex](https://github.com/bharath03-a/GitCortex).
The MCP server is configured in `mcp.json` (or your editor's equivalent). Use these
tools to navigate the codebase — they read the live knowledge graph, not grep output.

## Available MCP Tools

| Tool | Description |
|------|-------------|
| `lookup_symbol(name)` | Find any struct, function, trait, or class by name |
| `find_callers(function_name)` | Who calls this function? |
| `find_callees(function_name, depth)` | What does this function call? (forward trace) |
| `list_definitions(file)` | All symbols defined in a file |
| `find_implementors(trait_name)` | Who implements this trait or interface? |
| `trace_path(from, to)` | All call paths from A to B |
| `list_symbols_in_range(file, start, end)` | Symbols overlapping a line range |
| `find_unused_symbols(branch)` | Dead code candidates (0 callers) |
| `get_subgraph(seed_name, depth, direction)` | Everything around a symbol |
| `detect_changes(base_branch)` | Changed symbols + blast radius vs another branch |

## Workflows

**Navigating unfamiliar code**
1. `lookup_symbol("ThingYouHeardAbout")` — confirm it exists and find the file
2. `list_definitions("path/to/file.rs")` — see the full shape of a file
3. `get_subgraph("ThingYouHeardAbout", 2, "both")` — visualise its neighbours

**Debugging**
1. `lookup_symbol("failingFn")` — confirm location
2. `find_callers("failingFn")` — walk up the call chain
3. Repeat until you reach an entry point

**Impact analysis before changing a public API**
1. `find_callers("publicFn")` — direct callers
2. `get_subgraph("publicFn", 3, "in")` — full upstream blast radius
3. `find_implementors("TraitYouAreChanging")` — all implementors that must change

**Safe refactoring**
1. `find_unused_symbols(branch)` — find candidates for deletion
2. `list_symbols_in_range(file, start, end)` — map a diff hunk to graph nodes
3. `trace_path(from, to)` — verify a code path before removing an intermediate

## Slash commands (Claude Code / Cursor)
- `/gcx-lookup <name>` — `lookup_symbol` with formatted output
- `/gcx-callers <name>` — `find_callers` with call chain summary
- `/gcx-file <path>` — `list_definitions` ordered by line
- `/gcx-blast-radius` — changed symbols + risk report vs main
"#;

pub fn install_hooks(repo_root: &Path) -> Result<usize> {
    let hooks_dir = repo_root.join(".git").join("hooks");
    fs::create_dir_all(&hooks_dir)?;

    let mut installed = 0;
    for (name, body) in HOOK_NAMES {
        let path = hooks_dir.join(name);
        if path.exists() {
            let existing = fs::read_to_string(&path)?;
            if existing.contains("gcx hook") {
                continue;
            }
            fs::write(&path, format!("{existing}\n{body}"))?;
        } else {
            fs::write(&path, format!("{HOOK_SHEBANG}{body}"))?;
        }
        #[cfg(unix)]
        {
            let mut perms = fs::metadata(&path)?.permissions();
            perms.set_mode(0o755);
            fs::set_permissions(&path, perms)?;
        }
        installed += 1;
    }
    Ok(installed)
}

pub fn initial_index(repo_root: &Path) -> Result<(usize, usize)> {
    let mut store = KuzuGraphStore::open(repo_root).context("failed to open graph store")?;
    let branch = current_branch(repo_root)?;

    if store.last_indexed_sha(&branch)?.is_none() {
        let indexer = IncrementalIndexer::new(repo_root).context("failed to create indexer")?;
        let (diff, head_sha) = indexer.run(None).context("initial index failed")?;
        store.apply_diff(&branch, &diff).context("apply diff")?;
        store
            .set_last_indexed_sha(&branch, &head_sha)
            .context("persist sha")?;
    }

    let nodes = store.list_all_nodes(&branch)?.len();
    let edges = store.list_all_edges(&branch)?.len();
    Ok((nodes, edges))
}

pub fn write_agent_guide(repo_root: &Path) -> Result<()> {
    let dir = repo_root.join(".gitcortex");
    fs::create_dir_all(&dir)?;
    let path = dir.join("AGENT_GUIDE.md");
    if !path.exists() {
        fs::write(path, AGENT_GUIDE).context("write AGENT_GUIDE.md")?;
    }
    Ok(())
}

pub fn write_ci_workflow(repo_root: &Path) -> Result<()> {
    const GH_WORKFLOW: &str = r#"name: GitCortex Blast Radius

on:
  pull_request:

jobs:
  blast-radius:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install gcx
        run: cargo install --git https://github.com/bharath03-a/GitCortex --bin gcx

      - name: Index repository
        run: gcx init

      - name: Run blast-radius analysis
        run: |
          gcx blast-radius \
            --base ${{ github.base_ref }} \
            --head ${{ github.head_ref }} \
            --format github-comment > /tmp/blast-radius.md

      - name: Post PR comment
        uses: marocchino/sticky-pull-request-comment@v2
        with:
          path: /tmp/blast-radius.md
"#;
    let dir = repo_root.join(".github").join("workflows");
    fs::create_dir_all(&dir)?;
    let path = dir.join("gcx-blast-radius.yml");
    if !path.exists() {
        fs::write(path, GH_WORKFLOW).context("write gcx-blast-radius.yml")?;
    }
    Ok(())
}