This repository has no description
1use base64::Engine;
2use base64::engine::general_purpose::STANDARD;
3use serde::ser::SerializeSeq;
4use serde::{Serialize, Serializer};
5
6use knot_git::{
7 BranchInfo, BranchTip, Commit, CommitChangeId, EntryKind, FilePatch, Hunk, Identity, LineOp,
8 PatchStatus, TagInfo,
9};
10use knot_types::{AuthorName, Email, Oid, TagName};
11
12pub(crate) const ZERO_TIME: &str = "0001-01-01T00:00:00Z";
13
14fn display_opt<S: Serializer>(
15 value: &Option<CommitChangeId>,
16 serializer: S,
17) -> Result<S::Ok, S::Error> {
18 match value {
19 Some(id) => serializer.serialize_str(id.as_str()),
20 None => serializer.serialize_none(),
21 }
22}
23
24fn zoned(seconds: i64, offset_seconds: i32) -> chrono::DateTime<chrono::FixedOffset> {
25 let offset = chrono::FixedOffset::east_opt(offset_seconds)
26 .unwrap_or_else(|| chrono::FixedOffset::east_opt(0).expect("zero offset is valid"));
27 chrono::DateTime::from_timestamp(seconds, 0)
28 .unwrap_or_default()
29 .with_timezone(&offset)
30}
31
32pub(crate) fn rfc3339(seconds: i64, offset_seconds: i32) -> String {
33 zoned(seconds, offset_seconds).to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
34}
35
36pub(crate) fn rfc2822(seconds: i64, offset_seconds: i32) -> String {
37 zoned(seconds, offset_seconds).to_rfc2822()
38}
39
40pub(crate) struct HashBytes(pub Oid);
41
42impl Serialize for HashBytes {
43 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
44 let bytes = self.0.object_id();
45 let mut seq = serializer.serialize_seq(Some(bytes.as_bytes().len()))?;
46 bytes
47 .as_bytes()
48 .iter()
49 .try_for_each(|byte| seq.serialize_element(byte))?;
50 seq.end()
51 }
52}
53
54pub(crate) struct Base64Bytes(Vec<u8>);
55
56impl Serialize for Base64Bytes {
57 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
58 serializer.serialize_str(&STANDARD.encode(&self.0))
59 }
60}
61
62#[derive(Serialize)]
63pub(crate) struct SignatureWire {
64 #[serde(rename = "Name")]
65 pub name: AuthorName,
66 #[serde(rename = "Email")]
67 pub email: Email,
68 #[serde(rename = "When")]
69 pub when: String,
70}
71
72impl SignatureWire {
73 pub fn of(identity: &Identity) -> Self {
74 Self {
75 name: identity.name.clone(),
76 email: identity.email.clone(),
77 when: rfc3339(identity.time.get(), identity.offset_seconds),
78 }
79 }
80
81 pub fn utc(identity: &Identity) -> Self {
82 Self {
83 name: identity.name.clone(),
84 email: identity.email.clone(),
85 when: rfc3339(identity.time.get(), 0),
86 }
87 }
88
89 pub fn zero() -> Self {
90 Self {
91 name: AuthorName::new(""),
92 email: Email::new(""),
93 when: ZERO_TIME.to_string(),
94 }
95 }
96}
97
98#[derive(Serialize)]
99pub(crate) struct CommitWire {
100 pub hash: HashBytes,
101 pub author: SignatureWire,
102 pub committer: SignatureWire,
103 pub message: String,
104 pub tree: Oid,
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub parent_hashes: Vec<HashBytes>,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub pgp_signature: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub merge_tag: Option<String>,
111 #[serde(
112 skip_serializing_if = "Option::is_none",
113 serialize_with = "display_opt"
114 )]
115 pub change_id: Option<CommitChangeId>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub extra_headers: Option<std::collections::BTreeMap<String, Base64Bytes>>,
118 pub this: Oid,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub parent: Option<Oid>,
121}
122
123impl CommitWire {
124 pub fn of(commit: &Commit) -> Self {
125 let extra_headers: std::collections::BTreeMap<String, Base64Bytes> = commit
126 .extra_headers
127 .iter()
128 .map(|(name, value)| (name.clone(), Base64Bytes(value.clone())))
129 .collect();
130 Self {
131 hash: HashBytes(commit.id),
132 author: SignatureWire::of(&commit.author),
133 committer: SignatureWire::of(&commit.committer),
134 message: commit.message.clone(),
135 tree: commit.tree,
136 parent_hashes: commit.parents.iter().map(|oid| HashBytes(*oid)).collect(),
137 pgp_signature: commit.pgp_signature.clone(),
138 merge_tag: commit.merge_tag.clone(),
139 change_id: commit.change_id(),
140 extra_headers: (!extra_headers.is_empty()).then_some(extra_headers),
141 this: commit.id,
142 parent: commit.parents.first().copied(),
143 }
144 }
145}
146
147#[derive(Serialize)]
148pub(crate) struct BranchCommitWire {
149 #[serde(rename = "Hash")]
150 pub hash: HashBytes,
151 #[serde(rename = "Author")]
152 pub author: SignatureWire,
153 #[serde(rename = "Committer")]
154 pub committer: SignatureWire,
155 #[serde(rename = "MergeTag")]
156 pub merge_tag: String,
157 #[serde(rename = "PGPSignature")]
158 pub pgp_signature: String,
159 #[serde(rename = "Message")]
160 pub message: String,
161 #[serde(rename = "TreeHash")]
162 pub tree_hash: HashBytes,
163 #[serde(rename = "ParentHashes")]
164 pub parent_hashes: Vec<HashBytes>,
165 #[serde(rename = "Encoding")]
166 pub encoding: String,
167 #[serde(rename = "ExtraHeaders")]
168 pub extra_headers: Option<()>,
169}
170
171#[derive(Serialize)]
172pub(crate) struct Reference {
173 pub name: String,
174 pub hash: Oid,
175}
176
177#[derive(Serialize)]
178pub(crate) struct BranchWire {
179 pub reference: Reference,
180 pub commit: BranchCommitWire,
181 #[serde(skip_serializing_if = "std::ops::Not::not")]
182 pub is_default: bool,
183}
184
185impl BranchWire {
186 pub fn of(branch: &BranchInfo, is_default: bool, absent: Oid) -> Self {
187 Self {
188 reference: Reference {
189 name: branch.name.to_string(),
190 hash: branch.tip.id(),
191 },
192 commit: match &branch.tip {
193 BranchTip::Commit(commit) => BranchCommitWire {
194 hash: HashBytes(commit.id),
195 author: SignatureWire::utc(&commit.author),
196 committer: SignatureWire::utc(&commit.committer),
197 merge_tag: String::new(),
198 pgp_signature: String::new(),
199 message: commit.message.trim_end().to_string(),
200 tree_hash: HashBytes(commit.tree),
201 parent_hashes: commit.parents.iter().map(|oid| HashBytes(*oid)).collect(),
202 encoding: String::new(),
203 extra_headers: None,
204 },
205 BranchTip::Opaque { id, message, .. } => BranchCommitWire {
206 hash: HashBytes(*id),
207 author: SignatureWire::zero(),
208 committer: SignatureWire::zero(),
209 merge_tag: String::new(),
210 pgp_signature: String::new(),
211 message: message.trim_end().to_string(),
212 tree_hash: HashBytes(absent),
213 parent_hashes: Vec::new(),
214 encoding: String::new(),
215 extra_headers: None,
216 },
217 },
218 is_default,
219 }
220 }
221}
222
223#[derive(Serialize)]
224pub(crate) struct TagObjectWire {
225 #[serde(rename = "Hash")]
226 pub hash: HashBytes,
227 #[serde(rename = "Name")]
228 pub name: TagName,
229 #[serde(rename = "Tagger")]
230 pub tagger: SignatureWire,
231 #[serde(rename = "Message")]
232 pub message: String,
233 #[serde(rename = "PGPSignature")]
234 pub pgp_signature: String,
235 #[serde(rename = "TargetType")]
236 pub target_type: i8,
237 #[serde(rename = "Target")]
238 pub target: HashBytes,
239}
240
241#[derive(Serialize)]
242pub(crate) struct TagWire {
243 pub name: TagName,
244 pub hash: Oid,
245 #[serde(skip_serializing_if = "Option::is_none")]
246 pub tag: Option<TagObjectWire>,
247 #[serde(skip_serializing_if = "String::is_empty")]
248 pub message: String,
249}
250
251const TARGET_TYPE_TAG: i8 = 4;
252
253impl TagWire {
254 pub fn of(info: &TagInfo) -> Self {
255 let message = recombine_message(&info.message);
256 let tag =
257 info.annotated.as_ref().map(|annotated| TagObjectWire {
258 hash: HashBytes(info.id),
259 name: info.name.clone(),
260 tagger: annotated.tagger.as_ref().map(SignatureWire::utc).unwrap_or(
261 SignatureWire {
262 name: AuthorName::new(""),
263 email: Email::new(""),
264 when: rfc3339(0, 0),
265 },
266 ),
267 message: message.clone(),
268 pgp_signature: annotated.pgp_signature.clone().unwrap_or_default(),
269 target_type: TARGET_TYPE_TAG,
270 target: HashBytes(annotated.target),
271 });
272 Self {
273 name: info.name.clone(),
274 hash: info.id,
275 tag,
276 message,
277 }
278 }
279}
280
281pub(crate) fn fold_subject(message: &str) -> String {
282 message
283 .split("\n\n")
284 .next()
285 .unwrap_or_default()
286 .lines()
287 .map(str::trim_end)
288 .filter(|line| !line.is_empty())
289 .collect::<Vec<_>>()
290 .join(" ")
291}
292
293pub(crate) fn message_body(message: &str) -> String {
294 message
295 .split_once("\n\n")
296 .map(|(_, body)| body.trim_matches('\n').to_string())
297 .unwrap_or_default()
298}
299
300fn recombine_message(message: &str) -> String {
301 let subject = fold_subject(message);
302 let body = message_body(message);
303 match (subject.is_empty(), body.is_empty()) {
304 (_, true) => subject,
305 (true, false) => body,
306 (false, false) => format!("{subject}\n\n{body}"),
307 }
308}
309
310#[derive(Serialize)]
311pub(crate) struct LineWire {
312 #[serde(rename = "Op")]
313 pub op: u8,
314 #[serde(rename = "Line")]
315 pub line: String,
316}
317
318#[derive(Serialize)]
319pub(crate) struct TextFragmentWire {
320 #[serde(rename = "Comment")]
321 pub comment: String,
322 #[serde(rename = "OldPosition")]
323 pub old_position: i64,
324 #[serde(rename = "OldLines")]
325 pub old_lines: i64,
326 #[serde(rename = "NewPosition")]
327 pub new_position: i64,
328 #[serde(rename = "NewLines")]
329 pub new_lines: i64,
330 #[serde(rename = "LinesAdded")]
331 pub lines_added: i64,
332 #[serde(rename = "LinesDeleted")]
333 pub lines_deleted: i64,
334 #[serde(rename = "LeadingContext")]
335 pub leading_context: i64,
336 #[serde(rename = "TrailingContext")]
337 pub trailing_context: i64,
338 #[serde(rename = "Lines")]
339 pub lines: Vec<LineWire>,
340}
341
342impl TextFragmentWire {
343 pub fn of(hunk: &Hunk) -> Self {
344 let lines: Vec<LineWire> = hunk
345 .lines
346 .iter()
347 .map(|line| LineWire {
348 op: match line.op {
349 LineOp::Context => 0,
350 LineOp::Delete => 1,
351 LineOp::Add => 2,
352 },
353 line: String::from_utf8_lossy(&line.text).into_owned(),
354 })
355 .collect();
356 let leading = lines.iter().take_while(|line| line.op == 0).count();
357 let trailing = if leading == lines.len() {
358 0
359 } else {
360 lines.iter().rev().take_while(|line| line.op == 0).count()
361 };
362 Self {
363 comment: String::new(),
364 old_position: hunk.old_start.get() as i64,
365 old_lines: hunk.old_lines.get() as i64,
366 new_position: hunk.new_start.get() as i64,
367 new_lines: hunk.new_lines.get() as i64,
368 lines_added: hunk.added().get() as i64,
369 lines_deleted: hunk.deleted().get() as i64,
370 leading_context: leading as i64,
371 trailing_context: trailing as i64,
372 lines,
373 }
374 }
375}
376
377#[derive(Serialize)]
378pub(crate) struct DiffNameWire {
379 pub old: String,
380 pub new: String,
381}
382
383#[derive(Serialize)]
384pub(crate) struct DiffWire {
385 pub name: DiffNameWire,
386 pub text_fragments: Option<Vec<TextFragmentWire>>,
387 pub is_binary: bool,
388 pub is_new: bool,
389 pub is_delete: bool,
390 pub is_copy: bool,
391 pub is_rename: bool,
392}
393
394impl DiffWire {
395 pub fn of(patch: &FilePatch) -> Self {
396 let fragments: Vec<TextFragmentWire> =
397 patch.hunks.iter().map(TextFragmentWire::of).collect();
398 Self {
399 name: DiffNameWire {
400 old: match patch.status {
401 PatchStatus::Added => String::new(),
402 _ => patch.path.to_string(),
403 },
404 new: match patch.status {
405 PatchStatus::Deleted => String::new(),
406 _ => patch.path.to_string(),
407 },
408 },
409 text_fragments: (!fragments.is_empty()).then_some(fragments),
410 is_binary: patch.is_binary,
411 is_new: patch.status == PatchStatus::Added,
412 is_delete: patch.status == PatchStatus::Deleted,
413 is_copy: false,
414 is_rename: false,
415 }
416 }
417}
418
419#[derive(Serialize)]
420pub(crate) struct DiffStatWire {
421 pub insertions: i64,
422 pub deletions: i64,
423 pub files_changed: i64,
424}
425
426#[derive(Serialize)]
427pub(crate) struct NiceDiffWire {
428 pub commit: CommitWire,
429 pub stat: DiffStatWire,
430 pub diff: Option<Vec<DiffWire>>,
431}
432
433pub(crate) fn nice_diff(commit: &Commit, patches: &[FilePatch]) -> NiceDiffWire {
434 let diffs: Vec<DiffWire> = patches.iter().map(DiffWire::of).collect();
435 let stat = DiffStatWire {
436 insertions: patches
437 .iter()
438 .flat_map(|patch| patch.hunks.iter())
439 .map(|hunk| hunk.added().get() as i64)
440 .sum(),
441 deletions: patches
442 .iter()
443 .flat_map(|patch| patch.hunks.iter())
444 .map(|hunk| hunk.deleted().get() as i64)
445 .sum(),
446 files_changed: patches.len() as i64,
447 };
448 NiceDiffWire {
449 commit: CommitWire::of(commit),
450 stat,
451 diff: (!diffs.is_empty()).then_some(diffs),
452 }
453}
454
455#[derive(Serialize)]
456pub(crate) struct PatchIdentityWire {
457 #[serde(rename = "Name")]
458 pub name: AuthorName,
459 #[serde(rename = "Email")]
460 pub email: Email,
461}
462
463#[derive(Serialize)]
464pub(crate) struct FormatPatchWire {
465 #[serde(rename = "Files")]
466 pub files: Option<Vec<FileWire>>,
467 #[serde(rename = "SHA")]
468 pub sha: Oid,
469 #[serde(rename = "Author")]
470 pub author: Option<PatchIdentityWire>,
471 #[serde(rename = "AuthorDate")]
472 pub author_date: String,
473 #[serde(rename = "Committer")]
474 pub committer: Option<()>,
475 #[serde(rename = "CommitterDate")]
476 pub committer_date: String,
477 #[serde(rename = "Title")]
478 pub title: String,
479 #[serde(rename = "Body")]
480 pub body: String,
481 #[serde(rename = "SubjectPrefix")]
482 pub subject_prefix: String,
483 #[serde(rename = "BodyAppendix")]
484 pub body_appendix: String,
485 #[serde(rename = "RawHeaders")]
486 pub raw_headers: Option<std::collections::BTreeMap<String, Vec<String>>>,
487 #[serde(rename = "Raw")]
488 pub raw: String,
489}
490
491pub(crate) fn normalize_message_section<'a>(lines: impl Iterator<Item = &'a str>) -> String {
492 lines
493 .map(str::trim_end)
494 .fold((String::new(), 0usize), |(mut out, blanks), line| {
495 if line.is_empty() {
496 return (out, blanks + 1);
497 }
498 if !out.is_empty() {
499 out.push('\n');
500 if blanks > 0 {
501 out.push('\n');
502 }
503 }
504 out.push_str(line);
505 (out, 0)
506 })
507 .0
508}
509
510fn entry_mode_decimal(kind: EntryKind) -> u32 {
511 match kind {
512 EntryKind::Tree => 0o040000,
513 EntryKind::Blob => 0o100644,
514 EntryKind::BlobExecutable => 0o100755,
515 EntryKind::Link => 0o120000,
516 EntryKind::Commit => 0o160000,
517 }
518}
519
520pub(crate) fn entry_mode_octal(kind: EntryKind) -> String {
521 format!("{:06o}", entry_mode_decimal(kind))
522}
523
524#[derive(Serialize)]
525pub(crate) struct FileWire {
526 #[serde(rename = "OldName")]
527 pub old_name: String,
528 #[serde(rename = "NewName")]
529 pub new_name: String,
530 #[serde(rename = "IsNew")]
531 pub is_new: bool,
532 #[serde(rename = "IsDelete")]
533 pub is_delete: bool,
534 #[serde(rename = "IsCopy")]
535 pub is_copy: bool,
536 #[serde(rename = "IsRename")]
537 pub is_rename: bool,
538 #[serde(rename = "OldMode")]
539 pub old_mode: u32,
540 #[serde(rename = "NewMode")]
541 pub new_mode: u32,
542 #[serde(rename = "OldOIDPrefix")]
543 pub old_oid_prefix: String,
544 #[serde(rename = "NewOIDPrefix")]
545 pub new_oid_prefix: String,
546 #[serde(rename = "Score")]
547 pub score: i64,
548 #[serde(rename = "TextFragments")]
549 pub text_fragments: Option<Vec<TextFragmentWire>>,
550 #[serde(rename = "IsBinary")]
551 pub is_binary: bool,
552 #[serde(rename = "BinaryFragment")]
553 pub binary_fragment: Option<()>,
554 #[serde(rename = "ReverseBinaryFragment")]
555 pub reverse_binary_fragment: Option<()>,
556}
557
558impl FileWire {
559 pub fn of(patch: &FilePatch) -> Self {
560 let fragments: Vec<TextFragmentWire> =
561 patch.hunks.iter().map(TextFragmentWire::of).collect();
562 let same_mode = patch.old_kind.is_some() && patch.old_kind == patch.new_kind;
563 Self {
564 old_name: match patch.status {
565 PatchStatus::Added => String::new(),
566 _ => patch.path.to_string(),
567 },
568 new_name: match patch.status {
569 PatchStatus::Deleted => String::new(),
570 _ => patch.path.to_string(),
571 },
572 is_new: patch.status == PatchStatus::Added,
573 is_delete: patch.status == PatchStatus::Deleted,
574 is_copy: false,
575 is_rename: false,
576 old_mode: match patch.status {
577 PatchStatus::Added => 0,
578 PatchStatus::Deleted | PatchStatus::Modified => {
579 patch.old_kind.map(entry_mode_decimal).unwrap_or(0)
580 }
581 },
582 new_mode: match patch.status {
583 PatchStatus::Added => patch.new_kind.map(entry_mode_decimal).unwrap_or(0),
584 PatchStatus::Deleted => 0,
585 PatchStatus::Modified if same_mode => 0,
586 PatchStatus::Modified => patch.new_kind.map(entry_mode_decimal).unwrap_or(0),
587 },
588 old_oid_prefix: patch.old_oid.to_hex(),
589 new_oid_prefix: patch.new_oid.to_hex(),
590 score: 0,
591 text_fragments: (!fragments.is_empty()).then_some(fragments),
592 is_binary: patch.is_binary,
593 binary_fragment: None,
594 reverse_binary_fragment: None,
595 }
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::{rfc2822, rfc3339};
602
603 const A_JUNE_INSTANT: i64 = 1_717_236_600;
604
605 #[test]
606 fn rfc3339_renders_the_commits_own_offset_independent_of_the_host_zone() {
607 assert!(rfc3339(A_JUNE_INSTANT, 7200).ends_with("+02:00"));
608 assert!(rfc3339(A_JUNE_INSTANT, -18000).ends_with("-05:00"));
609 assert!(rfc3339(A_JUNE_INSTANT, 0).ends_with('Z'));
610 }
611
612 #[test]
613 fn rfc2822_keeps_the_signed_offset() {
614 assert!(rfc2822(A_JUNE_INSTANT, 7200).ends_with("+0200"));
615 assert!(rfc2822(A_JUNE_INSTANT, -18000).ends_with("-0500"));
616 }
617}