use crate::bare::GitBare; use crate::diff::get_diff_stats::{diff_stats_for_range, push_diff_options}; use crate::error::{GitError, GitResult}; use crate::paginate; use crate::pb::diff_file::ChangeType; use crate::pb::{DiffFile, GetDiffRequest, GetDiffResponse}; #[derive(Debug, Clone)] struct NameStatusEntry { status: char, old_path: String, new_path: String, similarity: f64, } #[derive(Debug, Clone, Default)] struct TreeMeta { oid_hex: String, mode: u32, } impl GitBare { pub fn get_diff(&self, request: GetDiffRequest) -> GitResult { let base = match request.base.and_then(|s| s.selector) { Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, None => "HEAD".into(), }; let head = match request.head.and_then(|s| s.selector) { Some(crate::pb::object_selector::Selector::Oid(oid)) => oid.hex, Some(crate::pb::object_selector::Selector::Revision(name)) => name.revision, None => "HEAD".into(), }; let options = request.options.as_ref(); let entries = self.diff_name_status(&base, &head, options)?; let max_files = options.and_then(|o| (o.max_files > 0).then_some(o.max_files as usize)); let overflow = max_files.is_some_and(|max| entries.len() > max); let entries_to_build = max_files.map_or(entries.as_slice(), |max| &entries[..entries.len().min(max)]); let mut files = Vec::with_capacity(entries_to_build.len()); for entry in entries_to_build { let old_meta = if !entry.old_path.is_empty() { self.tree_meta(&base, &entry.old_path).ok().flatten() } else { None }; let new_meta = if !entry.new_path.is_empty() { self.tree_meta(&head, &entry.new_path).ok().flatten() } else { None }; let (additions, deletions, binary) = self.path_numstat(&base, &head, entry)?; let (patch, too_large) = self.path_patch(&base, &head, entry, options)?; files.push(DiffFile { old_path: entry.old_path.clone(), new_path: entry.new_path.clone(), old_oid: old_meta.as_ref().map(|m| self.oid_to_pb(&m.oid_hex)), new_oid: new_meta.as_ref().map(|m| self.oid_to_pb(&m.oid_hex)), old_mode: old_meta.as_ref().map(|m| m.mode).unwrap_or(0), new_mode: new_meta.as_ref().map(|m| m.mode).unwrap_or(0), change_type: change_type(entry.status) as i32, binary, too_large, additions, deletions, hunks: Vec::new(), patch, similarity: entry.similarity, }); } let stats = diff_stats_for_range(self, &base, &head, options)?; let (files, page_info) = paginate::paginate(&files, request.pagination.as_ref()); Ok(GetDiffResponse { files, stats: Some(stats), page_info: Some(page_info), overflow, }) } fn diff_name_status( &self, base: &str, head: &str, options: Option<&crate::pb::DiffOptions>, ) -> GitResult> { let mut args = vec![ "--git-dir".to_string(), self.bare_dir.to_string_lossy().into_owned(), "diff".into(), "--name-status".into(), "-z".into(), ]; push_diff_options(&mut args, options); args.push(base.to_string()); args.push(head.to_string()); if let Some(options) = options && !options.pathspec.is_empty() { args.push("--".into()); args.extend(options.pathspec.iter().cloned()); } let result = duct::cmd("git", &args) .stdout_capture() .stderr_capture() .unchecked() .run()?; if !result.status.success() { return Err(GitError::CommandFailed { status_code: result.status.code(), stderr: String::from_utf8_lossy(&result.stderr).into_owned(), }); } let parts = result .stdout .split(|b| *b == 0) .filter(|part| !part.is_empty()) .map(|part| String::from_utf8_lossy(part).into_owned()) .collect::>(); let mut entries = Vec::new(); let mut idx = 0; while idx < parts.len() { let status_token = &parts[idx]; idx += 1; let status = status_token.chars().next().unwrap_or('M'); let similarity = status_token .get(1..) .and_then(|s| s.parse::().ok()) .unwrap_or(0.0); if matches!(status, 'R' | 'C') { if idx + 1 >= parts.len() { break; } let old_path = parts[idx].clone(); let new_path = parts[idx + 1].clone(); idx += 2; entries.push(NameStatusEntry { status, old_path, new_path, similarity, }); } else { if idx >= parts.len() { break; } let path = parts[idx].clone(); idx += 1; let (old_path, new_path) = match status { 'A' => (String::new(), path), 'D' => (path, String::new()), _ => (path.clone(), path), }; entries.push(NameStatusEntry { status, old_path, new_path, similarity, }); } } Ok(entries) } fn tree_meta(&self, revision: &str, path: &str) -> GitResult> { let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "ls-tree", "-z", "-l", revision, "--", path, ], ) .stdout_capture() .stderr_capture() .unchecked() .run()?; if !result.status.success() || result.stdout.is_empty() { return Ok(None); } let record = result .stdout .split(|b| *b == 0) .find(|part| !part.is_empty()) .map(|part| String::from_utf8_lossy(part).into_owned()); let Some(record) = record else { return Ok(None); }; let Some((meta, _path)) = record.split_once('\t') else { return Ok(None); }; let parts = meta.split_whitespace().collect::>(); if parts.len() < 3 { return Ok(None); } Ok(Some(TreeMeta { mode: u32::from_str_radix(parts[0], 8).unwrap_or(0), oid_hex: parts[2].to_string(), })) } fn path_numstat( &self, base: &str, head: &str, entry: &NameStatusEntry, ) -> GitResult<(u32, u32, bool)> { let path = if entry.new_path.is_empty() { &entry.old_path } else { &entry.new_path }; let result = duct::cmd( "git", [ "--git-dir", self.bare_dir.to_string_lossy().as_ref(), "diff", "--numstat", base, head, "--", path, ], ) .stdout_capture() .stderr_capture() .unchecked() .run()?; if !result.status.success() { return Err(GitError::CommandFailed { status_code: result.status.code(), stderr: String::from_utf8_lossy(&result.stderr).into_owned(), }); } let line = String::from_utf8_lossy(&result.stdout) .lines() .next() .unwrap_or_default() .to_string(); let mut parts = line.split('\t'); let add = parts.next().unwrap_or_default(); let del = parts.next().unwrap_or_default(); let binary = add == "-" || del == "-"; Ok((add.parse().unwrap_or(0), del.parse().unwrap_or(0), binary)) } fn path_patch( &self, base: &str, head: &str, entry: &NameStatusEntry, options: Option<&crate::pb::DiffOptions>, ) -> GitResult<(Vec, bool)> { let Some(options) = options else { return Ok((Vec::new(), false)); }; if !options.include_patch { return Ok((Vec::new(), false)); } let path = if entry.new_path.is_empty() { &entry.old_path } else { &entry.new_path }; let context = options.context_lines.to_string(); let mut args = vec![ "--git-dir".to_string(), self.bare_dir.to_string_lossy().into_owned(), "diff".into(), "--patch".into(), format!("--unified={context}"), ]; if options.include_binary { args.push("--binary".into()); } push_diff_options(&mut args, Some(options)); args.push(base.to_string()); args.push(head.to_string()); args.push("--".into()); args.push(path.to_string()); let result = duct::cmd("git", &args) .stdout_capture() .stderr_capture() .unchecked() .run()?; if !result.status.success() { return Err(GitError::CommandFailed { status_code: result.status.code(), stderr: String::from_utf8_lossy(&result.stderr).into_owned(), }); } let mut patch = result.stdout; let too_large = options.max_bytes > 0 && patch.len() > options.max_bytes as usize; if too_large { patch.truncate(options.max_bytes as usize); } Ok((patch, too_large)) } } fn change_type(status: char) -> ChangeType { match status { 'A' => ChangeType::DiffFileChangeTypeAdded, 'D' => ChangeType::DiffFileChangeTypeDeleted, 'R' => ChangeType::DiffFileChangeTypeRenamed, 'C' => ChangeType::DiffFileChangeTypeCopied, 'T' => ChangeType::DiffFileChangeTypeTypeChanged, 'U' => ChangeType::DiffFileChangeTypeUnmerged, _ => ChangeType::DiffFileChangeTypeModified, } }