gitdataai/libs/service/git/diff.rs
2026-04-14 19:02:01 +08:00

633 lines
20 KiB
Rust

use crate::AppService;
use crate::error::AppError;
use crate::git::{DiffDelta, DiffOptions, DiffResult, DiffStats, GitError, SideBySideDiffResult};
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use session::Session;
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
pub struct DiffQuery {
#[serde(default)]
pub old_tree: Option<String>,
#[serde(default)]
pub new_tree: Option<String>,
#[serde(default)]
pub context_lines: Option<u32>,
#[serde(default)]
pub pathspec: Option<Vec<String>>,
#[serde(default)]
pub include_untracked: Option<bool>,
#[serde(default)]
pub include_ignored: Option<bool>,
#[serde(default)]
pub ignore_whitespace: Option<bool>,
#[serde(default)]
pub force_text: Option<bool>,
#[serde(default)]
pub reverse: Option<bool>,
}
impl DiffQuery {
fn to_diff_options(&self) -> DiffOptions {
let mut opts = DiffOptions::new();
if let Some(n) = self.context_lines {
opts = opts.context_lines(n);
}
if let Some(ref paths) = self.pathspec {
for p in paths {
opts = opts.pathspec(p);
}
}
if self.include_untracked.unwrap_or(false) {
opts = opts.include_untracked();
}
if self.include_ignored.unwrap_or(false) {
opts = opts.include_ignored();
}
if self.ignore_whitespace.unwrap_or(false) {
opts = opts.ignore_whitespace();
}
if self.force_text.unwrap_or(false) {
opts = opts.force_text();
}
if self.reverse.unwrap_or(false) {
opts = opts.reverse();
}
opts
}
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct DiffCommitQuery {
#[serde(default)]
pub commit: String,
#[serde(flatten)]
#[serde(default)]
pub diff_opts: DiffQuery,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffStatsResponse {
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
}
impl From<DiffStats> for DiffStatsResponse {
fn from(s: DiffStats) -> Self {
Self {
files_changed: s.files_changed,
insertions: s.insertions,
deletions: s.deletions,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffDeltaResponse {
pub status: String,
pub old_file: DiffFileResponse,
pub new_file: DiffFileResponse,
pub nfiles: u16,
pub hunks: Vec<DiffHunkResponse>,
pub lines: Vec<DiffLineResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffFileResponse {
pub oid: Option<String>,
pub path: Option<String>,
pub size: u64,
pub is_binary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffHunkResponse {
pub old_start: u32,
pub old_lines: u32,
pub new_start: u32,
pub new_lines: u32,
pub header: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffLineResponse {
pub content: String,
pub origin: String,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
pub num_lines: u32,
pub content_offset: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DiffResultResponse {
pub stats: DiffStatsResponse,
pub deltas: Vec<DiffDeltaResponse>,
}
impl From<DiffResult> for DiffResultResponse {
fn from(r: DiffResult) -> Self {
Self {
stats: DiffStatsResponse::from(r.stats),
deltas: r.deltas.into_iter().map(DiffDeltaResponse::from).collect(),
}
}
}
impl From<DiffDelta> for DiffDeltaResponse {
fn from(d: DiffDelta) -> Self {
Self {
status: format!("{:?}", d.status).to_lowercase(),
old_file: DiffFileResponse {
oid: d.old_file.oid.map(|o| o.to_string()),
path: d.old_file.path,
size: d.old_file.size,
is_binary: d.old_file.is_binary,
},
new_file: DiffFileResponse {
oid: d.new_file.oid.map(|o| o.to_string()),
path: d.new_file.path,
size: d.new_file.size,
is_binary: d.new_file.is_binary,
},
nfiles: d.nfiles,
hunks: d
.hunks
.into_iter()
.map(|h| DiffHunkResponse {
old_start: h.old_start,
old_lines: h.old_lines,
new_start: h.new_start,
new_lines: h.new_lines,
header: h.header,
})
.collect(),
lines: d
.lines
.into_iter()
.map(|l| DiffLineResponse {
content: l.content,
origin: l.origin.to_string(),
old_lineno: l.old_lineno,
new_lineno: l.new_lineno,
num_lines: l.num_lines,
content_offset: l.content_offset,
})
.collect(),
}
}
}
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct DiffPatchIdResponse {
pub old_tree: String,
pub new_tree: String,
pub patch_id: String,
}
// ---------------------------------------------------------------------------
// Side-by-side diff response types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum SideBySideChangeTypeResponse {
Unchanged,
Added,
Removed,
Modified,
Empty,
}
impl From<crate::git::SideBySideChangeType> for SideBySideChangeTypeResponse {
fn from(v: crate::git::SideBySideChangeType) -> Self {
match v {
crate::git::SideBySideChangeType::Unchanged => Self::Unchanged,
crate::git::SideBySideChangeType::Added => Self::Added,
crate::git::SideBySideChangeType::Removed => Self::Removed,
crate::git::SideBySideChangeType::Modified => Self::Modified,
crate::git::SideBySideChangeType::Empty => Self::Empty,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SideBySideLineResponse {
pub left_line_no: Option<u32>,
pub right_line_no: Option<u32>,
pub left_content: String,
pub right_content: String,
pub change_type: SideBySideChangeTypeResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SideBySideFileResponse {
pub path: String,
pub additions: usize,
pub deletions: usize,
pub is_binary: bool,
pub is_rename: bool,
pub lines: Vec<SideBySideLineResponse>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct SideBySideDiffResponse {
pub files: Vec<SideBySideFileResponse>,
pub total_additions: usize,
pub total_deletions: usize,
}
impl From<SideBySideDiffResult> for SideBySideDiffResponse {
fn from(r: SideBySideDiffResult) -> Self {
Self {
files: r
.files
.into_iter()
.map(|f| SideBySideFileResponse {
path: f.path,
additions: f.additions,
deletions: f.deletions,
is_binary: f.is_binary,
is_rename: f.is_rename,
lines: f
.lines
.into_iter()
.map(|l| SideBySideLineResponse {
left_line_no: l.left_line_no,
right_line_no: l.right_line_no,
left_content: l.left_content,
right_content: l.right_content,
change_type: l.change_type.into(),
})
.collect(),
})
.collect(),
total_additions: r.total_additions,
total_deletions: r.total_deletions,
}
}
}
/// Query parameters for side-by-side diff.
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams, utoipa::ToSchema)]
pub struct SideBySideDiffQuery {
/// OID (SHA) of the base / old commit or tree.
pub base: String,
/// OID (SHA) of the head / new commit or tree.
pub head: String,
/// Optional path filter — only include files matching this prefix.
#[serde(default)]
pub pathspec: Option<Vec<String>>,
/// Number of context lines around changes (default 3).
#[serde(default)]
pub context_lines: Option<u32>,
}
impl AppService {
pub async fn git_diff_tree_to_tree(
&self,
namespace: String,
repo_name: String,
query: DiffQuery,
ctx: &Session,
) -> Result<DiffResultResponse, AppError> {
let repo = self
.utils_find_repo(namespace.clone(), repo_name.clone(), ctx)
.await?;
let cache_key = format!(
"git:diff:{}:{}:{}:{}",
namespace,
repo_name,
query.old_tree.as_deref().unwrap_or(""),
query.new_tree.as_deref().unwrap_or(""),
);
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await {
if let Ok(cached) = serde_json::from_str(&cached) {
return Ok(cached);
}
}
}
let old_tree = query
.old_tree
.as_ref()
.map(|s| git::CommitOid::new(s.as_str()));
let new_tree = git::CommitOid::new(
query
.new_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?,
);
let opts = query.to_diff_options();
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(opts))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
let response = DiffResultResponse::from(result);
if let Ok(mut conn) = self.cache.conn().await {
if let Err(e) = conn
.set_ex::<String, String, ()>(
cache_key,
serde_json::to_string(&response).unwrap_or_default(),
60 * 60,
)
.await
{
slog::debug!(self.logs, "cache set failed (non-fatal): {}", e);
}
}
Ok(response)
}
pub async fn git_diff_commit_to_workdir(
&self,
namespace: String,
repo_name: String,
query: DiffCommitQuery,
ctx: &Session,
) -> Result<DiffResultResponse, AppError> {
let repo = self
.utils_find_repo(namespace.clone(), repo_name.clone(), ctx)
.await?;
let cache_key = format!("git:diff:c2wd:{}:{}:{}", namespace, repo_name, query.commit,);
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await {
if let Ok(cached) = serde_json::from_str(&cached) {
return Ok(cached);
}
}
}
let commit = git::CommitOid::new(&query.commit);
let opts = query.diff_opts.to_diff_options();
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_commit_to_workdir(&commit, Some(opts))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
let response = DiffResultResponse::from(result);
if let Ok(mut conn) = self.cache.conn().await {
if let Err(e) = conn
.set_ex::<String, String, ()>(
cache_key,
serde_json::to_string(&response).unwrap_or_default(),
60 * 60,
)
.await
{
slog::debug!(self.logs, "cache set failed (non-fatal): {}", e);
}
}
Ok(response)
}
pub async fn git_diff_commit_to_index(
&self,
namespace: String,
repo_name: String,
query: DiffCommitQuery,
ctx: &Session,
) -> Result<DiffResultResponse, AppError> {
let repo = self
.utils_find_repo(namespace.clone(), repo_name.clone(), ctx)
.await?;
let cache_key = format!(
"git:diff:c2idx:{}:{}:{}",
namespace, repo_name, query.commit,
);
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await {
if let Ok(cached) = serde_json::from_str(&cached) {
return Ok(cached);
}
}
}
let commit = git::CommitOid::new(&query.commit);
let opts = query.diff_opts.to_diff_options();
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_commit_to_index(&commit, Some(opts))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
let response = DiffResultResponse::from(result);
if let Ok(mut conn) = self.cache.conn().await {
if let Err(e) = conn
.set_ex::<String, String, ()>(
cache_key,
serde_json::to_string(&response).unwrap_or_default(),
60 * 60,
)
.await
{
slog::debug!(self.logs, "cache set failed (non-fatal): {}", e);
}
}
Ok(response)
}
pub async fn git_diff_workdir_to_index(
&self,
namespace: String,
repo_name: String,
query: DiffQuery,
ctx: &Session,
) -> Result<DiffResultResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let opts = query.to_diff_options();
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_workdir_to_index(Some(opts))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
Ok(DiffResultResponse::from(result))
}
pub async fn git_diff_index_to_tree(
&self,
namespace: String,
repo_name: String,
query: DiffQuery,
ctx: &Session,
) -> Result<DiffResultResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let tree = git::CommitOid::new(
query
.new_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?,
);
let opts = query.to_diff_options();
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_index_to_tree(&tree, Some(opts))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
Ok(DiffResultResponse::from(result))
}
pub async fn git_diff_stats(
&self,
namespace: String,
repo_name: String,
query: DiffQuery,
ctx: &Session,
) -> Result<DiffStatsResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let old_tree = git::CommitOid::new(
query
.old_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("old_tree is required".into()))?,
);
let new_tree = git::CommitOid::new(
query
.new_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?,
);
let stats = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_stats(&old_tree, &new_tree)
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
Ok(DiffStatsResponse::from(stats))
}
pub async fn git_diff_patch_id(
&self,
namespace: String,
repo_name: String,
query: DiffQuery,
ctx: &Session,
) -> Result<DiffPatchIdResponse, AppError> {
let repo = self.utils_find_repo(namespace, repo_name, ctx).await?;
let old_tree = git::CommitOid::new(
query
.old_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("old_tree is required".into()))?,
);
let new_tree = git::CommitOid::new(
query
.new_tree
.as_deref()
.ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?,
);
let patch_id = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
domain.diff_patch_id(&old_tree, &new_tree)
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
Ok(DiffPatchIdResponse {
old_tree: query.old_tree.unwrap_or_default(),
new_tree: query.new_tree.unwrap_or_default(),
patch_id,
})
}
/// Generate a side-by-side diff between two commits or trees.
pub async fn git_diff_side_by_side(
&self,
namespace: String,
repo_name: String,
query: SideBySideDiffQuery,
ctx: &Session,
) -> Result<SideBySideDiffResponse, AppError> {
let repo = self
.utils_find_repo(namespace.clone(), repo_name.clone(), ctx)
.await?;
let cache_key = format!(
"git:diff:sbs:{}:{}:{}:{}",
namespace, repo_name, query.base, query.head,
);
if let Ok(mut conn) = self.cache.conn().await {
if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await {
if let Ok(cached) = serde_json::from_str::<SideBySideDiffResponse>(&cached) {
return Ok(cached);
}
}
}
let base = git::CommitOid::new(&query.base);
let head = git::CommitOid::new(&query.head);
let mut opts = DiffOptions::new();
if let Some(n) = query.context_lines {
opts = opts.context_lines(n);
}
if let Some(ref paths) = query.pathspec {
for p in paths {
opts = opts.pathspec(p);
}
}
let result = tokio::task::spawn_blocking(move || {
let domain = git::GitDomain::from_model(repo)?;
let diff_result = domain.diff_tree_to_tree(Some(&base), Some(&head), Some(opts))?;
Ok::<_, GitError>(git::diff_to_side_by_side(&diff_result))
})
.await
.map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))?
.map_err(AppError::from)?;
let response = SideBySideDiffResponse::from(result);
if let Ok(mut conn) = self.cache.conn().await {
if let Err(e) = conn
.set_ex::<String, String, ()>(
cache_key,
serde_json::to_string(&response).unwrap_or_default(),
60 * 60,
)
.await
{
slog::debug!(self.logs, "cache set failed (non-fatal): {}", e);
}
}
Ok(response)
}
}