Skip to main content

llm_wiki/
git.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use git2::{Delta, Repository, Signature};
6use serde::{Deserialize, Serialize};
7
8/// Initialise a new git repository at `path`.
9pub fn init_repo(path: &Path) -> Result<()> {
10    Repository::init(path)
11        .with_context(|| format!("failed to init git repo at {}", path.display()))?;
12    Ok(())
13}
14
15fn make_signature(repo: &Repository) -> Result<Signature<'_>> {
16    repo.signature()
17        .or_else(|_| Signature::now("llm-wiki", "llm-wiki@localhost"))
18        .context("failed to create git signature")
19}
20
21/// Stage all files and commit. Returns empty string if nothing to commit.
22pub fn commit(repo_root: &Path, message: &str) -> Result<String> {
23    let repo = Repository::open(repo_root)
24        .with_context(|| format!("failed to open repo at {}", repo_root.display()))?;
25
26    let sig = make_signature(&repo)?;
27    let mut index = repo.index()?;
28    index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
29    index.write()?;
30    let tree_oid = index.write_tree()?;
31    let tree = repo.find_tree(tree_oid)?;
32
33    let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
34
35    // Skip if tree matches parent (nothing changed)
36    if let Some(ref p) = parent
37        && p.tree_id() == tree_oid
38    {
39        return Ok(String::new());
40    }
41
42    let parents: Vec<&git2::Commit> = parent.iter().collect();
43    let oid = repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
44    Ok(oid.to_string())
45}
46
47/// Stage specific paths and commit. Returns empty string if nothing to commit.
48pub fn commit_paths(repo_root: &Path, paths: &[&Path], message: &str) -> Result<String> {
49    let repo = Repository::open(repo_root)
50        .with_context(|| format!("failed to open repo at {}", repo_root.display()))?;
51
52    let sig = make_signature(&repo)?;
53    let mut index = repo.index()?;
54    for path in paths {
55        let rel = path.strip_prefix(repo_root).unwrap_or(path);
56        index.add_path(rel)?;
57    }
58    index.write()?;
59    let tree_oid = index.write_tree()?;
60    let tree = repo.find_tree(tree_oid)?;
61
62    let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
63
64    if let Some(ref p) = parent
65        && p.tree_id() == tree_oid
66    {
67        return Ok(String::new());
68    }
69
70    let parents: Vec<&git2::Commit> = parent.iter().collect();
71    let oid = repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
72    Ok(oid.to_string())
73}
74
75/// Get current HEAD commit hash. Returns None if repo has no commits.
76pub fn current_head(repo_root: &Path) -> Option<String> {
77    let repo = Repository::open(repo_root).ok()?;
78    let head = repo.head().ok()?.peel_to_commit().ok()?;
79    Some(head.id().to_string())
80}
81
82// ── Change detection ──────────────────────────────────────────────────────────
83
84/// A file that changed between git tree states.
85#[derive(Debug, Clone)]
86pub struct ChangedFile {
87    /// Repository-relative path of the changed file.
88    pub path: PathBuf,
89    /// Git delta status (Added, Modified, Deleted, etc.).
90    pub status: Delta,
91}
92
93/// Detect changed `.md` files under `wiki/` in the working tree vs HEAD.
94pub fn changed_wiki_files(repo_root: &Path, wiki_root: &Path) -> Result<Vec<ChangedFile>> {
95    let repo = Repository::open(repo_root)
96        .with_context(|| format!("failed to open repo at {}", repo_root.display()))?;
97    let head_tree = repo
98        .head()
99        .and_then(|h| h.peel_to_tree())
100        .context("no HEAD commit")?;
101    let mut opts = git2::DiffOptions::new();
102    opts.include_untracked(true).recurse_untracked_dirs(true);
103    let diff = repo.diff_tree_to_workdir_with_index(Some(&head_tree), Some(&mut opts))?;
104    let prefix = wiki_root
105        .strip_prefix(repo_root)
106        .unwrap_or(Path::new("wiki"));
107    Ok(collect_md_changes(&diff, prefix))
108}
109
110/// Detect changed `.md` files under `wiki/` between a past commit and HEAD.
111pub fn changed_since_commit(
112    repo_root: &Path,
113    wiki_root: &Path,
114    from_commit: &str,
115) -> Result<Vec<ChangedFile>> {
116    let repo = Repository::open(repo_root)
117        .with_context(|| format!("failed to open repo at {}", repo_root.display()))?;
118    let from_oid = git2::Oid::from_str(from_commit)
119        .with_context(|| format!("invalid commit hash: {from_commit}"))?;
120    let from_tree = repo.find_commit(from_oid)?.tree()?;
121    let head_tree = repo
122        .head()
123        .and_then(|h| h.peel_to_tree())
124        .context("no HEAD commit")?;
125    let diff = repo.diff_tree_to_tree(Some(&from_tree), Some(&head_tree), None)?;
126    let prefix = wiki_root
127        .strip_prefix(repo_root)
128        .unwrap_or(Path::new("wiki"));
129    Ok(collect_md_changes(&diff, prefix))
130}
131
132fn collect_md_changes(diff: &git2::Diff, wiki_prefix: &Path) -> Vec<ChangedFile> {
133    let mut changes = Vec::new();
134    diff.foreach(
135        &mut |delta, _| {
136            let path = delta.new_file().path().or_else(|| delta.old_file().path());
137            if let Some(p) = path
138                && p.starts_with(wiki_prefix)
139                && p.extension().and_then(|e| e.to_str()) == Some("md")
140            {
141                changes.push(ChangedFile {
142                    path: p.to_path_buf(),
143                    status: delta.status(),
144                });
145            }
146            true
147        },
148        None,
149        None,
150        None,
151    )
152    .ok();
153    changes
154}
155
156/// Collect all changed `.md` files by merging two git diffs:
157/// - Working tree vs HEAD (uncommitted changes)
158/// - `last_indexed_commit` vs HEAD (commits since last index update)
159///
160/// Working tree changes overwrite commit-based changes on duplicates.
161pub fn collect_changed_files(
162    repo_root: &Path,
163    wiki_root: &Path,
164    last_indexed_commit: Option<&str>,
165) -> Result<HashMap<PathBuf, Delta>> {
166    let mut changes = HashMap::new();
167
168    // B: last indexed commit vs HEAD (insert first so A wins on duplicates)
169    if let Some(from_hash) = last_indexed_commit
170        && let Ok(files) = changed_since_commit(repo_root, wiki_root, from_hash)
171    {
172        for f in files {
173            changes.insert(f.path, f.status);
174        }
175    }
176
177    // A: working tree vs HEAD (overwrites B on duplicates)
178    if let Ok(files) = changed_wiki_files(repo_root, wiki_root) {
179        for f in files {
180            changes.insert(f.path, f.status);
181        }
182    }
183
184    Ok(changes)
185}
186
187// ── Page history ──────────────────────────────────────────────────────────────
188
189/// A single entry from `git log` for a wiki page.
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct HistoryEntry {
192    /// Full commit SHA-1 hash.
193    pub hash: String,
194    /// ISO-8601 author date string.
195    pub date: String,
196    /// Commit subject line.
197    pub message: String,
198    /// Author name.
199    pub author: String,
200}
201
202/// Return git commit history for a file path relative to repo root.
203/// Uses `git log` (shell) for simplicity and built-in `--follow` support.
204pub fn page_history(
205    repo_root: &Path,
206    rel_path: &Path,
207    limit: usize,
208    follow: bool,
209) -> Result<Vec<HistoryEntry>> {
210    let mut cmd = std::process::Command::new("git");
211    cmd.current_dir(repo_root)
212        .args(["log", "--format=%H%x00%aI%x00%s%x00%an"]);
213    if follow {
214        cmd.arg("--follow");
215    }
216    if limit > 0 {
217        cmd.args(["-n", &limit.to_string()]);
218    }
219    cmd.arg("--").arg(rel_path);
220
221    let output = cmd
222        .output()
223        .context("failed to run git log — is git installed?")?;
224
225    if !output.status.success() {
226        let stderr = String::from_utf8_lossy(&output.stderr);
227        // Empty history is not an error (new file, no commits yet)
228        if stderr.is_empty() {
229            return Ok(Vec::new());
230        }
231        anyhow::bail!("git log failed: {stderr}");
232    }
233
234    let stdout = String::from_utf8_lossy(&output.stdout);
235    let mut entries = Vec::new();
236    for line in stdout.lines() {
237        if line.is_empty() {
238            continue;
239        }
240        let parts: Vec<&str> = line.splitn(4, '\0').collect();
241        if parts.len() == 4 {
242            entries.push(HistoryEntry {
243                hash: parts[0].to_string(),
244                date: parts[1].to_string(),
245                message: parts[2].to_string(),
246                author: parts[3].to_string(),
247            });
248        }
249    }
250    Ok(entries)
251}