1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use git2::{Delta, Repository, Signature};
6use serde::{Deserialize, Serialize};
7
8pub 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
21pub 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 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
47pub 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
75pub 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#[derive(Debug, Clone)]
86pub struct ChangedFile {
87 pub path: PathBuf,
89 pub status: Delta,
91}
92
93pub 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
110pub 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
156pub 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct HistoryEntry {
192 pub hash: String,
194 pub date: String,
196 pub message: String,
198 pub author: String,
200}
201
202pub 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 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}