use anyhow::{Context, Result};
use serde::Deserialize;
use std::process::Command;
use crate::cli::{Args, DiffRange};
use crate::diff::{DiffLine, FileDiff, FileStatus, LineKind, parse_unified};
pub fn load_file_content(range: &DiffRange, path: &str) -> Result<String> {
match range {
DiffRange::Working | DiffRange::Uncommitted | DiffRange::UncommittedAgainst { .. } => {
read_working_tree(path)
}
DiffRange::Staged | DiffRange::StagedAgainst { .. } => show_index(path),
DiffRange::Range { target, .. } => show_at_ref(target, path),
DiffRange::MergeBase { target, .. } => match target.as_str() {
"working" | "." => read_working_tree(path),
"staged" => show_index(path),
other => show_at_ref(other, path),
},
DiffRange::PullRequest { url, head_sha, .. } => {
let sha = head_sha.as_deref().ok_or_else(|| {
anyhow::anyhow!(
"cannot expand folds: PR head commit SHA was not resolved at startup"
)
})?;
fetch_pr_file(url, sha, path)
}
DiffRange::Stdin => std::fs::read_to_string(path)
.context("cannot expand folds in stdin mode (file not available locally)"),
}
}
fn read_working_tree(path: &str) -> Result<String> {
std::fs::read_to_string(path).context("failed to read file from working tree")
}
fn show_index(path: &str) -> Result<String> {
let out = Command::new("git")
.args(["-c", "core.quotepath=false", "show", &format!(":{path}")])
.output()
.context("failed to run git show for staged file")?;
if !out.status.success() {
anyhow::bail!(
"git show :{path} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn show_at_ref(reference: &str, path: &str) -> Result<String> {
let out = Command::new("git")
.args([
"-c",
"core.quotepath=false",
"show",
&format!("{reference}:{path}"),
])
.output()
.context("failed to run git show")?;
if !out.status.success() {
anyhow::bail!(
"git show {reference}:{path} failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn fetch_pr_file(pr_url: &str, sha: &str, path: &str) -> Result<String> {
if commit_exists_locally(sha)
&& let Ok(content) = show_at_ref(sha, path)
{
return Ok(content);
}
let (host, owner, repo, _) =
parse_pr_url(pr_url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {pr_url}"))?;
let endpoint = format!(
"repos/{owner}/{repo}/contents/{}?ref={sha}",
url_encode_path(path)
);
let raw = run_gh_api(&host, &endpoint, &["Accept: application/vnd.github.v3.raw"])?;
Ok(String::from_utf8_lossy(&raw).into_owned())
}
fn commit_exists_locally(sha: &str) -> bool {
Command::new("git")
.args(["cat-file", "-e", &format!("{sha}^{{commit}}")])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn url_encode_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for byte in path.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{:02X}", byte)),
}
}
out
}
pub fn load_diff_simple(range: &DiffRange) -> Result<Vec<FileDiff>> {
let output = run_git_diff(range, None)?;
Ok(parse_unified(&output))
}
pub fn load_diff(range: &DiffRange, args: &Args) -> Result<Vec<FileDiff>> {
let output = run_git_diff(range, args.context)?;
let mut files = parse_unified(&output);
if args.include_untracked
&& matches!(
range,
DiffRange::Working | DiffRange::Uncommitted | DiffRange::UncommittedAgainst { .. }
)
{
let untracked = list_untracked_files()?;
for path in untracked {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let lines: Vec<DiffLine> = content
.lines()
.enumerate()
.map(|(i, text)| {
DiffLine::body(LineKind::Added, text.to_string(), None, Some(i as u32 + 1))
})
.collect();
let added = lines.len();
let mut file = FileDiff {
path,
old_path: None,
status: FileStatus::Added,
added,
removed: 0,
lines,
auto_collapsed: false,
viewed: false,
};
crate::diff::apply_auto_collapse(&mut file);
files.push(file);
}
}
Ok(files)
}
pub fn load_pr_diff(url: &str) -> Result<String> {
let out = Command::new("gh")
.args(["pr", "diff", url])
.output()
.context("failed to run `gh pr diff`. Is `gh` CLI installed?")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("gh pr diff failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
pub fn parse_pr_url(url: &str) -> Option<(String, String, String, String)> {
let url = url.split('#').next().unwrap_or(url).trim_end_matches('/');
let parts: Vec<&str> = url.split('/').collect();
for i in 0..parts.len() {
if parts[i] == "pull" && i + 1 < parts.len() && i >= 2 {
let number = parts[i + 1].to_string();
let repo = parts[i - 1].to_string();
let owner = parts[i - 2].to_string();
let host = if i >= 4 {
parts[2].to_string() } else {
"github.com".to_string()
};
return Some((host, owner, repo, number));
}
}
None
}
pub fn load_pr_head_sha(url: &str) -> Result<String> {
#[derive(Deserialize)]
struct Raw {
head: RawRef,
}
#[derive(Deserialize)]
struct RawRef {
sha: String,
}
let (host, owner, repo, number) =
parse_pr_url(url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {url}"))?;
let endpoint = format!("repos/{owner}/{repo}/pulls/{number}");
let raw = run_gh_api(&host, &endpoint, &[])?;
let parsed: Raw = serde_json::from_slice(&raw).context("failed to parse PR meta response")?;
Ok(parsed.head.sha)
}
fn run_gh_api(host: &str, endpoint: &str, headers: &[&str]) -> Result<Vec<u8>> {
let mut cmd = Command::new("gh");
cmd.arg("api");
for h in headers {
cmd.args(["-H", h]);
}
cmd.arg(endpoint);
if host != "github.com" {
cmd.args(["--hostname", host]);
}
let out = cmd.output().context("failed to run `gh api`")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("gh api {endpoint} failed: {}", stderr.trim());
}
Ok(out.stdout)
}
pub fn load_pr_comments(url: &str) -> Result<Vec<PrComment>> {
let (host, owner, repo, number) =
parse_pr_url(url).ok_or_else(|| anyhow::anyhow!("could not parse PR URL: {url}"))?;
let endpoint = format!("repos/{owner}/{repo}/pulls/{number}/comments");
let raw_bytes = run_gh_api(&host, &endpoint, &[])?;
let raw: Vec<RawPrComment> = serde_json::from_slice(&raw_bytes)
.context("failed to parse `gh api` response as PR review comments")?;
let mut top: Vec<PrComment> = Vec::new();
let mut reply_map: std::collections::HashMap<u64, Vec<String>> =
std::collections::HashMap::new();
for c in &raw {
if let Some(parent) = c.in_reply_to_id {
reply_map.entry(parent).or_default().push(c.body.clone());
}
}
for c in raw {
if c.in_reply_to_id.is_none() {
let replies = reply_map.remove(&c.id).unwrap_or_default();
top.push(PrComment {
path: c.path,
line: c.line,
body: c.body,
replies,
});
}
}
Ok(top)
}
#[derive(Debug)]
pub struct PrComment {
pub path: String,
pub line: Option<u32>,
pub body: String,
pub replies: Vec<String>,
}
#[derive(Deserialize)]
struct RawPrComment {
id: u64,
path: String,
line: Option<u32>,
body: String,
in_reply_to_id: Option<u64>,
}
fn resolve_merge_base(base: &str, target: &str) -> Result<String> {
let out = Command::new("git")
.args(["merge-base", base, target])
.output()
.context("failed to run `git merge-base`")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("git merge-base failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
pub fn list_revisions() -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let refs = Command::new("git")
.args([
"for-each-ref",
"--sort=-committerdate",
"--format=%(refname:short)\t%(contents:subject)",
"refs/heads",
"refs/tags",
"refs/remotes",
])
.output();
if let Ok(o) = refs {
for line in String::from_utf8_lossy(&o.stdout).lines() {
if line.is_empty() {
continue;
}
out.push(line.to_string());
}
}
let log = Command::new("git")
.args(["log", "--pretty=format:%h\t%s", "-n", "50"])
.output();
if let Ok(o) = log {
for line in String::from_utf8_lossy(&o.stdout).lines() {
if line.is_empty() {
continue;
}
out.push(line.to_string());
}
}
out
}
fn list_untracked_files() -> Result<Vec<String>> {
let out = Command::new("git")
.args([
"-c",
"core.quotepath=false",
"ls-files",
"--others",
"--exclude-standard",
])
.output()
.context("failed to run `git ls-files`")?;
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
fn run_git_diff(range: &DiffRange, context_lines: Option<u32>) -> Result<String> {
let context_arg = context_lines.map(|n| format!("-U{n}"));
let run = |args: Vec<&str>| -> Result<String> {
let mut cmd = Command::new("git");
cmd.arg("-c")
.arg("core.quotepath=false")
.arg("--no-pager")
.arg("diff")
.arg("--no-color");
if let Some(a) = &context_arg {
cmd.arg(a);
}
for a in args {
cmd.arg(a);
}
let out = cmd
.output()
.context("failed to spawn `git diff`. is git installed and in PATH?")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
anyhow::bail!("git diff failed: {}", stderr.trim());
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
};
match range {
DiffRange::Working => run(vec![]),
DiffRange::Staged => run(vec!["--cached"]),
DiffRange::StagedAgainst { base } => run(vec!["--cached", base]),
DiffRange::Uncommitted => run(vec!["HEAD"]),
DiffRange::UncommittedAgainst { base } => run(vec![base]),
DiffRange::Range { base, target } => run(vec![&format!("{base}..{target}")]),
DiffRange::MergeBase { base, target } => {
let mb_target = merge_base_target_ref(target);
let mb = resolve_merge_base(base, &mb_target)?;
match target.as_str() {
"working" => run(vec![]),
"staged" => run(vec!["--cached", &mb]),
"." => run(vec![&mb]),
_ => run(vec![&format!("{mb}..{target}")]),
}
}
DiffRange::Stdin | DiffRange::PullRequest { .. } => {
anyhow::bail!("run_git_diff should not be called for Stdin/PR mode");
}
}
}
fn merge_base_target_ref(target: &str) -> String {
match target {
"." | "staged" | "working" => "HEAD".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_pr_url_github_com() {
let (host, owner, repo, num) =
parse_pr_url("https://github.com/owner/repo/pull/42").unwrap();
assert_eq!(host, "github.com");
assert_eq!(owner, "owner");
assert_eq!(repo, "repo");
assert_eq!(num, "42");
}
#[test]
fn parse_pr_url_trailing_slash_and_fragment() {
let (host, owner, repo, num) =
parse_pr_url("https://github.com/owner/repo/pull/42/#issue-123").unwrap();
assert_eq!(host, "github.com");
assert_eq!(owner, "owner");
assert_eq!(repo, "repo");
assert_eq!(num, "42");
}
#[test]
fn parse_pr_url_enterprise() {
let (host, owner, repo, num) =
parse_pr_url("https://github.example.com/a/b/pull/7").unwrap();
assert_eq!(host, "github.example.com");
assert_eq!(owner, "a");
assert_eq!(repo, "b");
assert_eq!(num, "7");
}
#[test]
fn parse_pr_url_rejects_non_pr_urls() {
assert!(parse_pr_url("https://github.com/owner/repo/commits/main").is_none());
assert!(parse_pr_url("pull/42").is_none());
assert!(parse_pr_url("").is_none());
assert!(parse_pr_url("not-a-url").is_none());
}
#[test]
fn merge_base_target_ref_special_values() {
assert_eq!(merge_base_target_ref("."), "HEAD");
assert_eq!(merge_base_target_ref("staged"), "HEAD");
assert_eq!(merge_base_target_ref("working"), "HEAD");
assert_eq!(merge_base_target_ref("main"), "main");
assert_eq!(merge_base_target_ref("feat/x"), "feat/x");
}
#[test]
fn url_encode_path_preserves_slashes_and_unreserved() {
assert_eq!(url_encode_path("src/main.rs"), "src/main.rs");
assert_eq!(url_encode_path("src/with space.rs"), "src/with%20space.rs");
assert_eq!(url_encode_path("q?x.md"), "q%3Fx.md");
assert_eq!(url_encode_path("a#b"), "a%23b");
assert_eq!(url_encode_path("50%off.md"), "50%25off.md");
}
#[test]
fn url_encode_path_percent_encodes_non_ascii() {
assert_eq!(url_encode_path("日.md"), "%E6%97%A5.md");
}
}