use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use git2::{DiffOptions, Repository};
use crate::repo_state::open_repo;
use crate::untracked_scan::add_untracked;
#[derive(Debug)]
pub enum Lines {
All,
Ranges(BTreeSet<usize>),
}
#[derive(Debug)]
pub struct Changeset {
pub root: String,
pub files: BTreeMap<String, Lines>,
}
impl Changeset {
pub fn load(base: &str) -> Result<Changeset, String> {
let repo = open_repo(None)?;
let root = repo
.workdir()
.and_then(|p| p.to_str())
.ok_or("repository has no work directory")?
.trim_end_matches('/')
.replace('\\', "/");
let files = changed_lines(&repo, base)?;
Ok(Changeset { root, files })
}
pub fn line_selected(&self, rel: &str, line: usize) -> bool {
match self.files.get(rel) {
None => false,
Some(Lines::All) => true,
Some(Lines::Ranges(set)) => set.contains(&line),
}
}
pub fn span_selected(&self, rel: &str, start: usize, end: usize) -> bool {
match self.files.get(rel) {
None => false,
Some(Lines::All) => true,
Some(Lines::Ranges(set)) => set
.range(start..=end)
.next()
.map(|l| *l <= end)
.unwrap_or(false),
}
}
pub fn rel_of<'a>(&'a self, path: &'a str) -> Option<&'a str> {
let prefix = format!("{}/", self.root);
path.strip_prefix(&prefix)
.or_else(|| (path == self.root).then_some(""))
}
pub fn code_files(&self) -> Vec<std::path::PathBuf> {
self.files
.keys()
.filter(|k| crate::paths::is_code_path(Path::new(k)))
.map(|k| Path::new(&self.root).join(k))
.filter(|p| p.exists())
.collect()
}
}
fn changed_lines(repo: &Repository, base: &str) -> Result<BTreeMap<String, Lines>, String> {
let tree = base_tree(repo, base)?;
let mut files: BTreeMap<String, Lines> = tracked_ranges(repo, &tree)?
.into_iter()
.map(|(p, set)| (p, Lines::Ranges(set)))
.collect();
add_untracked(repo, &mut files)?;
Ok(files)
}
fn base_tree<'r>(repo: &'r Repository, base: &str) -> Result<git2::Tree<'r>, String> {
repo.revparse_single(base)
.map_err(|e| format!("cannot resolve base {base}: {e}"))?
.peel_to_commit()
.map_err(|e| format!("base {base} is not a commit: {e}"))?
.tree()
.map_err(|e| e.to_string())
}
fn tracked_ranges(
repo: &Repository,
tree: &git2::Tree<'_>,
) -> Result<Vec<(String, BTreeSet<usize>)>, String> {
sync_index_stats(repo);
let mut opts = DiffOptions::new();
opts.context_lines(0);
let mut sets: BTreeMap<String, BTreeSet<usize>> = BTreeMap::new();
diff_into(repo, tree, &mut opts, &mut sets)?;
Ok(sets.into_iter().collect())
}
fn diff_into(
repo: &Repository,
tree: &git2::Tree<'_>,
opts: &mut DiffOptions,
sets: &mut BTreeMap<String, BTreeSet<usize>>,
) -> Result<(), String> {
repo.diff_tree_to_workdir_with_index(Some(tree), Some(opts))
.map_err(|e| e.to_string())?
.foreach(
&mut |_, _| true,
None,
Some(&mut |d, h| {
on_hunk(d, h, sets);
true
}),
None,
)
.map_err(|e| e.to_string())?;
Ok(())
}
fn sync_index_stats(repo: &Repository) {
if let Ok(mut idx) = repo.index() {
let _ = idx.update_all(["*"], None::<&mut git2::IndexMatchedPath>);
}
}
fn on_hunk(
delta: git2::DiffDelta,
hunk: git2::DiffHunk,
sets: &mut BTreeMap<String, BTreeSet<usize>>,
) {
if !matches!(delta.status(), git2::Delta::Modified | git2::Delta::Added) {
return;
}
let Some(path) = delta.new_file().path().and_then(|p| p.to_str()) else {
return;
};
let start = hunk.new_start() as usize;
sets.entry(path.to_string())
.or_default()
.extend(start..start + (hunk.new_lines() as usize).max(1));
}