use std::path::{Path, PathBuf};
use strop_workspace::RemoteEndpoint;
use crate::diff::{DiffLine, FileDiff, Hunk, HunkKind, LineOrigin};
use crate::target::RepoTarget;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum GitError {
Native(String),
OutsideWorkdir,
}
impl std::fmt::Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Native(message) => write!(f, "{message}"),
Self::OutsideWorkdir => write!(f, "path is outside the repository workdir"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct GitContext {
pub repo: RepoTarget,
pub head_sha: Option<String>,
pub head_branch: Option<String>,
pub remotes: Vec<(String, String)>,
}
impl GitContext {
pub fn workdir(&self) -> &Path {
self.repo.workdir()
}
pub fn endpoint(&self) -> Option<&RemoteEndpoint> {
self.repo.endpoint()
}
pub fn is_remote(&self) -> bool {
self.repo.is_remote()
}
}
pub struct Repo {
inner: git2::Repository,
pub(crate) workdir: PathBuf,
}
impl Repo {
pub fn discover(from: &Path) -> Option<Self> {
let inner = git2::Repository::discover(from).ok()?;
let workdir = inner.workdir()?.to_path_buf();
Some(Self { inner, workdir })
}
pub fn workdir(&self) -> &Path {
&self.workdir
}
pub fn remotes(&self) -> Vec<(String, String)> {
let Ok(remotes) = self.inner.remotes() else {
return vec![];
};
remotes
.iter()
.flatten()
.filter_map(|name| {
self.inner
.find_remote(name)
.ok()
.and_then(|r| r.url().map(|u| (name.to_string(), u.to_string())))
})
.collect()
}
pub fn head_sha(&self) -> Option<String> {
Some(
self.inner
.head()
.ok()?
.peel_to_commit()
.ok()?
.id()
.to_string(),
)
}
pub fn head_branch(&self) -> Option<String> {
self.inner
.head()
.ok()
.and_then(|h| h.shorthand().map(String::from))
}
pub fn context(&self) -> GitContext {
GitContext {
repo: RepoTarget::Local {
workdir: self.workdir.clone(),
},
head_sha: self.head_sha(),
head_branch: self.head_branch(),
remotes: self.remotes(),
}
}
fn rel_path(&self, path: &Path) -> Option<PathBuf> {
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
self.workdir.join(path)
};
abs.strip_prefix(&self.workdir)
.ok()
.map(|p| p.to_path_buf())
}
pub fn head_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
let commit = self.inner.head().ok()?.peel_to_commit().ok()?;
let tree = commit.tree().ok()?;
let entry = tree.get_path(rel).ok()?;
let blob = self.inner.find_blob(entry.id()).ok()?;
Some(blob.content().to_vec())
}
pub fn commit_bytes(&self, sha: &str, rel: &Path) -> Option<Vec<u8>> {
let oid = self.inner.revparse_single(sha).ok()?.id();
let commit = self.inner.find_commit(oid).ok()?;
let tree = commit.tree().ok()?;
let entry = tree.get_path(rel).ok()?;
let blob = self.inner.find_blob(entry.id()).ok()?;
Some(blob.content().to_vec())
}
pub fn index_bytes(&self, rel: &Path) -> Option<Vec<u8>> {
let mut index = self.inner.index().ok()?;
index.read(true).ok()?;
let entry = index.get_path(rel, 0)?;
let blob = self.inner.find_blob(entry.id).ok()?;
Some(blob.content().to_vec())
}
pub fn merge_base(&self, a: &str, b: &str) -> Option<String> {
let a = self.inner.revparse_single(a).ok()?.id();
let b = self.inner.revparse_single(b).ok()?.id();
let base = self.inner.merge_base(a, b).ok()?;
Some(base.to_string())
}
pub fn head_content(&self, path: &Path) -> Option<String> {
self.head_content_res(path).ok().flatten()
}
fn head_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
let head = match self.inner.head() {
Ok(reference) => reference
.peel_to_tree()
.map_err(|e| GitError::Native(format!("read HEAD: {e}")))?,
Err(error) if error.code() == git2::ErrorCode::UnbornBranch => {
return Ok(None);
}
Err(error) => return Err(GitError::Native(format!("read HEAD: {error}"))),
};
match head.get_path(&rel) {
Err(_) => Ok(None),
Ok(entry) => self.blob_utf8(entry.id(), "HEAD").map(Some),
}
}
pub fn index_content(&self, path: &Path) -> Option<String> {
self.index_content_res(path).ok().flatten()
}
fn index_content_res(&self, path: &Path) -> Result<Option<String>, GitError> {
let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
let mut index = self
.inner
.index()
.map_err(|e| GitError::Native(format!("open index: {e}")))?;
index
.read(true)
.map_err(|e| GitError::Native(format!("reload index: {e}")))?;
match index.get_path(&rel, 0) {
Some(entry) => self.blob_utf8(entry.id, "index").map(Some),
None => Ok(None),
}
}
fn blob_utf8(&self, id: git2::Oid, edge: &str) -> Result<String, GitError> {
let blob = self
.inner
.find_blob(id)
.map_err(|e| GitError::Native(format!("{edge} blob: {e}")))?;
String::from_utf8(blob.content().to_vec())
.map_err(|_| GitError::Native(format!("{edge} blob is not UTF-8")))
}
pub fn is_untracked(&self, path: &Path) -> Result<bool, GitError> {
Ok(self.index_content_res(path)?.is_none() && self.head_content_res(path)?.is_none())
}
pub fn staged_hunks(&self, path: &Path) -> Result<Vec<Hunk>, GitError> {
let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
let Some(index) = self.index_content_res(path)? else {
return Ok(vec![]);
};
let head = self.head_content_res(path)?.unwrap_or_default();
self.diff_strings(&head, &index, &rel)
}
pub fn unstaged_hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
let base = match self.index_content_res(path)? {
Some(index) => Some(index),
None => self.head_content_res(path)?,
};
match base {
Some(base) => self.diff_strings(&base, content, &rel),
None => self.hunks(path, content),
}
}
pub fn unstage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
let old_side: Vec<&DiffLine> = hunk
.lines
.iter()
.filter(|l| l.origin != LineOrigin::Addition)
.collect();
self.index_region_edit(rel, hunk.new_start, hunk.new_count, &old_side)
}
pub fn hunks(&self, path: &Path, content: &str) -> Result<Vec<Hunk>, GitError> {
let rel = self.rel_path(path).ok_or(GitError::OutsideWorkdir)?;
match self.head_content_res(path)? {
Some(old) => self.diff_strings(&old, content, &rel),
None => Ok(all_add_hunk(content)),
}
}
fn diff_strings(&self, old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
let mut opts = git2::DiffOptions::new();
opts.context_lines(3);
let patch = git2::Patch::from_buffers(
old.as_bytes(),
Some(rel),
new.as_bytes(),
Some(rel),
Some(&mut opts),
)
.map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
Ok(hunks_from_patch(&patch))
}
pub fn commit_file_diff(&self, sha: &str, path: &Path) -> Result<FileDiff, String> {
let commit = self
.inner
.find_commit(git2::Oid::from_str(sha).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let new_tree = commit.tree().map_err(|e| e.to_string())?;
let old_tree = match commit.parent(0) {
Ok(parent) => Some(parent.tree().map_err(|e| e.to_string())?),
Err(_) => None,
};
let mut opts = git2::DiffOptions::new();
opts.context_lines(3)
.pathspec(path)
.disable_pathspec_match(true)
.include_unmodified(false);
let diff = self
.inner
.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(&mut opts))
.map_err(|e| e.to_string())?;
let mut file = None;
for (d, _delta) in diff.deltas().enumerate() {
let Some(patch) = git2::Patch::from_diff(&diff, d).map_err(|e| e.to_string())? else {
continue; };
file = Some(FileDiff::from_hunks(
path.to_path_buf(),
hunks_from_patch(&patch),
));
}
file.ok_or_else(|| "no diff for path".to_string())
}
pub fn stage_hunk(&self, rel: &Path, hunk: &Hunk) -> Result<(), String> {
let new_side: Vec<&DiffLine> = hunk
.lines
.iter()
.filter(|l| l.origin != LineOrigin::Deletion)
.collect();
self.index_region_edit(rel, hunk.old_start, hunk.old_count, &new_side)
}
fn index_region_edit(
&self,
rel: &Path,
start: usize,
count: usize,
new_lines: &[&DiffLine],
) -> Result<(), String> {
let mut index = self.inner.index().map_err(|e| e.to_string())?;
index.read(true).map_err(|e| e.to_string())?; let entry = index.get_path(rel, 0);
let (old_bytes, mode) = match entry {
Some(e) => {
let blob = self
.inner
.find_blob(e.id)
.map_err(|e| format!("index blob: {e}"))?;
(blob.content().to_vec(), e.mode)
}
None => (Vec::new(), 0o100644), };
let lines = split_lines_bytes(&old_bytes);
let lo = start.saturating_sub(1).min(lines.len());
let hi = (lo + count).min(lines.len());
let mut out: Vec<u8> = Vec::with_capacity(old_bytes.len() + 64);
for (text, nl) in &lines[..lo] {
out.extend_from_slice(text);
if *nl {
out.push(b'\n');
}
}
for l in new_lines {
out.extend_from_slice(&l.bytes_with_terminator());
}
for (text, nl) in &lines[hi..] {
out.extend_from_slice(text);
if *nl {
out.push(b'\n');
}
}
let oid = self.inner.blob(&out).map_err(|e| e.to_string())?;
index
.add(&git2::IndexEntry {
ctime: git2::IndexTime::new(0, 0),
mtime: git2::IndexTime::new(0, 0),
dev: 0,
ino: 0,
mode,
uid: 0,
gid: 0,
file_size: 0,
id: oid,
flags: 0,
flags_extended: 0,
path: rel.to_string_lossy().replace('\\', "/").into_bytes(),
})
.map_err(|e| e.to_string())?;
index.write().map_err(|e| e.to_string())?;
Ok(())
}
}
fn all_add_hunk(content: &str) -> Vec<Hunk> {
let count = content.lines().count();
if count == 0 {
return vec![];
}
vec![Hunk {
kind: HunkKind::Add,
new_start: 1,
new_count: count,
old_start: 0,
old_count: 0,
lines: split_lines_bytes(content.as_bytes())
.into_iter()
.enumerate()
.map(|(i, (text, has_newline))| DiffLine {
origin: LineOrigin::Addition,
old_lineno: None,
new_lineno: Some(i + 1),
text,
has_newline,
})
.collect(),
}]
}
pub(crate) fn gutter_from_contents(
head: Option<&str>,
index: Option<&str>,
text: &str,
rel: &Path,
) -> Result<(Vec<Hunk>, Vec<Hunk>, bool), GitError> {
let staged = match index {
Some(index) => hunks_from_strings(head.unwrap_or_default(), index, rel)?,
None => Vec::new(),
};
let unstaged = match index.or(head) {
Some(base) => hunks_from_strings(base, text, rel)?,
None => all_add_hunk(text),
};
let untracked = index.is_none() && head.is_none();
Ok((unstaged, staged, untracked))
}
fn hunks_from_strings(old: &str, new: &str, rel: &Path) -> Result<Vec<Hunk>, GitError> {
let mut opts = git2::DiffOptions::new();
opts.context_lines(3);
let patch = git2::Patch::from_buffers(
old.as_bytes(),
Some(rel),
new.as_bytes(),
Some(rel),
Some(&mut opts),
)
.map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
Ok(hunks_from_patch(&patch))
}
pub(crate) fn hunks_from_buffers(
old: Option<&[u8]>,
new: &[u8],
rel: &Path,
) -> Result<Vec<Hunk>, GitError> {
let mut opts = git2::DiffOptions::new();
opts.context_lines(3);
let patch = git2::Patch::from_buffers(
old.unwrap_or(&[]),
Some(rel),
new,
Some(rel),
Some(&mut opts),
)
.map_err(|e| GitError::Native(format!("diff {rel:?}: {e}")))?;
Ok(hunks_from_patch(&patch))
}
fn split_lines_bytes(bytes: &[u8]) -> Vec<(Vec<u8>, bool)> {
let mut out = Vec::new();
let mut start = 0;
for (i, b) in bytes.iter().enumerate() {
if *b == b'\n' {
out.push((bytes[start..i].to_vec(), true));
start = i + 1;
}
}
if start < bytes.len() {
out.push((bytes[start..].to_vec(), false));
}
out
}
fn hunks_from_patch(patch: &git2::Patch) -> Vec<Hunk> {
let mut hunks = Vec::new();
for h in 0..patch.num_hunks() {
let Ok((header, line_count)) = patch.hunk(h) else {
continue;
};
let mut lines = Vec::with_capacity(line_count);
for l in 0..line_count {
let Ok(line) = patch.line_in_hunk(h, l) else {
continue;
};
let raw = line.content();
if raw.starts_with(b"\\ No newline") || raw.starts_with(b"\n\\ No newline") {
continue;
}
let origin = match line.origin() {
'+' => LineOrigin::Addition,
'-' => LineOrigin::Deletion,
_ => LineOrigin::Context,
};
let old_lineno = line.old_lineno().map(|n| n as usize);
let new_lineno = line.new_lineno().map(|n| n as usize);
let content = line.content();
let (text, has_newline) = match content.last() {
Some(b'\n') => (&content[..content.len() - 1], true),
_ => (content, false),
};
lines.push(DiffLine {
origin,
old_lineno,
new_lineno,
text: text.to_vec(),
has_newline,
});
}
hunks.push(Hunk::build(
header.old_start() as usize,
header.old_lines() as usize,
header.new_start() as usize,
header.new_lines() as usize,
lines,
));
}
hunks
}
#[cfg(test)]
mod head_tests {
use super::*;
use crate::tests::fixture;
use std::process::Command;
#[test]
fn head_content_probe() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let git = |args: &[&str]| {
Command::new("git")
.args(args)
.current_dir(root)
.output()
.unwrap();
};
git(&["init", "-q"]);
git(&["config", "user.email", "t@t.t"]);
git(&["config", "user.name", "t"]);
std::fs::write(root.join("f.rs"), "fn a() {}\n").unwrap();
git(&["add", "."]);
git(&["commit", "-qm", "init"]);
let repo = Repo::discover(root).unwrap();
eprintln!("workdir: {:?}", repo.workdir());
let abs = root.join("f.rs");
eprintln!("abs: {:?} rel: {:?}", abs, repo.rel_path(&abs));
eprintln!("head: {:?}", repo.head_content(&abs));
assert!(repo.head_content(&abs).is_some());
}
#[test]
fn four_state_edges() {
let (_d, repo, path) = fixture();
std::fs::write(&path, "fn a() {}\nfn STAGED() {}\nfn c() {}\n").unwrap();
let staged = repo
.unstaged_hunks(&path, &std::fs::read_to_string(&path).unwrap())
.unwrap();
assert_eq!(staged.len(), 1);
let hunk = staged.into_iter().next().unwrap();
repo.stage_hunk(Path::new("f.rs"), &hunk).unwrap();
let idx = repo.index_content(&path).unwrap();
assert!(idx.contains("STAGED"));
let head = repo.head_content(&path).unwrap();
assert!(!head.contains("STAGED"));
assert_eq!(repo.staged_hunks(&path).unwrap().len(), 1);
let wt = std::fs::read_to_string(&path).unwrap();
assert!(repo.unstaged_hunks(&path, &wt).unwrap().is_empty());
let live = "fn a() {}\nfn STAGED() {}\nfn c() {}\nfn live()\n";
let unstaged = repo.unstaged_hunks(&path, live).unwrap();
assert_eq!(unstaged.len(), 1);
assert!(unstaged[0]
.lines
.iter()
.any(|l| l.text.starts_with(b"fn live")));
assert_eq!(
repo.staged_hunks(&path).unwrap().len(),
1,
"staged untouched"
);
let staged = repo.staged_hunks(&path).unwrap();
repo.unstage_hunk(Path::new("f.rs"), &staged[0]).unwrap();
assert!(repo.staged_hunks(&path).unwrap().is_empty());
assert!(!repo.index_content(&path).unwrap().contains("STAGED"));
}
}