//! same fields as knot2's knot-xrpc responses.
use knot_git::{
BinaryDiff, BranchInfo, BranchTip, Commit, EntryKind, FilePatch, Identity, LineOp, PatchBody,
PatchStatus, Repo, TagInfo,
};
use knot_types::{AuthorName, BranchName, Email, Oid, RefName, RepoPath, TagName, UnixSeconds};
use serde::{Serialize, Serializer};
fn zoned(seconds: i64, offset_seconds: i32) -> chrono::DateTime<chrono::FixedOffset> {
let offset = chrono::FixedOffset::east_opt(offset_seconds)
.unwrap_or_else(|| chrono::FixedOffset::east_opt(0).expect("zero offset is valid"));
chrono::DateTime::from_timestamp(seconds, 0)
.unwrap_or_default()
.with_timezone(&offset)
}
pub(crate) fn rfc3339(at: UnixSeconds, offset_seconds: i32) -> String {
zoned(at.get(), offset_seconds).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub(crate) fn fold_subject(message: &str) -> String {
message
.split("\n\n")
.next()
.unwrap_or_default()
.lines()
.map(str::trim_end)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
#[derive(Serialize, Clone)]
pub(crate) struct SignatureOut {
name: AuthorName,
email: Email,
when: String,
}
impl SignatureOut {
pub(crate) fn of(identity: &Identity) -> Self {
Self {
name: identity.name.clone(),
email: identity.email.clone(),
when: rfc3339(identity.time, identity.offset_seconds),
}
}
fn zero() -> Self {
Self {
name: AuthorName::new(""),
email: Email::new(""),
when: rfc3339(UnixSeconds::new(0), 0),
}
}
}
#[derive(Serialize, Clone)]
pub(crate) struct HeaderOut {
key: &'static str,
value: String,
}
// org.tangled.git.defs#commit, hash repeats oid because svfe's readers read hash
#[derive(Serialize, Clone)]
pub(crate) struct CommitOut {
hash: Oid,
oid: Oid,
parents: Vec<Oid>,
author: SignatureOut,
committer: SignatureOut,
message: String,
tree: Oid,
#[serde(rename = "extraHeaders", skip_serializing_if = "Vec::is_empty")]
extra_headers: Vec<HeaderOut>,
#[serde(skip)]
authored: (UnixSeconds, Oid),
}
impl CommitOut {
pub(crate) fn of(commit: &Commit) -> Self {
Self {
hash: commit.id,
oid: commit.id,
parents: commit.parents.clone(),
author: SignatureOut::of(&commit.author),
committer: SignatureOut::of(&commit.committer),
message: commit.message.clone(),
tree: commit.tree,
extra_headers: commit
.change_id()
.map(|id| HeaderOut {
key: "change-id",
value: id.as_str().to_string(),
})
.into_iter()
.collect(),
authored: (commit.author.time, commit.id),
}
}
pub(crate) fn hash(&self) -> Oid {
self.hash
}
pub(crate) fn parents_of(&self) -> &[Oid] {
&self.parents
}
pub(crate) fn tree_hex(&self) -> String {
self.tree.to_hex()
}
/// ties on author time go to the bigger oid, same as knot2
pub(crate) fn author_time(&self) -> (UnixSeconds, Oid) {
self.authored
}
}
#[derive(Serialize)]
pub(crate) struct TreeEntryOut {
pub(crate) name: String,
pub(crate) mode: String,
pub(crate) size: i64,
#[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")]
pub(crate) last_commit: Option<CommitOut>,
}
#[derive(Serialize)]
pub(crate) struct TreeOut {
pub(crate) entries: Vec<TreeEntryOut>,
#[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")]
pub(crate) last_commit: Option<CommitOut>,
}
#[derive(Serialize)]
pub(crate) struct LogOut {
pub(crate) commits: Vec<CommitOut>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) cursor: Option<Oid>,
}
#[derive(Serialize)]
pub(crate) struct MergeBaseOut {
pub(crate) commit: Oid,
}
#[derive(Serialize)]
pub(crate) struct BranchInfoOut {
name: BranchName,
oid: Oid,
message: String,
author: SignatureOut,
committer: SignatureOut,
}
impl BranchInfoOut {
pub(crate) fn of(branch: &BranchInfo) -> Self {
match &branch.tip {
BranchTip::Commit(commit) => Self {
name: branch.name.clone(),
oid: commit.id,
message: commit.message.clone(),
author: SignatureOut::of(&commit.author),
committer: SignatureOut::of(&commit.committer),
},
BranchTip::Opaque { id, message, .. } => Self {
name: branch.name.clone(),
oid: *id,
message: message.trim_end().to_owned(),
author: SignatureOut::zero(),
committer: SignatureOut::zero(),
},
}
}
}
#[derive(Serialize)]
pub(crate) struct BranchesOut<'a> {
pub(crate) branches: &'a [BranchInfoOut],
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) cursor: Option<String>,
pub(crate) total: usize,
#[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")]
pub(crate) default_branch: Option<&'a BranchName>,
}
#[derive(Serialize)]
pub(crate) struct BranchOut {
pub(crate) name: BranchName,
pub(crate) hash: Oid,
pub(crate) message: String,
pub(crate) author: SignatureOut,
#[serde(rename = "totalCommits")]
pub(crate) total_commits: usize,
}
// org.tangled.git.listTags#annotation, oid is the tag object and not the target
#[derive(Serialize)]
pub(crate) struct AnnotationOut {
#[serde(skip_serializing_if = "Option::is_none")]
tagger: Option<SignatureOut>,
message: String,
oid: Oid,
}
#[derive(Serialize)]
pub(crate) struct TagInfoOut {
name: TagName,
oid: Oid,
message: String,
author: SignatureOut,
#[serde(skip_serializing_if = "Option::is_none")]
annotation: Option<AnnotationOut>,
}
impl TagInfoOut {
pub(crate) fn of(repo: &Repo, info: &TagInfo) -> Self {
let annotation = info.annotated.as_ref().map(|annotated| AnnotationOut {
tagger: annotated.tagger.as_ref().map(SignatureOut::of),
message: info.message.trim_end().to_owned(),
oid: info.id,
});
let target = match &info.annotated {
Some(annotated) => annotated.target,
None => info.id,
};
let (oid, message, author) = match repo
.peel_to_commit(target)
.ok()
.and_then(|oid| repo.find_commit(oid).ok())
{
Some(commit) => (commit.id, commit.message, SignatureOut::of(&commit.author)),
None => (info.id, info.message.clone(), SignatureOut::zero()),
};
Self {
name: info.name.clone(),
oid,
message,
author,
annotation,
}
}
}
#[derive(Serialize)]
pub(crate) struct TagsOut<'a> {
pub(crate) tags: &'a [TagInfoOut],
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) cursor: Option<String>,
pub(crate) total: usize,
}
// org.tangled.git.defs#submodule
#[derive(Serialize)]
pub(crate) struct SubmoduleOut {
pub(crate) name: String,
pub(crate) url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) branch: Option<BranchName>,
}
#[derive(Serialize)]
pub(crate) struct EntryOut {
pub(crate) name: String,
pub(crate) mode: String,
pub(crate) oid: Oid,
pub(crate) size: i64,
#[serde(rename = "lastCommit", skip_serializing_if = "Option::is_none")]
pub(crate) last_commit: Option<CommitOut>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) submodule: Option<SubmoduleOut>,
}
#[derive(Serialize, serde::Deserialize, Clone)]
pub(crate) struct BlameAuthorOut {
name: String,
email: String,
}
#[derive(Serialize, serde::Deserialize, Clone)]
pub(crate) struct BlameCommitOut {
sha: String,
subject: String,
#[serde(rename = "authoredAt")]
authored_at: String,
author: BlameAuthorOut,
}
impl BlameCommitOut {
pub(crate) fn of(commit: &Commit) -> Self {
Self {
sha: commit.id.to_hex(),
subject: fold_subject(&commit.message),
authored_at: rfc3339(commit.author.time, commit.author.offset_seconds),
author: BlameAuthorOut {
name: commit.author.name.as_str().to_string(),
email: commit.author.email.as_str().to_string(),
},
}
}
}
#[derive(Serialize, serde::Deserialize, Clone)]
pub(crate) struct BlameRunOut {
pub(crate) start: u32,
pub(crate) count: u32,
pub(crate) commit: String,
}
#[derive(Serialize, serde::Deserialize, Clone)]
pub(crate) struct BlameBody {
pub(crate) commits: Vec<BlameCommitOut>,
pub(crate) runs: Vec<BlameRunOut>,
}
#[derive(Serialize)]
pub(crate) struct BlameOut<'a> {
pub(crate) commit: Oid,
#[serde(flatten)]
pub(crate) body: &'a BlameBody,
}
#[derive(Serialize, serde::Deserialize, Clone)]
pub(crate) struct LanguageOut {
pub(crate) name: String,
pub(crate) size: u64,
pub(crate) percentage: i64,
}
#[derive(Serialize)]
pub(crate) struct LanguagesOut<'a> {
#[serde(rename = "ref")]
pub(crate) refspec: &'a str,
pub(crate) languages: &'a [LanguageOut],
#[serde(rename = "totalSize", skip_serializing_if = "Option::is_none")]
pub(crate) total_size: Option<u64>,
#[serde(rename = "totalFiles", skip_serializing_if = "Option::is_none")]
pub(crate) total_files: Option<i64>,
}
#[derive(Serialize)]
pub(crate) struct DefaultBranchWire {
#[serde(rename = "ref")]
pub(crate) name: RefName,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) head: Option<Oid>,
}
#[derive(Serialize)]
pub(crate) struct RefWire {
#[serde(rename = "ref")]
pub(crate) name: RefName,
pub(crate) sha: Oid,
}
#[derive(Serialize)]
pub(crate) struct ListRefsOut<'a> {
pub(crate) refs: &'a [RefWire],
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) cursor: Option<String>,
#[serde(rename = "defaultBranch", skip_serializing_if = "Option::is_none")]
pub(crate) default_branch: Option<&'a DefaultBranchWire>,
}
// org.tangled.git.defs#diffSrc
#[derive(Serialize)]
struct DiffSrcOut {
#[serde(serialize_with = "serialize_diff_path")]
path: Option<RepoPath>,
oid: Oid,
size: i64,
#[serde(rename = "isBinary")]
is_binary: bool,
#[serde(rename = "isSubmodule")]
is_submodule: bool,
}
fn serialize_diff_path<S: Serializer>(
path: &Option<RepoPath>,
serializer: S,
) -> Result<S::Ok, S::Error> {
serializer.serialize_str(path.as_ref().map(RepoPath::as_str).unwrap_or(""))
}
pub(crate) fn is_file(kind: EntryKind) -> bool {
matches!(
kind,
EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link
)
}
fn diff_src(
repo: &Repo,
path: Option<&RepoPath>,
oid: Oid,
kind: Option<EntryKind>,
is_binary: bool,
) -> DiffSrcOut {
DiffSrcOut {
path: path.cloned(),
oid,
size: kind
.filter(|kind| is_file(*kind))
.and_then(|_| repo.blob_size(oid).ok())
.map(|size| size as i64)
.unwrap_or(0),
is_binary,
is_submodule: kind == Some(EntryKind::Commit),
}
}
// org.tangled.git.defs#linePair, 0-based on both sides
#[derive(Serialize)]
struct LinePairOut {
#[serde(skip_serializing_if = "Option::is_none")]
lhs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
rhs: Option<u32>,
}
// org.tangled.git.defs#diffHunk
#[derive(Serialize)]
struct DiffHunkOut {
#[serde(rename = "novelLhs")]
novel_lhs: Vec<u32>,
#[serde(rename = "novelRhs")]
novel_rhs: Vec<u32>,
lines: Vec<LinePairOut>,
}
fn diff_hunk(hunk: &knot_git::Hunk) -> DiffHunkOut {
let mut novel_lhs = Vec::new();
let mut novel_rhs = Vec::new();
let mut lines = Vec::new();
let mut lhs = hunk.old_start.get().saturating_sub(1);
let mut rhs = hunk.new_start.get().saturating_sub(1);
for line in &hunk.lines {
let (left, right) = match line.op {
LineOp::Context => (Some(lhs), Some(rhs)),
LineOp::Delete => {
novel_lhs.push(lhs);
(Some(lhs), None)
}
LineOp::Add => {
novel_rhs.push(rhs);
(None, Some(rhs))
}
};
lines.push(LinePairOut {
lhs: left,
rhs: right,
});
lhs += u32::from(left.is_some());
rhs += u32::from(right.is_some());
}
DiffHunkOut {
novel_lhs,
novel_rhs,
lines,
}
}
// org.tangled.git.defs#byteChanges
#[derive(Serialize)]
struct ByteChangesOut {
lhs: u64,
rhs: u64,
}
// org.tangled.git.defs#fileDiff
#[derive(Serialize)]
pub(crate) struct FileDiffOut {
#[serde(rename = "lhsSrc")]
lhs_src: DiffSrcOut,
#[serde(rename = "rhsSrc")]
rhs_src: DiffSrcOut,
hunks: Vec<DiffHunkOut>,
#[serde(rename = "hasByteChanges", skip_serializing_if = "Option::is_none")]
has_byte_changes: Option<ByteChangesOut>,
#[serde(rename = "hasSyntacticChanges")]
has_syntactic_changes: bool,
}
pub(crate) fn file_diff(repo: &Repo, patch: &FilePatch) -> FileDiffOut {
let old_path = (patch.status != PatchStatus::Added).then_some(&patch.path);
let new_path = (patch.status != PatchStatus::Deleted).then_some(&patch.path);
let is_binary = patch.is_binary();
FileDiffOut {
lhs_src: diff_src(repo, old_path, patch.old_oid, patch.old_kind, is_binary),
rhs_src: diff_src(repo, new_path, patch.new_oid, patch.new_kind, is_binary),
hunks: patch.hunks().iter().map(diff_hunk).collect(),
has_byte_changes: match &patch.body {
PatchBody::Binary(BinaryDiff::Encoded { sizes, .. })
| PatchBody::Binary(BinaryDiff::Omitted(sizes)) => Some(ByteChangesOut {
lhs: sizes.old,
rhs: sizes.new,
}),
PatchBody::Binary(BinaryDiff::Unchanged(_)) | PatchBody::Text(_) => None,
},
has_syntactic_changes: patch
.hunks()
.iter()
.any(|hunk| hunk.added().get() > 0 || hunk.deleted().get() > 0),
}
}
// org.tangled.git.defs#diffStat
#[derive(Serialize)]
pub(crate) struct DiffStatOut {
pub(crate) insertions: i64,
pub(crate) deletions: i64,
#[serde(rename = "filesChanged")]
pub(crate) files_changed: i64,
}
impl DiffStatOut {
pub(crate) fn of(patches: &[FilePatch]) -> Self {
let hunks = || patches.iter().flat_map(|patch| patch.hunks().iter());
Self {
insertions: hunks().map(|hunk| hunk.added().get() as i64).sum(),
deletions: hunks().map(|hunk| hunk.deleted().get() as i64).sum(),
files_changed: patches.len() as i64,
}
}
}
#[derive(Serialize)]
pub(crate) struct DiffOut<'a> {
pub(crate) diffs: &'a [FileDiffOut],
pub(crate) stats: DiffStatOut,
}