1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result, bail};
6
7use crate::review::{ChangeStatus, FileChange};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Side {
12 Rev(String),
13 Index,
14 Worktree,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct RangeSpec {
20 pub old: String,
21 pub new: Side,
22 pub merge_base: bool,
24}
25
26impl RangeSpec {
27 pub fn parse(arg: Option<&str>, staged: bool) -> Result<RangeSpec> {
31 let rev = |s: &str| {
32 if s.is_empty() {
33 "HEAD".to_string()
34 } else {
35 s.to_string()
36 }
37 };
38 let spec = match arg {
39 Some(arg) if arg.contains("..") => {
40 if staged {
41 bail!("--staged takes a single revision, not a range");
42 }
43 let (merge_base, (a, b)) = match arg.split_once("...") {
44 Some(parts) => (true, parts),
45 None => (false, arg.split_once("..").unwrap_or((arg, ""))),
46 };
47 RangeSpec {
48 old: rev(a),
49 new: Side::Rev(rev(b)),
50 merge_base,
51 }
52 }
53 other => RangeSpec {
54 old: rev(other.unwrap_or("")),
55 new: if staged { Side::Index } else { Side::Worktree },
56 merge_base: false,
57 },
58 };
59 Ok(spec)
60 }
61}
62
63pub fn repo_root(dir: &Path) -> Result<PathBuf> {
65 let top = git(dir, &["rev-parse", "--show-toplevel"]).context("not inside a git repository")?;
66 Ok(PathBuf::from(String::from_utf8(top)?.trim_end()))
67}
68
69pub fn load(dir: &Path, spec: &RangeSpec) -> Result<Vec<FileChange>> {
71 let root = repo_root(dir)?;
72
73 let old_rev = if spec.merge_base {
74 let tip = match &spec.new {
76 Side::Rev(new_rev) => new_rev.as_str(),
77 Side::Index | Side::Worktree => "HEAD",
78 };
79 String::from_utf8(git(&root, &["merge-base", &spec.old, tip])?)?
80 .trim()
81 .to_string()
82 } else {
83 spec.old.clone()
84 };
85
86 let mut args = vec![
87 "diff",
88 "--name-status",
89 "-z",
90 "-M",
91 "--no-color",
92 "--no-ext-diff",
93 ];
94 if spec.new == Side::Index {
95 args.push("--cached");
96 }
97 args.push(&old_rev);
98 if let Side::Rev(new_rev) = &spec.new {
99 args.push(new_rev);
100 }
101 args.push("--");
102 let listing = git(&root, &args)?;
103
104 let mut tokens = listing
105 .split(|byte| *byte == 0)
106 .filter(|token| !token.is_empty())
107 .map(|token| String::from_utf8_lossy(token).into_owned());
108 let mut changes = Vec::new();
109 while let Some(code) = tokens.next() {
110 let first = tokens
111 .next()
112 .context("unexpected `git diff --name-status` output")?;
113 let (status, old_path, path) = match code.chars().next() {
114 Some('R' | 'C') => {
115 let second = tokens
116 .next()
117 .context("unexpected `git diff --name-status` output")?;
118 (ChangeStatus::Renamed, Some(first), second)
119 }
120 Some('A') => (ChangeStatus::Added, None, first),
121 Some('D') => (ChangeStatus::Deleted, None, first),
122 _ => (ChangeStatus::Modified, None, first),
123 };
124
125 let old = match status {
126 ChangeStatus::Added => None,
127 _ => {
128 let old_path = old_path.as_deref().unwrap_or(&path);
129 Some(git(&root, &["show", &format!("{old_rev}:{old_path}")])?)
130 }
131 };
132 let new = match status {
133 ChangeStatus::Deleted => None,
134 _ => Some(read_side(&root, &spec.new, &path)?),
135 };
136 changes.push(file_change(path, old_path, status, old, new));
137 }
138
139 if spec.new == Side::Worktree {
140 let untracked = git(&root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
141 for path in untracked.split(|byte| *byte == 0).filter(|p| !p.is_empty()) {
142 let path = String::from_utf8_lossy(path).into_owned();
143 if !root.join(&path).is_file() {
144 continue;
145 }
146 let new = read_side(&root, &Side::Worktree, &path)?;
147 changes.push(file_change(
148 path,
149 None,
150 ChangeStatus::Added,
151 None,
152 Some(new),
153 ));
154 }
155 }
156
157 changes.sort_by(|a, b| a.path.cmp(&b.path));
158 Ok(changes)
159}
160
161fn read_side(root: &Path, side: &Side, path: &str) -> Result<Vec<u8>> {
162 match side {
163 Side::Rev(rev) => git(root, &["show", &format!("{rev}:{path}")]),
164 Side::Index => git(root, &["show", &format!(":{path}")]),
165 Side::Worktree => {
166 let full = root.join(path);
167 if full
169 .symlink_metadata()
170 .is_ok_and(|meta| meta.file_type().is_symlink())
171 {
172 return Ok(fs::read_link(&full)?
173 .to_string_lossy()
174 .into_owned()
175 .into_bytes());
176 }
177 fs::read(&full).with_context(|| format!("reading {path}"))
178 }
179 }
180}
181
182fn file_change(
183 path: String,
184 old_path: Option<String>,
185 status: ChangeStatus,
186 old: Option<Vec<u8>>,
187 new: Option<Vec<u8>>,
188) -> FileChange {
189 let (old, old_binary) = decode(old);
190 let (new, new_binary) = decode(new);
191 let binary = old_binary || new_binary;
192 FileChange {
193 path,
194 old_path,
195 status,
196 old: if binary { None } else { old },
197 new: if binary { None } else { new },
198 binary,
199 }
200}
201
202fn decode(bytes: Option<Vec<u8>>) -> (Option<String>, bool) {
203 let Some(bytes) = bytes else {
204 return (None, false);
205 };
206 if bytes.iter().take(8000).any(|byte| *byte == 0) {
207 return (None, true);
208 }
209 match String::from_utf8(bytes) {
210 Ok(text) => (Some(text), false),
211 Err(_) => (None, true),
212 }
213}
214
215pub(crate) fn git(dir: &Path, args: &[&str]) -> Result<Vec<u8>> {
216 let output = Command::new("git")
217 .arg("-C")
218 .arg(dir)
219 .args(args)
220 .output()
221 .context("failed to run git")?;
222 if !output.status.success() {
223 bail!(
224 "git {} failed: {}",
225 args.join(" "),
226 String::from_utf8_lossy(&output.stderr).trim()
227 );
228 }
229 Ok(output.stdout)
230}