Skip to main content

solid_pod_rs_forge/
compare.rs

1//! Compare: resolve a base and a (possibly cross-fork) head, then report
2//! ahead/behind counts, the commit list and a structured diff.
3//!
4//! Behaviour follows the JSS forge `compareData`, `resolveHead`,
5//! `parseHeadSpec`, `parsePatch` and `renderDiff` (cited by name only). A head
6//! is either a branch of the same repository (`feature`) or `<owner>:<ref>`, a
7//! branch of the named owner's same-named repository. A cross-repository head
8//! is path-fetched into the base repository under the hidden, reusable ref
9//! `refs/forge/heads/<owner>/<ref>`; both ends of the fetch are forge-owned
10//! paths and no user-supplied URL is ever fetched.
11
12use 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
25/// Commits per page in compare and pull commit lists (JSS `PER_PAGE`).
26pub const COMMITS_PER_PAGE: usize = 30;
27
28/// Longest accepted head spec (`owner:ref`), as in JSS `parseHeadSpec`.
29const HEAD_SPEC_MAX: usize = 320;
30
31/// JSS `OWNER_NAME`: alphanumeric first, then `[A-Za-z0-9._-]`, at most 64.
32#[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/// JSS `REPO_NAME` (at most 128) tightened to the crate's 100-character
45/// directory limit, with no `..` and no `.git` suffix.
46#[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/// JSS `okRef`: alphanumeric first, then `[A-Za-z0-9._/-]` (at most 201), no
59/// `..`, no `//`, no trailing `/`. Additionally refuses a trailing `.lock` or
60/// `.`, which git itself would reject, so a branch name can never address a
61/// lock file. A leading `-` is impossible, so it can never be read as an option.
62#[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/// JSS `SHA_RE`: 4 to 64 lowercase hexadecimal characters.
80#[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/// Parse `ref` or `owner:ref` against the base owner. `None` when invalid.
86#[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/// A resolved compare/PR head.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103pub struct HeadRef {
104    /// Owner namespace of the head repository.
105    pub owner: String,
106    /// Head repository name (always the base repository's name).
107    pub repo: String,
108    /// Branch name.
109    #[serde(rename = "ref")]
110    pub ref_name: String,
111    /// Resolved commit id.
112    pub sha: String,
113}
114
115/// One commit in a compare/PR list (JSS `commitLog` shape).
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117pub struct CommitSummary {
118    /// Full object id.
119    pub sha: String,
120    /// Abbreviated id.
121    pub short: String,
122    /// Author name.
123    pub author: String,
124    /// Author email.
125    pub email: String,
126    /// Author time, Unix seconds.
127    pub at: i64,
128    /// Subject line.
129    pub subject: String,
130}
131
132/// Kind of one diff line.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "lowercase")]
135pub enum LineKind {
136    /// Added line.
137    Add,
138    /// Deleted line.
139    Del,
140    /// Context line.
141    Ctx,
142    /// `\ No newline at end of file` marker.
143    Meta,
144}
145
146/// One diff line with its old/new numbers.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct DiffLine {
150    /// Line kind.
151    #[serde(rename = "type")]
152    pub kind: LineKind,
153    /// Line number in the old file.
154    pub old_line: Option<u64>,
155    /// Line number in the new file.
156    pub new_line: Option<u64>,
157    /// Text without the leading marker.
158    pub text: String,
159}
160
161/// One `@@` hunk.
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163pub struct Hunk {
164    /// The `@@ -a,b +c,d @@` header line.
165    pub header: String,
166    /// Lines of the hunk.
167    pub lines: Vec<DiffLine>,
168}
169
170/// One file of a structured diff (JSS `parsePatch` shape).
171#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
172pub struct DiffFile {
173    /// New path (old path for a deletion).
174    pub name: Option<String>,
175    /// `true` for a binary change.
176    pub binary: bool,
177    /// Added line count.
178    pub adds: u64,
179    /// Deleted line count.
180    pub dels: u64,
181    /// Hunks.
182    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/// Parse unified `git diff` output into a structured per-file form, as JSS
201/// `parsePatch` does. Unlike the JSS parser, hunk bodies are consumed by the
202/// line counts in their `@@` header, so a content line that happens to start
203/// with `+++ ` or `--- ` is classified as content, not as a file header.
204#[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/// Render a structured diff as escaped HTML tables (JSS `renderDiff`).
305#[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\">&minus;{}</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
357/// Commits in `range` (a `base..head` range of two validated object ids),
358/// newest first, with a `has_more` flag for the page boundary.
359pub(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
403/// Structured diff between `merge_base` (or `base_sha`) and `head_sha`.
404pub(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/// Everything a compare view needs.
431#[derive(Debug, Clone, Serialize)]
432#[serde(rename_all = "camelCase")]
433pub struct Compare {
434    /// Base ref as requested.
435    pub base_ref: String,
436    /// Base commit id.
437    pub base_sha: String,
438    /// Resolved head.
439    pub head: HeadRef,
440    /// Commits in head not in base.
441    pub ahead_by: u64,
442    /// Commits in base not in head.
443    pub behind_by: u64,
444    /// Merge base, when the histories are related.
445    pub merge_base: Option<String>,
446    /// First page of ahead commits.
447    pub commits: Vec<CommitSummary>,
448    /// More commits than one page.
449    pub has_more: bool,
450    /// Structured diff.
451    pub files: Vec<DiffFile>,
452}
453
454/// Why a caller may (or may not) pull a foreign head into a base repository.
455///
456/// A cross-owner head costs the base repository disk, refs and a fetch lock,
457/// and under `private_repos` it reads another namespace. So the fetch is
458/// authorised by who is asking, never by the spec alone (security review H1 and
459/// M2; JSS fetches for anyone, which is a deliberate divergence).
460#[derive(Clone, Copy)]
461pub(crate) enum HeadAccess<'a> {
462    /// A live request. A cross-owner head is fetched only when the agent owns
463    /// the head's namespace (so it could read it directly) or an open pull
464    /// request in the base repository already names that head.
465    Caller(&'a ForgeAgent),
466    /// An open pull request already names this head: its creation was
467    /// authorised by the head owner, so the base owner may follow it.
468    ExistingPr,
469    /// Never fetch; a cross-owner head resolves only from a ref already held
470    /// (closed pull requests and the PR pages that render them).
471    Cached,
472}
473
474/// Most `refs/forge/heads/*` refs one base repository keeps.
475pub(crate) const MAX_FORGE_HEADS: usize = 64;
476
477/// Most bytes one cross-fork fetch may add to the base repository.
478pub(crate) const MAX_HEAD_FETCH_BYTES: u64 = 256 * 1024 * 1024;
479
480/// How long a request waits for the per-repository fetch lock before a 503.
481pub(crate) const FETCH_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
482
483impl ForgeService {
484    /// Resolve a compare/PR head (JSS `resolveHead`). `Ok(None)` for any
485    /// invalid, missing, unauthorised or unfetchable head; the caller answers
486    /// 404 or 422. [`ForgeError::Busy`] when another fetch holds the lock.
487    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        // Lazy heal for repositories that predate the hidden-refs config.
542        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    /// `true` when an open pull request of `<base_owner>/<name>` names exactly
574    /// this head.
575    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    /// Drop the fetched copy of a pull request's foreign head once it is
594    /// closed or merged, unless another open pull request still uses it.
595    /// Best effort: a failure only leaves a hidden ref behind.
596    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    /// Resolve the base ref: branch, then tag, then a hex sha.
624    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    /// JSS `compareData`: `Ok(None)` when the base or head does not resolve.
641    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    /// `GET api/repos/<o>/<n>/compare/<base>...<head>` (JSS `apiCompare`).
690    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    /// `GET <owner>/<repo>/compare/<base>...<head>` (JSS `comparePage`).
724    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
789/// Split `<base>...<head>` at the first `...` (the base must be non-empty).
790fn split_spec(spec: &str) -> Option<(&str, &str)> {
791    let i = spec.find("...")?;
792    (i >= 1).then(|| (&spec[..i], &spec[i + 3..]))
793}
794
795/// A commit list as an escaped table.
796pub(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
823/// Percent-decode a query value for the new-PR form (`head=dana%3Afeature`).
824pub(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("&lt;x&gt;"));
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}