oyo 0.1.67

CLI TUI for oyo - Step-through diff viewer
use super::*;
use std::collections::{BTreeMap, BTreeSet, VecDeque};

#[derive(Clone)]
struct Commit {
    sha: String,
    parents: Vec<String>,
}

fn commit(
    adapter: &dyn ReviewProviderAdapter,
    target: &RemoteTarget,
    reference: &str,
) -> Result<Commit> {
    let endpoint = match target.provider {
        ReviewProviderKind::GitLab => format!(
            "projects/{}/repository/commits/{}",
            encode(&target.repo),
            encode(reference)
        ),
        ReviewProviderKind::Forgejo => format!(
            "/repos/{}/git/commits/{}?stat=false&files=false",
            target.repo,
            encode(reference)
        ),
        ReviewProviderKind::GitHub => unreachable!(),
    };
    let value = adapter.remote_json(&target.host, &endpoint)?;
    let (sha, parents) = if matches!(target.provider, ReviewProviderKind::GitLab) {
        let parents = value["parent_ids"]
            .as_array()
            .ok_or_else(|| anyhow!("Missing commit parents"))?
            .iter()
            .map(|id| revision(&serde_json::json!({"sha":id}), "/sha"))
            .collect::<Result<Vec<_>>>()?;
        (revision(&value, "/id")?, parents)
    } else {
        let parents = value["parents"]
            .as_array()
            .ok_or_else(|| anyhow!("Missing commit parents"))?
            .iter()
            .map(|parent| revision(parent, "/sha"))
            .collect::<Result<Vec<_>>>()?;
        (revision(&value, "/sha")?, parents)
    };
    Ok(Commit { sha, parents })
}

fn ancestor_graph(
    adapter: &dyn ReviewProviderAdapter,
    target: &RemoteTarget,
    tip: &str,
    graph: &mut BTreeMap<String, Vec<String>>,
) -> Result<BTreeSet<String>> {
    let mut seen = BTreeSet::new();
    let mut pending = VecDeque::from([tip.to_owned()]);
    while let Some(sha) = pending.pop_front() {
        if !seen.insert(sha.clone()) {
            continue;
        }
        if !graph.contains_key(&sha) {
            if graph.len() >= 2048 {
                bail!("Forgejo merge-base search exceeded 2048 commits; use an explicit two-dot comparison for endpoint semantics");
            }
            let item = commit(adapter, target, &sha)?;
            if item.sha != sha {
                bail!("Provider changed an immutable commit identity");
            }
            graph.insert(sha.clone(), item.parents);
        }
        pending.extend(graph[&sha].iter().cloned());
    }
    Ok(seen)
}

