633 lines
20 KiB
Rust
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)
|
|
}
|
|
}
|