1use std::path::Path;
13
14use serde::Serialize;
15
16use crate::error::ForgeError;
17use crate::forks;
18use crate::html::{agent_label, crumbs, page};
19use crate::ownership::ForgeAgent;
20use crate::prgit;
21use crate::repo;
22use crate::request::{esc, percent_decode};
23use crate::{ForgeRequest, ForgeResponse, ForgeService};
24
25pub const COMMITS_PER_PAGE: usize = 30;
27
28const HEAD_SPEC_MAX: usize = 320;
30
31#[must_use]
33pub fn valid_owner(s: &str) -> bool {
34 let b = s.as_bytes();
35 !b.is_empty()
36 && b.len() <= 64
37 && b[0].is_ascii_alphanumeric()
38 && b.iter()
39 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'))
40 && !s.contains("..")
41 && !crate::guard::reserved_owner(s)
42}
43
44#[must_use]
47pub fn valid_repo(s: &str) -> bool {
48 let b = s.as_bytes();
49 !b.is_empty()
50 && b.len() <= 100
51 && b[0].is_ascii_alphanumeric()
52 && b.iter()
53 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-'))
54 && !s.contains("..")
55 && !s.ends_with(".git")
56}
57
58#[must_use]
63pub fn valid_ref(s: &str) -> bool {
64 let b = s.as_bytes();
65 !b.is_empty()
66 && b.len() <= 201
67 && b[0].is_ascii_alphanumeric()
68 && b.iter()
69 .all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'/' | b'-'))
70 && !s.contains("..")
71 && !s.contains("//")
72 && !s.ends_with('/')
73 && !s.ends_with('.')
74 && !s.ends_with(".lock")
75 && !s.contains("/.")
76 && !s.contains(".lock/")
77}
78
79#[must_use]
81pub fn valid_sha(s: &str) -> bool {
82 (4..=64).contains(&s.len()) && s.bytes().all(|c| matches!(c, b'0'..=b'9' | b'a'..=b'f'))
83}
84
85#[must_use]
87pub fn parse_head_spec(base_owner: &str, spec: &str) -> Option<(String, String)> {
88 if spec.len() > HEAD_SPEC_MAX {
89 return None;
90 }
91 let (owner, r) = match spec.split_once(':') {
92 Some((o, r)) => (o, r),
93 None => (base_owner, spec),
94 };
95 if !valid_owner(owner) || !valid_ref(r) {
96 return None;
97 }
98 Some((owner.to_string(), r.to_string()))
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct HeadRef {
104 pub owner: String,
106 pub repo: String,
108 #[serde(rename = "ref")]
110 pub ref_name: String,
111 pub sha: String,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117pub struct CommitSummary {
118 pub sha: String,
120 pub short: String,
122 pub author: String,
124 pub email: String,
126 pub at: i64,
128 pub subject: String,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "lowercase")]
135pub enum LineKind {
136 Add,
138 Del,
140 Ctx,
142 Meta,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct DiffLine {
150 #[serde(rename = "type")]
152 pub kind: LineKind,
153 pub old_line: Option<u64>,
155 pub new_line: Option<u64>,
157 pub text: String,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163pub struct Hunk {
164 pub header: String,
166 pub lines: Vec<DiffLine>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172pub struct DiffFile {
173 pub name: Option<String>,
175 pub binary: bool,
177 pub adds: u64,
179 pub dels: u64,
181 pub hunks: Vec<Hunk>,
183}
184
185fn parse_hunk_header(line: &str) -> Option<(u64, u64, u64, u64)> {
186 let rest = line.strip_prefix("@@ -")?;
187 let (old, rest) = rest.split_once(" +")?;
188 let (new, _) = rest.split_once(" @@")?;
189 let pair = |s: &str| -> Option<(u64, u64)> {
190 match s.split_once(',') {
191 Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
192 None => Some((s.parse().ok()?, 1)),
193 }
194 };
195 let (os, oc) = pair(old)?;
196 let (ns, nc) = pair(new)?;
197 Some((os, oc, ns, nc))
198}
199
200#[must_use]
205pub fn parse_patch(patch: &str) -> Vec<DiffFile> {
206 let mut files: Vec<DiffFile> = Vec::new();
207 let (mut old_left, mut new_left) = (0u64, 0u64);
208 let (mut old_n, mut new_n) = (0u64, 0u64);
209 for line in patch.split('\n') {
210 let in_hunk = old_left > 0 || new_left > 0;
211 if !in_hunk && line.starts_with("diff --git ") {
212 let name = line
213 .strip_prefix("diff --git a/")
214 .and_then(|r| r.rsplit_once(" b/"))
215 .map(|(_, b)| b.to_string());
216 files.push(DiffFile {
217 name,
218 binary: false,
219 adds: 0,
220 dels: 0,
221 hunks: Vec::new(),
222 });
223 continue;
224 }
225 let Some(cur) = files.last_mut() else {
226 continue;
227 };
228 if in_hunk {
229 let hunk = cur.hunks.last_mut().expect("hunk open");
230 if let Some(t) = line.strip_prefix('+') {
231 hunk.lines.push(DiffLine {
232 kind: LineKind::Add,
233 old_line: None,
234 new_line: Some(new_n),
235 text: t.to_string(),
236 });
237 new_n += 1;
238 new_left = new_left.saturating_sub(1);
239 cur.adds += 1;
240 } else if let Some(t) = line.strip_prefix('-') {
241 hunk.lines.push(DiffLine {
242 kind: LineKind::Del,
243 old_line: Some(old_n),
244 new_line: None,
245 text: t.to_string(),
246 });
247 old_n += 1;
248 old_left = old_left.saturating_sub(1);
249 cur.dels += 1;
250 } else if let Some(t) = line.strip_prefix(' ') {
251 hunk.lines.push(DiffLine {
252 kind: LineKind::Ctx,
253 old_line: Some(old_n),
254 new_line: Some(new_n),
255 text: t.to_string(),
256 });
257 old_n += 1;
258 new_n += 1;
259 old_left = old_left.saturating_sub(1);
260 new_left = new_left.saturating_sub(1);
261 } else if line.starts_with('\\') {
262 hunk.lines.push(DiffLine {
263 kind: LineKind::Meta,
264 old_line: None,
265 new_line: None,
266 text: line.to_string(),
267 });
268 }
269 continue;
270 }
271 if let Some(n) = line.strip_prefix("+++ ") {
272 if n != "/dev/null" {
273 cur.name = Some(n.strip_prefix("b/").unwrap_or(n).to_string());
274 }
275 } else if let Some(n) = line.strip_prefix("--- ") {
276 if cur.name.is_none() && n != "/dev/null" {
277 cur.name = Some(n.strip_prefix("a/").unwrap_or(n).to_string());
278 }
279 } else if line.starts_with("Binary files") {
280 cur.binary = true;
281 } else if let Some((os, oc, ns, nc)) = parse_hunk_header(line) {
282 old_n = os;
283 new_n = ns;
284 old_left = oc;
285 new_left = nc;
286 cur.hunks.push(Hunk {
287 header: line.to_string(),
288 lines: Vec::new(),
289 });
290 } else if line.starts_with('\\') {
291 if let Some(h) = cur.hunks.last_mut() {
292 h.lines.push(DiffLine {
293 kind: LineKind::Meta,
294 old_line: None,
295 new_line: None,
296 text: line.to_string(),
297 });
298 }
299 }
300 }
301 files
302}
303
304#[must_use]
306pub fn render_diff(files: &[DiffFile]) -> String {
307 if files.is_empty() {
308 return "<p class=\"muted\">No changes.</p>".to_string();
309 }
310 let mut out = String::new();
311 for f in files {
312 let name = esc(f.name.as_deref().unwrap_or("(unknown)"));
313 out.push_str(&format!(
314 "<details class=\"dfile\" open><summary class=\"dhead\"><span class=\"fname\">{name}</span>\
315<span class=\"counts\"><span class=\"adds\">+{}</span> <span class=\"dels\">−{}</span></span></summary>",
316 f.adds, f.dels
317 ));
318 if f.binary {
319 out.push_str("<div class=\"dbinary muted\">Binary file not shown.</div>");
320 } else {
321 out.push_str("<table class=\"diff\">");
322 for h in &f.hunks {
323 out.push_str(&format!(
324 "<tr class=\"hunk\"><td class=\"num\" colspan=\"2\"></td><td class=\"code\">{}</td></tr>",
325 esc(&h.header)
326 ));
327 for l in &h.lines {
328 let num = |n: Option<u64>| n.map(|v| v.to_string()).unwrap_or_default();
329 match l.kind {
330 LineKind::Meta => out.push_str(&format!(
331 "<tr class=\"ctx\"><td class=\"num\" colspan=\"2\"></td><td class=\"code muted\">{}</td></tr>",
332 esc(&l.text)
333 )),
334 k => {
335 let (cls, sign) = match k {
336 LineKind::Add => ("add", '+'),
337 LineKind::Del => ("del", '-'),
338 _ => ("ctx", ' '),
339 };
340 out.push_str(&format!(
341 "<tr class=\"{cls}\"><td class=\"num\">{}</td><td class=\"num\">{}</td><td class=\"code\">{sign}{}</td></tr>",
342 num(l.old_line),
343 num(l.new_line),
344 esc(&l.text)
345 ));
346 }
347 }
348 }
349 }
350 out.push_str("</table>");
351 }
352 out.push_str("</details>\n");
353 }
354 out
355}
356
357pub(crate) async fn commit_range(
360 dir: &Path,
361 base_sha: &str,
362 head_sha: &str,
363) -> Result<(Vec<CommitSummary>, bool), ForgeError> {
364 if !valid_sha(base_sha) || !valid_sha(head_sha) {
365 return Err(ForgeError::BadRequest(
366 "range ends must be object ids".into(),
367 ));
368 }
369 let range = format!("{base_sha}..{head_sha}");
370 let n = format!("-n{}", COMMITS_PER_PAGE + 1);
371 let out = prgit::text(
372 dir,
373 &[
374 "log",
375 "-z",
376 "--no-ext-diff",
377 "--no-textconv",
378 &n,
379 "--format=%H%x00%h%x00%an%x00%ae%x00%at%x00%s",
380 &range,
381 ],
382 )
383 .await?;
384 let fields: Vec<&str> = out.split('\0').collect();
385 let mut commits = Vec::new();
386 let mut i = 0;
387 while i + 5 < fields.len() {
388 commits.push(CommitSummary {
389 sha: fields[i].trim_start_matches('\n').to_string(),
390 short: fields[i + 1].to_string(),
391 author: fields[i + 2].to_string(),
392 email: fields[i + 3].to_string(),
393 at: fields[i + 4].parse().unwrap_or(0),
394 subject: fields[i + 5].to_string(),
395 });
396 i += 6;
397 }
398 let more = commits.len() > COMMITS_PER_PAGE;
399 commits.truncate(COMMITS_PER_PAGE);
400 Ok((commits, more))
401}
402
403pub(crate) async fn diff_files(
405 dir: &Path,
406 merge_base: Option<&str>,
407 base_sha: &str,
408 head_sha: &str,
409) -> Vec<DiffFile> {
410 let from = merge_base.unwrap_or(base_sha);
411 if !valid_sha(from) || !valid_sha(head_sha) {
412 return Vec::new();
413 }
414 let patch = prgit::text(
415 dir,
416 &[
417 "diff",
418 "--no-color",
419 "--no-ext-diff",
420 "--no-textconv",
421 from,
422 head_sha,
423 ],
424 )
425 .await
426 .unwrap_or_default();
427 parse_patch(&patch)
428}
429
430#[derive(Debug, Clone, Serialize)]
432#[serde(rename_all = "camelCase")]
433pub struct Compare {
434 pub base_ref: String,
436 pub base_sha: String,
438 pub head: HeadRef,
440 pub ahead_by: u64,
442 pub behind_by: u64,
444 pub merge_base: Option<String>,
446 pub commits: Vec<CommitSummary>,
448 pub has_more: bool,
450 pub files: Vec<DiffFile>,
452}
453
454#[derive(Clone, Copy)]
461pub(crate) enum HeadAccess<'a> {
462 Caller(&'a ForgeAgent),
466 ExistingPr,
469 Cached,
472}
473
474pub(crate) const MAX_FORGE_HEADS: usize = 64;
476
477pub(crate) const MAX_HEAD_FETCH_BYTES: u64 = 256 * 1024 * 1024;
479
480pub(crate) const FETCH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
482
483impl ForgeService {
484 pub(crate) async fn resolve_head(
488 &self,
489 base_owner: &str,
490 name: &str,
491 spec: &str,
492 access: HeadAccess<'_>,
493 ) -> Result<Option<HeadRef>, ForgeError> {
494 let Some((owner, r)) = parse_head_spec(base_owner, spec) else {
495 return Ok(None);
496 };
497 if !valid_repo(name) {
498 return Ok(None);
499 }
500 let dir = repo::repo_git_dir(&self.repo_root, base_owner, name)?;
501 let make = |sha: String| HeadRef {
502 owner: owner.clone(),
503 repo: name.to_string(),
504 ref_name: r.clone(),
505 sha,
506 };
507 if owner == base_owner {
508 return Ok(prgit::rev_parse(&dir, &format!("refs/heads/{r}"))
509 .await
510 .map(make));
511 }
512 let local = format!("refs/forge/heads/{owner}/{r}");
513 let may_fetch = match access {
514 HeadAccess::Cached => {
515 return Ok(prgit::rev_parse(&dir, &local).await.map(make));
516 }
517 HeadAccess::ExistingPr => true,
518 HeadAccess::Caller(agent) => {
519 agent.owner() == Some(owner.as_str())
520 || self.open_pr_names_head(base_owner, name, &owner, &r).await
521 }
522 };
523 if !may_fetch {
524 return Ok(None);
525 }
526 let Ok(head_dir) = repo::repo_git_dir(&self.repo_root, &owner, name) else {
527 return Ok(None);
528 };
529 if !tokio::fs::metadata(&head_dir)
530 .await
531 .map(|m| m.is_dir())
532 .unwrap_or(false)
533 {
534 return Ok(None);
535 }
536 let refspec = format!("+refs/heads/{r}:{local}");
537 let head_path = head_dir.to_string_lossy().into_owned();
538 let _g = prgit::fetch_locks()
539 .lock_within(&format!("{base_owner}/{name}"), FETCH_LOCK_WAIT)
540 .await?;
541 forks::ensure_forge_refs_hidden(&dir).await;
543 let held = prgit::text(
544 &dir,
545 &["for-each-ref", "--format=%(refname)", "refs/forge/heads/"],
546 )
547 .await
548 .unwrap_or_default();
549 if held.lines().count() >= MAX_FORGE_HEADS && !held.lines().any(|l| l == local) {
550 return Err(ForgeError::Conflict(
551 "too many foreign heads are held for this repository".into(),
552 ));
553 }
554 let args = [
555 "-c",
556 "fetch.fsckObjects=true",
557 "fetch",
558 "--quiet",
559 "--no-tags",
560 "--no-write-fetch-head",
561 "--",
562 head_path.as_str(),
563 refspec.as_str(),
564 ];
565 let fetch = prgit::run(&dir, &args);
566 let fetched = crate::sizecap::guard_growth(&dir, MAX_HEAD_FETCH_BYTES, fetch).await?;
567 if !fetched.ok() {
568 return Ok(None);
569 }
570 Ok(prgit::rev_parse(&dir, &local).await.map(make))
571 }
572
573 async fn open_pr_names_head(
576 &self,
577 base_owner: &str,
578 name: &str,
579 head_owner: &str,
580 head_ref: &str,
581 ) -> bool {
582 use crate::spine::issues::IssueState;
583 match crate::pulls::load_pull_index(self.spine.as_ref(), base_owner, name).await {
584 Ok(idx) => idx.pulls.values().any(|p| {
585 p.state == IssueState::Open
586 && p.head.owner == head_owner
587 && p.head.ref_name == head_ref
588 }),
589 Err(_) => false,
590 }
591 }
592
593 pub(crate) async fn release_head_ref(
597 &self,
598 base_owner: &str,
599 name: &str,
600 idx: &crate::pulls::PullIndex,
601 pr: &crate::pulls::PullEntry,
602 ) {
603 use crate::spine::issues::IssueState;
604 if pr.head.owner == base_owner {
605 return;
606 }
607 let shared = idx.pulls.values().any(|p| {
608 p.number != pr.number
609 && p.state == IssueState::Open
610 && p.head.owner == pr.head.owner
611 && p.head.ref_name == pr.head.ref_name
612 });
613 if shared || !valid_owner(&pr.head.owner) || !valid_ref(&pr.head.ref_name) {
614 return;
615 }
616 let Ok(dir) = repo::repo_git_dir(&self.repo_root, base_owner, name) else {
617 return;
618 };
619 let local = format!("refs/forge/heads/{}/{}", pr.head.owner, pr.head.ref_name);
620 let _ = prgit::run(&dir, &["update-ref", "-d", &local]).await;
621 }
622
623 pub(crate) async fn resolve_base(&self, dir: &Path, base_ref: &str) -> Option<String> {
625 if !valid_ref(base_ref) {
626 return None;
627 }
628 if let Some(s) = prgit::rev_parse(dir, &format!("refs/heads/{base_ref}")).await {
629 return Some(s);
630 }
631 if let Some(s) = prgit::rev_parse(dir, &format!("refs/tags/{base_ref}^{{commit}}")).await {
632 return Some(s);
633 }
634 if valid_sha(base_ref) {
635 return prgit::rev_parse(dir, &format!("{base_ref}^{{commit}}")).await;
636 }
637 None
638 }
639
640 pub(crate) async fn compare_data(
642 &self,
643 owner: &str,
644 name: &str,
645 base_ref: &str,
646 head_spec: &str,
647 access: HeadAccess<'_>,
648 ) -> Result<Option<Compare>, ForgeError> {
649 let dir = repo::repo_git_dir(&self.repo_root, owner, name)?;
650 let Some(base_sha) = self.resolve_base(&dir, base_ref).await else {
651 return Ok(None);
652 };
653 let Some(head) = self.resolve_head(owner, name, head_spec, access).await? else {
654 return Ok(None);
655 };
656 let counts = prgit::text(
657 &dir,
658 &[
659 "rev-list",
660 "--left-right",
661 "--count",
662 &format!("{base_sha}...{}", head.sha),
663 ],
664 )
665 .await?;
666 let mut it = counts.split_whitespace();
667 let behind_by: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
668 let ahead_by: u64 = it.next().and_then(|v| v.parse().ok()).unwrap_or(0);
669 let merge_base = prgit::text(&dir, &["merge-base", &base_sha, &head.sha])
670 .await
671 .ok()
672 .map(|s| s.trim().to_string())
673 .filter(|s| !s.is_empty());
674 let (commits, has_more) = commit_range(&dir, &base_sha, &head.sha).await?;
675 let files = diff_files(&dir, merge_base.as_deref(), &base_sha, &head.sha).await;
676 Ok(Some(Compare {
677 base_ref: base_ref.to_string(),
678 base_sha,
679 head,
680 ahead_by,
681 behind_by,
682 merge_base,
683 commits,
684 has_more,
685 files,
686 }))
687 }
688
689 pub(crate) async fn h_api_compare(
691 &self,
692 agent: &ForgeAgent,
693 owner: &str,
694 repo_name: &str,
695 spec: &str,
696 ) -> Result<ForgeResponse, ForgeError> {
697 self.resolve_repo(owner, repo_name).await?;
698 let not_found = || Ok(ForgeResponse::error(404, "not found"));
699 let Some((b, h)) = split_spec(spec) else {
700 return not_found();
701 };
702 let Some(c) = self
703 .compare_data(owner, repo_name, b, h, HeadAccess::Caller(agent))
704 .await?
705 else {
706 return not_found();
707 };
708 Ok(ForgeResponse::json(
709 200,
710 &serde_json::json!({
711 "base": {"ref": c.base_ref, "sha": c.base_sha},
712 "head": {"owner": c.head.owner, "repo": c.head.repo, "ref": c.head.ref_name, "sha": c.head.sha},
713 "aheadBy": c.ahead_by,
714 "behindBy": c.behind_by,
715 "mergeBase": c.merge_base,
716 "hasMore": c.has_more,
717 "commits": c.commits,
718 "files": c.files,
719 }),
720 ))
721 }
722
723 pub(crate) async fn h_compare_page(
725 &self,
726 agent: &ForgeAgent,
727 owner: &str,
728 repo_name: &str,
729 spec: &str,
730 ) -> Result<ForgeResponse, ForgeError> {
731 self.resolve_repo(owner, repo_name).await?;
732 let Some((b, h)) = split_spec(spec) else {
733 return Ok(ForgeResponse::error(404, "not found"));
734 };
735 let Some(c) = self
736 .compare_data(owner, repo_name, b, h, HeadAccess::Caller(agent))
737 .await?
738 else {
739 return Ok(ForgeResponse::error(404, "not found"));
740 };
741 let prefix = self.prefix();
742 let base = format!("{prefix}/{owner}/{repo_name}");
743 let head_label = format!("{}:{}", agent_label(&c.head.owner), c.head.ref_name);
744 let enc = |s: &str| {
745 s.bytes()
746 .map(|c| {
747 if c.is_ascii_alphanumeric() || b"-._~".contains(&c) {
748 (c as char).to_string()
749 } else {
750 format!("%{c:02X}")
751 }
752 })
753 .collect::<String>()
754 };
755 let pr_href = format!("{base}/pulls/new?base={}&head={}", enc(b), enc(h));
756 let mut body = crumbs(&[
757 ("forge", Some(prefix.clone())),
758 (&agent_label(owner), Some(format!("{prefix}/{owner}"))),
759 (repo_name, Some(base.clone())),
760 ("compare", None),
761 ]);
762 body.push_str(&format!(
763 "<h1>Comparing <code>{}...{}</code></h1>\
764<p><span class=\"badge\">{} ahead, {} behind {}</span> {}</p>",
765 esc(b),
766 esc(&head_label),
767 c.ahead_by,
768 c.behind_by,
769 esc(b),
770 if c.ahead_by > 0 {
771 format!("<a href=\"{}\">Open pull request</a>", esc(&pr_href))
772 } else {
773 "<span class=\"muted\">Nothing to compare: the base contains the head.</span>"
774 .to_string()
775 }
776 ));
777 body.push_str(&commit_rows(&base, &c.commits, c.has_more));
778 body.push_str(&render_diff(&c.files));
779 Ok(ForgeResponse::html(
780 200,
781 page(
782 &format!("Comparing {b}...{head_label} - {owner}/{repo_name}"),
783 &body,
784 ),
785 ))
786 }
787}
788
789fn split_spec(spec: &str) -> Option<(&str, &str)> {
791 let i = spec.find("...")?;
792 (i >= 1).then(|| (&spec[..i], &spec[i + 3..]))
793}
794
795pub(crate) fn commit_rows(base: &str, commits: &[CommitSummary], has_more: bool) -> String {
797 let mut out = format!(
798 "<h3>{}{} commit{}</h3>",
799 commits.len(),
800 if has_more { "+" } else { "" },
801 if commits.len() == 1 { "" } else { "s" }
802 );
803 if commits.is_empty() {
804 out.push_str("<p class=\"muted\">No commits.</p>");
805 return out;
806 }
807 out.push_str("<table><tbody>");
808 for c in commits {
809 out.push_str(&format!(
810 "<tr><td><a href=\"{b}/commit/{sha}\">{subj}</a><div class=\"muted\">{who}</div></td>\
811<td><a href=\"{b}/commit/{sha}\"><code>{short}</code></a></td></tr>",
812 b = esc(base),
813 sha = esc(&c.sha),
814 subj = esc(&c.subject),
815 who = esc(&c.author),
816 short = esc(&c.short),
817 ));
818 }
819 out.push_str("</tbody></table>");
820 out
821}
822
823pub(crate) fn query_decoded(req: &ForgeRequest, key: &str) -> String {
825 req.query
826 .split('&')
827 .find_map(|p| {
828 let (k, v) = p.split_once('=').unwrap_or((p, ""));
829 (k == key).then(|| percent_decode(v))
830 })
831 .unwrap_or_default()
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn validators_match_jss_rules() {
840 assert!(valid_owner("dana"));
841 assert!(valid_owner(&"a".repeat(64)));
842 assert!(!valid_owner(&"a".repeat(65)));
843 assert!(!valid_owner("-x"));
844 assert!(!valid_owner(".x"));
845 assert!(!valid_owner("a..b"));
846 assert!(valid_ref("feature/x"));
847 assert!(!valid_ref("--upload-pack=x"));
848 assert!(!valid_ref("-x"));
849 assert!(!valid_ref("bad..ref"));
850 assert!(!valid_ref("a//b"));
851 assert!(!valid_ref("a/"));
852 assert!(!valid_ref("a.lock"));
853 assert!(!valid_ref("a/.b"));
854 assert!(!valid_ref("--output=x"));
855 assert!(!valid_ref("a b"));
856 assert!(valid_sha("deadbeef"));
857 assert!(!valid_sha("abc"));
858 assert!(!valid_sha("DEADBEEF"));
859 assert!(!valid_sha("--help"));
860 }
861
862 #[test]
863 fn head_spec_parsing() {
864 assert_eq!(
865 parse_head_spec("casey", "dana:feature"),
866 Some(("dana".into(), "feature".into()))
867 );
868 assert_eq!(
869 parse_head_spec("casey", "feature"),
870 Some(("casey".into(), "feature".into()))
871 );
872 for bad in [
873 "dana:--upload-pack=x",
874 "-x:feature",
875 "dana:bad..ref",
876 "dana:a//b",
877 "dana:a/",
878 "",
879 "dana:",
880 ":x",
881 ] {
882 assert_eq!(parse_head_spec("casey", bad), None, "{bad}");
883 }
884 assert_eq!(
885 parse_head_spec("casey", &format!("dana:{}", "a".repeat(400))),
886 None
887 );
888 }
889
890 #[test]
891 fn spec_split() {
892 assert_eq!(
893 split_spec("main...dana:feature"),
894 Some(("main", "dana:feature"))
895 );
896 assert_eq!(split_spec("main"), None);
897 assert_eq!(split_spec("...x"), None);
898 }
899
900 const PATCH: &str = "diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md\n\
901new file mode 100644\nindex 0000000..1111111\n--- /dev/null\n+++ b/CONTRIBUTING.md\n\
902@@ -0,0 +1,3 @@\n+# Contributing\n+\n+Please be kind.\n\
903diff --git a/src/a.txt b/src/a.txt\nindex 1..2 100644\n--- a/src/a.txt\n+++ b/src/a.txt\n\
904@@ -1,2 +1,2 @@\n keep\n-old\n+new\n\\ No newline at end of file\n\
905diff --git a/img.bin b/img.bin\nBinary files a/img.bin and b/img.bin differ\n";
906
907 #[test]
908 fn parses_files_hunks_and_counts() {
909 let f = parse_patch(PATCH);
910 assert_eq!(f.len(), 3);
911 assert_eq!(f[0].name.as_deref(), Some("CONTRIBUTING.md"));
912 assert_eq!((f[0].adds, f[0].dels), (3, 0));
913 assert_eq!(f[0].hunks[0].lines[0].new_line, Some(1));
914 assert_eq!(f[1].name.as_deref(), Some("src/a.txt"));
915 assert_eq!((f[1].adds, f[1].dels), (1, 1));
916 let kinds: Vec<_> = f[1].hunks[0].lines.iter().map(|l| l.kind).collect();
917 assert_eq!(
918 kinds,
919 vec![LineKind::Ctx, LineKind::Del, LineKind::Add, LineKind::Meta]
920 );
921 assert_eq!(f[1].hunks[0].lines[1].old_line, Some(2));
922 assert!(f[2].binary);
923 assert_eq!(f[2].name.as_deref(), Some("img.bin"));
924 }
925
926 #[test]
927 fn content_that_looks_like_a_header_stays_content() {
928 let p = "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n--- not a header\n+++ also content\n";
929 let f = parse_patch(p);
930 assert_eq!(f.len(), 1);
931 assert_eq!(f[0].name.as_deref(), Some("x"));
932 assert_eq!((f[0].adds, f[0].dels), (1, 1));
933 }
934
935 #[test]
936 fn render_escapes_and_marks_rows() {
937 let f =
938 parse_patch("diff --git a/<x> b/<x>\n--- a/<x>\n+++ b/<x>\n@@ -1 +1 @@\n-<b>\n+<i>\n");
939 let h = render_diff(&f);
940 assert!(h.contains("<x>"));
941 assert!(!h.contains("<b>"));
942 assert!(h.contains("<tr class=\"add\">"));
943 assert!(h.contains("<tr class=\"del\">"));
944 assert!(render_diff(&[]).contains("No changes."));
945 }
946}