use anyhow::{Context, Result};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::index::layout;
pub const DEFAULT_HISTORY_COMMITS: usize = 500;
const MAX_BODY_BYTES: usize = 4_000;
const MAX_BLAME_FILES: usize = 500;
const AGE_MINUTE: i64 = 60;
const AGE_HOUR: i64 = 60 * AGE_MINUTE;
const AGE_DAY: i64 = 24 * AGE_HOUR;
const AGE_MONTH: i64 = 30 * AGE_DAY;
const AGE_YEAR: i64 = 365 * AGE_DAY;
pub fn history_commit_limit() -> usize {
std::env::var("SEMANTEX_HISTORY_COMMITS")
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_HISTORY_COMMITS)
}
pub fn blame_enabled() -> bool {
std::env::var("SEMANTEX_HISTORY_BLAME")
.map(|v| v == "1")
.unwrap_or(false)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommitInfo {
pub hash: String,
pub author: String,
pub ts: i64,
pub subject: String,
}
pub fn humanize_age(ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let delta = (now - ts).max(0);
if delta < AGE_MINUTE {
format!("{delta}s ago")
} else if delta < AGE_HOUR {
format!("{}m ago", delta / AGE_MINUTE)
} else if delta < AGE_DAY {
format!("{}h ago", delta / AGE_HOUR)
} else if delta < AGE_MONTH {
format!("{}d ago", delta / AGE_DAY)
} else if delta < AGE_YEAR {
format!("{}mo ago", delta / AGE_MONTH)
} else {
format!("{}y ago", delta / AGE_YEAR)
}
}
fn row_to_commit(row: &rusqlite::Row) -> rusqlite::Result<CommitInfo> {
let message: String = row.get(3)?;
let subject = message.lines().next().unwrap_or("").to_string();
Ok(CommitInfo {
hash: row.get(0)?,
author: row.get(1)?,
ts: row.get(2)?,
subject,
})
}
pub fn open(project_root: &Path) -> Result<Connection> {
let conn = layout::open_history_db(&layout::history_db_path(project_root))?;
let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
Ok(conn)
}
pub fn has_history(project_root: &Path) -> bool {
let path = layout::history_db_path(project_root);
if !path.exists() {
return false;
}
let Ok(conn) = Connection::open(&path) else {
return false;
};
let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
conn.query_row("SELECT 1 FROM commits LIMIT 1", [], |_| Ok(()))
.is_ok()
}
pub fn commits_touching_file(
conn: &Connection,
path: &str,
limit: usize,
) -> Result<Vec<CommitInfo>> {
let mut stmt = conn.prepare(
"SELECT c.hash, c.author, c.ts, c.message \
FROM commits c JOIN file_commits f ON f.hash = c.hash \
WHERE f.path = ?1 ORDER BY c.ts DESC, c.rowid DESC LIMIT ?2",
)?;
let rows = stmt
.query_map(params![path, limit as i64], row_to_commit)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn recent_commits(conn: &Connection, limit: usize) -> Result<Vec<CommitInfo>> {
let mut stmt = conn.prepare(
"SELECT hash, author, ts, message FROM commits ORDER BY ts DESC, rowid DESC LIMIT ?1",
)?;
let rows = stmt
.query_map(params![limit as i64], row_to_commit)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
pub fn search_commit_messages(
conn: &Connection,
query: &str,
limit: usize,
) -> Result<Vec<CommitInfo>> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
if commits_fts_exists(conn) {
let fts_result = (|| -> rusqlite::Result<Vec<CommitInfo>> {
let mut stmt = conn.prepare(
"SELECT c.hash, c.author, c.ts, c.message \
FROM commits_fts f JOIN commits c ON c.hash = f.hash \
WHERE commits_fts MATCH ?1 ORDER BY c.ts DESC, c.rowid DESC LIMIT ?2",
)?;
stmt.query_map(params![q, limit as i64], row_to_commit)?
.collect::<rusqlite::Result<Vec<_>>>()
})();
if let Ok(rows) = fts_result {
return Ok(rows);
}
}
let like = format!("%{}%", q.replace(['%', '_'], ""));
let mut stmt = conn.prepare(
"SELECT hash, author, ts, message FROM commits WHERE message LIKE ?1 ORDER BY ts DESC, rowid DESC LIMIT ?2",
)?;
let rows = stmt
.query_map(params![like, limit as i64], row_to_commit)?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> bool {
let sql = format!("PRAGMA table_info({table})");
let Ok(mut stmt) = conn.prepare(&sql) else {
return false;
};
let Ok(mut rows) = stmt.query([]) else {
return false;
};
while let Ok(Some(row)) = rows.next() {
if let Ok(name) = row.get::<_, String>(1)
&& name == column
{
return true;
}
}
false
}
fn ensure_history_extensions(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS history_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
if !column_exists(conn, "commits", "body") {
conn.execute_batch("ALTER TABLE commits ADD COLUMN body TEXT NOT NULL DEFAULT ''")?;
}
Ok(())
}
fn ensure_commits_fts(conn: &Connection) -> bool {
if commits_fts_exists(conn) {
return true;
}
conn.execute_batch("CREATE VIRTUAL TABLE commits_fts USING fts5(hash UNINDEXED, message)")
.is_ok()
}
fn commits_fts_exists(conn: &Connection) -> bool {
conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='commits_fts'",
[],
|_| Ok(()),
)
.is_ok()
}
fn read_state(conn: &Connection, key: &str) -> Option<String> {
conn.query_row(
"SELECT value FROM history_state WHERE key = ?1",
params![key],
|row| row.get(0),
)
.ok()
}
fn write_state(conn: &Connection, key: &str, value: &str) -> Result<()> {
conn.execute(
"INSERT INTO history_state (key, value) VALUES (?1, ?2) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)?;
Ok(())
}
fn run_git(project_root: &Path, args: &[&str]) -> Result<std::process::Output> {
Command::new("git")
.arg("-C")
.arg(project_root)
.args(args)
.output()
.context("failed to spawn `git`")
}
fn resolve_head(project_root: &Path) -> Option<String> {
let output = run_git(project_root, &["rev-parse", "HEAD"]).ok()?;
if !output.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
fn commit_exists(project_root: &Path, sha: &str) -> bool {
run_git(
project_root,
&["cat-file", "-e", &format!("{sha}^{{commit}}")],
)
.map(|o| o.status.success())
.unwrap_or(false)
}
fn rev_list_count(project_root: &Path, range: &str) -> Option<usize> {
let output = run_git(project_root, &["rev-list", "--count", range, "--"]).ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
struct RawCommit {
hash: String,
author: String,
ts: i64,
subject: String,
body: String,
}
fn fetch_commit_metadata(
project_root: &Path,
range: &str,
max_count: Option<usize>,
) -> Result<Vec<RawCommit>> {
let mut args: Vec<String> = vec![
"log".into(),
"--no-color".into(),
"--date=unix".into(),
"--reverse".into(),
];
if let Some(n) = max_count {
args.push("-n".into());
args.push(n.to_string());
}
args.push("--pretty=format:%H%x00%an%x00%ct%x00%s%x00%b%x00".into());
args.push(range.to_string());
args.push("--".into());
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run_git(project_root, &arg_refs)?;
if !output.status.success() {
anyhow::bail!(
"git log failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let text = String::from_utf8_lossy(&output.stdout);
let fields: Vec<&str> = text.split('\0').collect();
let mut commits = Vec::new();
for chunk in fields.chunks_exact(5) {
let hash = chunk[0].trim_start_matches('\n').trim();
if hash.is_empty() {
continue;
}
let Ok(ts) = chunk[2].parse::<i64>() else {
continue;
};
let body = truncate_body(chunk[4].trim());
commits.push(RawCommit {
hash: hash.to_string(),
author: chunk[1].to_string(),
ts,
subject: chunk[3].to_string(),
body,
});
}
Ok(commits)
}
fn truncate_body(body: &str) -> String {
if body.len() <= MAX_BODY_BYTES {
return body.to_string();
}
let mut end = MAX_BODY_BYTES;
while end > 0 && !body.is_char_boundary(end) {
end -= 1;
}
format!("{}…", &body[..end])
}
fn fetch_changed_files(
project_root: &Path,
range: &str,
max_count: Option<usize>,
) -> Result<HashMap<String, Vec<String>>> {
let mut args: Vec<String> = vec![
"-c".into(),
"core.quotepath=off".into(),
"log".into(),
"--no-color".into(),
"--reverse".into(),
"--name-only".into(),
"--relative".into(),
];
if let Some(n) = max_count {
args.push("-n".into());
args.push(n.to_string());
}
args.push("--pretty=format:%x00%H".into());
args.push(range.to_string());
args.push("--".into());
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = run_git(project_root, &arg_refs)?;
if !output.status.success() {
anyhow::bail!(
"git log --name-only failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let text = String::from_utf8_lossy(&output.stdout);
let mut map = HashMap::new();
for block in text.split('\0') {
let mut lines = block.lines();
let Some(hash) = lines.next() else { continue };
let hash = hash.trim();
if hash.is_empty() {
continue;
}
let files: Vec<String> = lines
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
map.insert(hash.to_string(), files);
}
Ok(map)
}
fn store_commit_batch(
conn: &mut Connection,
commits: &[RawCommit],
files_by_hash: &HashMap<String, Vec<String>>,
) -> Result<()> {
let has_fts = ensure_commits_fts(conn);
let tx = conn.transaction()?;
{
let mut insert_commit = tx.prepare(
"INSERT OR IGNORE INTO commits (hash, author, ts, message, body) VALUES (?1, ?2, ?3, ?4, ?5)",
)?;
let mut insert_file =
tx.prepare("INSERT OR IGNORE INTO file_commits (path, hash) VALUES (?1, ?2)")?;
for c in commits {
let message = if c.body.is_empty() {
c.subject.clone()
} else {
format!("{}\n\n{}", c.subject, c.body)
};
insert_commit.execute(params![c.hash, c.author, c.ts, message, c.body])?;
if has_fts {
tx.execute("DELETE FROM commits_fts WHERE hash = ?1", params![c.hash])?;
tx.execute(
"INSERT INTO commits_fts (hash, message) VALUES (?1, ?2)",
params![c.hash, message],
)?;
}
if let Some(files) = files_by_hash.get(&c.hash) {
for f in files {
insert_file.execute(params![f, c.hash])?;
}
}
}
}
tx.commit()?;
Ok(())
}
pub fn populate_history(project_root: &Path) -> Result<()> {
populate_history_with_bound(project_root, history_commit_limit())
}
fn populate_history_with_bound(project_root: &Path, bound: usize) -> Result<()> {
let Some(head) = resolve_head(project_root) else {
return Ok(());
};
let mut conn = open(project_root)?;
ensure_history_extensions(&conn)?;
let last_sha = read_state(&conn, "last_sha");
if last_sha.as_deref() == Some(head.as_str()) {
return Ok(()); }
let (range, max_count): (String, Option<usize>) = match last_sha.as_deref() {
Some(sha) if commit_exists(project_root, sha) => {
let incremental_range = format!("{sha}..HEAD");
let count = rev_list_count(project_root, &incremental_range).unwrap_or(usize::MAX);
if count == 0 {
write_state(&conn, "last_sha", &head)?;
return Ok(());
}
if count > bound {
("HEAD".to_string(), Some(bound))
} else {
(incremental_range, None)
}
}
_ => ("HEAD".to_string(), Some(bound)),
};
let commits = fetch_commit_metadata(project_root, &range, max_count)?;
if commits.is_empty() {
write_state(&conn, "last_sha", &head)?;
return Ok(());
}
let files_by_hash = fetch_changed_files(project_root, &range, max_count).unwrap_or_default();
store_commit_batch(&mut conn, &commits, &files_by_hash)?;
let newest = &commits.last().expect("checked non-empty above").hash;
write_state(&conn, "last_sha", newest)?;
Ok(())
}
pub fn populate_history_best_effort(project_root: &Path, changed_files: &[PathBuf]) {
if let Err(e) = populate_history(project_root) {
tracing::warn!("history population failed (index still valid): {e}");
}
if blame_enabled() {
populate_chunk_blame_best_effort(project_root, changed_files);
}
}
fn populate_chunk_blame_best_effort(project_root: &Path, changed_files: &[PathBuf]) {
if changed_files.is_empty() {
return;
}
if resolve_head(project_root).is_none() {
return; }
let chunks_db = layout::container_dir(project_root).join("chunks.db");
if !chunks_db.exists() {
return;
}
let Ok(history_conn) = open(project_root) else {
return;
};
let Ok(chunks_conn) = Connection::open(&chunks_db) else {
return;
};
let files = if changed_files.len() > MAX_BLAME_FILES {
tracing::warn!(
total = changed_files.len(),
cap = MAX_BLAME_FILES,
"SEMANTEX_HISTORY_BLAME: capping per-build blame fan-out; \
skipped files get blamed when a later build re-touches them"
);
&changed_files[..MAX_BLAME_FILES]
} else {
changed_files
};
for rel in files {
if let Err(e) =
populate_chunk_blame_for_file(project_root, &chunks_conn, &history_conn, rel)
{
tracing::debug!(file = %rel.display(), "chunk blame skipped: {e}");
}
}
}
fn parse_blame_porcelain(output: &str) -> Vec<(u32, String)> {
let mut result = Vec::new();
let mut current: Option<(u32, String)> = None;
for line in output.lines() {
if line.starts_with('\t') {
if let Some(pair) = current.take() {
result.push(pair);
}
continue;
}
let mut it = line.split_whitespace();
let Some(sha) = it.next() else { continue };
if sha.len() != 40 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
continue; }
if sha.bytes().all(|b| b == b'0') {
current = None;
continue;
}
let Some(_orig_line) = it.next() else {
continue;
};
let Some(final_line) = it.next().and_then(|s| s.parse::<u32>().ok()) else {
continue;
};
current = Some((final_line, sha.to_string()));
}
result
}
fn populate_chunk_blame_for_file(
project_root: &Path,
chunks_conn: &Connection,
history_conn: &Connection,
rel_path: &Path,
) -> Result<()> {
let path_str = rel_path.to_string_lossy();
let mut stmt =
chunks_conn.prepare("SELECT id, start_line, end_line FROM chunks WHERE file_path = ?1")?;
let chunk_rows: Vec<(i64, u32, u32)> = stmt
.query_map(params![path_str.as_ref()], |row| {
Ok((
row.get(0)?,
row.get::<_, i64>(1)? as u32,
row.get::<_, i64>(2)? as u32,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if chunk_rows.is_empty() {
return Ok(());
}
let output = Command::new("git")
.arg("-C")
.arg(project_root)
.args(["blame", "--porcelain", "--"])
.arg(rel_path)
.output()
.context("failed to spawn `git blame`")?;
if !output.status.success() {
return Ok(()); }
let text = String::from_utf8_lossy(&output.stdout);
let mut line_hash: HashMap<u32, String> = HashMap::new();
for (line_no, sha) in parse_blame_porcelain(&text) {
line_hash.insert(line_no, sha);
}
let mut insert = history_conn
.prepare("INSERT OR IGNORE INTO chunk_blame (chunk_id, hash) VALUES (?1, ?2)")?;
for (chunk_id, start, end) in chunk_rows {
let mut seen: HashSet<&str> = HashSet::new();
for line_no in start..=end {
if let Some(sha) = line_hash.get(&line_no)
&& seen.insert(sha.as_str())
{
insert.execute(params![chunk_id, sha])?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command as StdCommand;
use tempfile::TempDir;
fn git(dir: &Path, args: &[&str]) {
let status = StdCommand::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.expect("git must be on PATH for these tests");
assert!(status.success(), "git {args:?} failed");
}
fn init_repo(dir: &Path, commit_count: usize) {
git(dir, &["init", "-q"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "Test User"]);
for i in 0..commit_count {
std::fs::write(dir.join(format!("file{i}.txt")), format!("content {i}")).unwrap();
git(dir, &["add", "."]);
git(dir, &["commit", "-q", "-m", &format!("commit number {i}")]);
}
}
#[test]
fn non_git_dir_populate_is_a_silent_no_op() {
let tmp = TempDir::new().unwrap();
populate_history(tmp.path()).unwrap();
assert!(!has_history(tmp.path()));
assert!(!layout::history_db_path(tmp.path()).exists());
}
#[test]
fn populate_records_all_commits_within_bound() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 5);
populate_history(tmp.path()).unwrap();
assert!(has_history(tmp.path()));
let conn = open(tmp.path()).unwrap();
let commits = recent_commits(&conn, 100).unwrap();
assert_eq!(commits.len(), 5);
assert_eq!(commits[0].subject, "commit number 4");
assert_eq!(commits[4].subject, "commit number 0");
assert_eq!(commits[0].author, "Test User");
}
#[test]
fn populate_records_file_commits() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 3);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let hits = commits_touching_file(&conn, "file1.txt", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].subject, "commit number 1");
}
#[test]
fn incremental_repopulation_only_adds_new_commits() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 3);
populate_history(tmp.path()).unwrap();
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
assert_eq!(recent_commits(&conn, 100).unwrap().len(), 3);
drop(conn);
std::fs::write(tmp.path().join("file3.txt"), "content 3").unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "commit number 3"]);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let commits = recent_commits(&conn, 100).unwrap();
assert_eq!(commits.len(), 4);
assert_eq!(commits[0].subject, "commit number 3");
}
#[test]
fn history_commit_limit_bounds_a_cold_start() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 10);
populate_history_with_bound(tmp.path(), 3).unwrap();
let conn = open(tmp.path()).unwrap();
let commits = recent_commits(&conn, 100).unwrap();
assert_eq!(
commits.len(),
3,
"bounded cold start must cap at the override"
);
assert_eq!(commits[0].subject, "commit number 9");
assert_eq!(commits[2].subject, "commit number 7");
}
#[test]
fn default_history_commits_constant_is_500() {
assert_eq!(DEFAULT_HISTORY_COMMITS, 500);
}
#[test]
fn search_commit_messages_finds_substring() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 3);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let hits = search_commit_messages(&conn, "number 2", 10).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].subject, "commit number 2");
let none = search_commit_messages(&conn, "nonexistent-xyz", 10).unwrap();
assert!(none.is_empty());
let empty_query = search_commit_messages(&conn, " ", 10).unwrap();
assert!(empty_query.is_empty());
}
#[test]
fn body_is_stored_and_truncated() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Test User"]);
std::fs::write(tmp.path().join("f.txt"), "x").unwrap();
git(tmp.path(), &["add", "."]);
let long_body = "y".repeat(10_000);
git(
tmp.path(),
&[
"commit",
"-q",
"-m",
&format!("subject line\n\n{long_body}"),
],
);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let body: String = conn
.query_row("SELECT body FROM commits LIMIT 1", [], |r| r.get(0))
.unwrap();
assert!(body.len() <= MAX_BODY_BYTES + 4); assert!(body.starts_with('y'));
}
#[test]
fn humanize_age_buckets() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
assert!(humanize_age(now).ends_with("s ago"));
assert!(humanize_age(now - 3600).ends_with("h ago"));
assert!(humanize_age(now - 86400 * 2).ends_with("d ago"));
assert!(humanize_age(now - 86400 * 400).ends_with("y ago"));
}
#[test]
fn git_log_reverse_selects_most_recent_then_reverses_order() {
let tmp = TempDir::new().unwrap();
init_repo(tmp.path(), 10);
let newest_first = fetch_commit_metadata(tmp.path(), "HEAD", Some(3)).unwrap();
assert_eq!(newest_first.len(), 3);
assert_eq!(newest_first[0].subject, "commit number 7");
assert_eq!(newest_first[2].subject, "commit number 9");
}
#[test]
fn chunk_blame_maps_lines_to_commits_when_opted_in() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Test User"]);
std::fs::write(
tmp.path().join("lib.rs"),
"line one\nline two\nline three\n",
)
.unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "add lib.rs"]);
populate_history(tmp.path()).unwrap();
let chunks_db = layout::container_dir(tmp.path()).join("chunks.db");
std::fs::create_dir_all(chunks_db.parent().unwrap()).unwrap();
let chunks_conn = Connection::open(&chunks_db).unwrap();
chunks_conn
.execute_batch(
"CREATE TABLE chunks (id INTEGER PRIMARY KEY, file_path TEXT, \
start_line INTEGER, end_line INTEGER);
INSERT INTO chunks (id, file_path, start_line, end_line) \
VALUES (1, 'lib.rs', 1, 3);",
)
.unwrap();
drop(chunks_conn);
populate_chunk_blame_best_effort(tmp.path(), &[PathBuf::from("lib.rs")]);
let history_conn = open(tmp.path()).unwrap();
let hash: String = history_conn
.query_row("SELECT hash FROM chunk_blame WHERE chunk_id = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(hash.len(), 40);
}
#[test]
fn blame_disabled_by_default_leaves_chunk_blame_empty() {
assert!(!blame_enabled());
}
#[test]
fn tracked_file_named_head_does_not_break_population() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Test User"]);
std::fs::write(tmp.path().join("HEAD"), "not a ref, a real file").unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "add a file named HEAD"]);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let commits = recent_commits(&conn, 10).unwrap();
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].subject, "add a file named HEAD");
let hits = commits_touching_file(&conn, "HEAD", 10).unwrap();
assert_eq!(hits.len(), 1);
std::fs::write(tmp.path().join("other.txt"), "x").unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "second"]);
populate_history(tmp.path()).unwrap();
assert_eq!(recent_commits(&conn, 10).unwrap().len(), 2);
}
#[test]
fn non_ascii_filenames_are_stored_unquoted() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Test User"]);
std::fs::write(tmp.path().join("héllo.txt"), "bonjour").unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "add héllo"]);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let hits = commits_touching_file(&conn, "héllo.txt", 10).unwrap();
assert_eq!(hits.len(), 1, "non-ASCII path must match verbatim");
let quoted: i64 = conn
.query_row(
"SELECT COUNT(*) FROM file_commits WHERE path LIKE '\"%'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(quoted, 0, "no C-quoted paths may be stored");
}
#[test]
fn hostile_commit_body_cannot_forge_commit_rows() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Real Author"]);
std::fs::write(tmp.path().join("f.txt"), "x").unwrap();
git(tmp.path(), &["add", "."]);
let hostile_body = format!(
"innocent first line\n\
{rs}\ndeadbeefdeadbeefdeadbeefdeadbeefdeadbeef{fs}Fake Author{fs}1{fs}fake subject{fs}fake body{rs}\n\
trailing text",
rs = '\u{1e}',
fs = '\u{1f}',
);
git(
tmp.path(),
&[
"commit",
"-q",
"-m",
&format!("real subject\n\n{hostile_body}"),
],
);
populate_history(tmp.path()).unwrap();
let conn = open(tmp.path()).unwrap();
let commits = recent_commits(&conn, 10).unwrap();
assert_eq!(commits.len(), 1, "exactly one real commit, no forged rows");
assert_eq!(commits[0].author, "Real Author");
assert_eq!(commits[0].subject, "real subject");
assert_ne!(commits[0].hash, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
let fake: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commits WHERE author = 'Fake Author'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(fake, 0);
}
#[test]
fn subdir_project_stores_project_relative_paths() {
let tmp = TempDir::new().unwrap();
git(tmp.path(), &["init", "-q"]);
git(tmp.path(), &["config", "user.email", "test@example.com"]);
git(tmp.path(), &["config", "user.name", "Test User"]);
let sub = tmp.path().join("sub");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("inner.txt"), "in subdir").unwrap();
std::fs::write(tmp.path().join("outer.txt"), "outside project").unwrap();
git(tmp.path(), &["add", "."]);
git(tmp.path(), &["commit", "-q", "-m", "touch inner and outer"]);
populate_history(&sub).unwrap();
let conn = open(&sub).unwrap();
let hits = commits_touching_file(&conn, "inner.txt", 10).unwrap();
assert_eq!(
hits.len(),
1,
"paths must be project-relative, not toplevel-relative"
);
assert!(
commits_touching_file(&conn, "sub/inner.txt", 10)
.unwrap()
.is_empty()
);
assert!(
commits_touching_file(&conn, "outer.txt", 10)
.unwrap()
.is_empty()
);
assert!(
commits_touching_file(&conn, "../outer.txt", 10)
.unwrap()
.is_empty()
);
}
#[test]
fn parse_blame_porcelain_skips_uncommitted_zero_sha() {
let out = "\
0000000000000000000000000000000000000000 1 1 1\n\
author Not Committed Yet\n\
\tuncommitted line\n\
deadbeefdeadbeefdeadbeefdeadbeefdeadbeef 2 2 1\n\
author Real\n\
\tcommitted line\n";
let parsed = parse_blame_porcelain(out);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].0, 2);
assert_eq!(parsed[0].1, "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
}
}