fn merge_base(
    adapter: &dyn ReviewProviderAdapter,
    target: &RemoteTarget,
    before: &str,
    after: &str,
) -> Result<String> {
    if before == after {
        return Ok(before.to_owned());
    }
    if matches!(target.provider, ReviewProviderKind::GitLab) {
        return revision(
            &adapter.remote_json(
                &target.host,
                &format!(
                    "projects/{}/repository/merge_base?refs%5B%5D={before}&refs%5B%5D={after}",
                    encode(&target.repo)
                ),
            )?,
            "/id",
        );
    }
    let mut graph = BTreeMap::new();
    let left = ancestor_graph(adapter, target, before, &mut graph)?;
    let right = ancestor_graph(adapter, target, after, &mut graph)?;
    let common = left.intersection(&right).cloned().collect::<BTreeSet<_>>();
    let mut best = common.clone();
    for candidate in &common {
        for parent in &graph[candidate] {
            best.remove(parent);
        }
    }
    if best.len() != 1 {
        bail!("Comparison has no unique merge base; use an explicit two-dot comparison");
    }
    Ok(best.into_iter().next().unwrap())
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct TreeEntry {
    blob: String,
    mode: String,
}

fn tree(
    adapter: &dyn ReviewProviderAdapter,
    target: &RemoteTarget,
    revision_id: &str,
) -> Result<BTreeMap<String, TreeEntry>> {
    if revision_id.is_empty() {
        return Ok(BTreeMap::new());
    }
    let mut result = BTreeMap::new();
    let mut seen = HashSet::new();
    for page in 1..=1000 {
        let gitlab = matches!(target.provider, ReviewProviderKind::GitLab);
        let endpoint = if gitlab {
            format!("projects/{}/repository/tree?ref={revision_id}&recursive=true&per_page=100&page={page}", encode(&target.repo))
        } else {
            format!(
                "/repos/{}/git/trees/{revision_id}?recursive=true&per_page=100&page={page}",
                target.repo
            )
        };
        let value = adapter.remote_json(&target.host, &endpoint)?;
        let entries = if gitlab {
            value.as_array()
        } else {
            value["tree"].as_array()
        }
        .ok_or_else(|| anyhow!("Missing repository tree entries"))?;
        for entry in entries {
            let path = field(entry, "/path")?;
            validate_path(&path)?;
            if !seen.insert(path.clone()) {
                bail!("Provider repeated a tree entry during pagination");
            }
            match entry["type"].as_str() {
                Some("tree") => continue,
                Some("blob") => {}
                _ => bail!("Remote submodule comparisons are unsupported"),
            }
            let blob = revision(entry, if gitlab { "/id" } else { "/sha" })?;
            result.insert(
                path,
                TreeEntry {
                    blob,
                    mode: field(entry, "/mode")?,
                },
            );
        }
        if gitlab && entries.is_empty() {
            return Ok(result);
        }
        if !gitlab {
            let total = value["total_count"]
                .as_u64()
                .ok_or_else(|| anyhow!("Missing tree entry count"))?;
            if value["truncated"].as_bool() == Some(false) {
                if seen.len() as u64 != total {
                    bail!("Provider returned an incomplete repository tree");
                }
                return Ok(result);
            }
            if entries.is_empty() {
                bail!("Provider truncated the repository tree");
            }
        }
    }
    bail!("Repository tree exceeds the 1000-page limit")
}

fn changed_files(
    before: BTreeMap<String, TreeEntry>,
    after: BTreeMap<String, TreeEntry>,
) -> Result<Vec<FileEntry>> {
    let paths = before
        .keys()
        .chain(after.keys())
        .cloned()
        .collect::<BTreeSet<_>>();
    let mut files = Vec::new();
    for path in paths {
        let old = before.get(&path);
        let new = after.get(&path);
        if old == new {
            continue;
        }
        if let (Some(old), Some(new)) = (old, new) {
            if old.mode != new.mode {
                bail!("Remote comparison contains a file-mode change, which is unsupported");
            }
        }
        let status = match (old, new) {
            (None, Some(_)) => FileStatus::Added,
            (Some(_), None) => FileStatus::Deleted,
            _ => FileStatus::Modified,
        };
        files.push(FileEntry {
            path: path.clone().into(),
            display_name: path,
            old_path: None,
            old_source_path: None,
            new_source_path: None,
            status,
            insertions: 0,
            deletions: 0,
            binary: false,
        });
    }
    Ok(files)
}

pub(super) fn snapshot(
    adapter: &dyn ReviewProviderAdapter,
    target: &RemoteTarget,
) -> Result<RemoteSnapshot> {
    let (before, after) = match &target.kind {
        RemoteTargetKind::Commit(reference) => {
            let after = commit(adapter, target, reference)?;
            (
                after.parents.first().cloned().unwrap_or_default(),
                after.sha,
            )
        }
        RemoteTargetKind::Compare {
            before,
            after,
            merge_base: use_merge_base,
        } => {
            let before = commit(adapter, target, before)?.sha;
            let after = commit(adapter, target, after)?.sha;
            let before = if *use_merge_base {
                merge_base(adapter, target, &before, &after)?
            } else {
                before
            };
            (before, after)
        }
        RemoteTargetKind::Review(_) => unreachable!(),
    };
    let files = changed_files(
        tree(adapter, target, &before)?,
        tree(adapter, target, &after)?,
    )?;
    Ok(RemoteSnapshot {
        review_version: None,
        target: target.clone(),
        before_repo: target.repo.clone(),
        after_repo: target.repo.clone(),
        before,
        after,
        files,
    })
}