| 1 | |
| 2 | |
| 3 | |
| 4 | use std::collections::{HashMap, HashSet}; |
| 5 | use std::path::Path; |
| 6 | use std::process::Stdio; |
| 7 | |
| 8 | use tokio::io::AsyncReadExt; |
| 9 | |
| 10 | use crate::browse::{self, CommitInfo}; |
| 11 | use crate::command::{GitCommand, GitEnv, ObjectSource}; |
| 12 | use crate::error::{Error, Result}; |
| 13 | |
| 14 | |
| 15 | const OUTPUT_CAP: usize = 20 * 1024 * 1024; |
| 16 | const EMPTY_TREE_SHA1: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; |
| 17 | const EMPTY_TREE_SHA256: &str = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; |
| 18 | |
| 19 | |
| 20 | const REC: u8 = 0x1e; |
| 21 | const FIELD: u8 = 0x1f; |
| 22 | const END: u8 = 0x1d; |
| 23 | |
| 24 | const COMMIT_FORMAT: &str = |
| 25 | "--format=%x1e%H%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%cn%x1f%ce%x1f%ct%x1f%s%x1f%B%x1d"; |
| 26 | |
| 27 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 28 | pub enum ArchiveFormat { |
| 29 | TarGz, |
| 30 | Zip, |
| 31 | } |
| 32 | |
| 33 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 34 | pub enum DiffStatus { |
| 35 | Added, |
| 36 | Deleted, |
| 37 | Modified, |
| 38 | Renamed, |
| 39 | Copied, |
| 40 | TypeChanged, |
| 41 | } |
| 42 | |
| 43 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 44 | pub enum LineKind { |
| 45 | Context, |
| 46 | Add, |
| 47 | Del, |
| 48 | NoNewline, |
| 49 | } |
| 50 | |
| 51 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 52 | pub struct DiffLine { |
| 53 | pub kind: LineKind, |
| 54 | pub old_no: Option<u32>, |
| 55 | pub new_no: Option<u32>, |
| 56 | pub text: String, |
| 57 | } |
| 58 | |
| 59 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 60 | pub struct Hunk { |
| 61 | pub header: String, |
| 62 | pub lines: Vec<DiffLine>, |
| 63 | } |
| 64 | |
| 65 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 66 | pub struct FileDiff { |
| 67 | pub old_path: Option<String>, |
| 68 | pub new_path: Option<String>, |
| 69 | pub status: DiffStatus, |
| 70 | pub is_binary: bool, |
| 71 | pub additions: u32, |
| 72 | pub deletions: u32, |
| 73 | pub hunks: Vec<Hunk>, |
| 74 | pub truncated: bool, |
| 75 | } |
| 76 | |
| 77 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 78 | pub struct DiffLimits { |
| 79 | pub max_files: usize, |
| 80 | pub max_lines_per_file: usize, |
| 81 | pub max_total_lines: usize, |
| 82 | } |
| 83 | |
| 84 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 85 | pub struct CommitDiff { |
| 86 | pub files: Vec<FileDiff>, |
| 87 | pub truncated_files: bool, |
| 88 | } |
| 89 | |
| 90 | |
| 91 | |
| 92 | pub(crate) fn git(env: &GitEnv, subcommand: &'static str) -> GitCommand { |
| 93 | GitCommand::new(env, subcommand) |
| 94 | .env("GIT_CONFIG_COUNT", "1") |
| 95 | .env("GIT_CONFIG_KEY_0", "core.quotePath") |
| 96 | .env("GIT_CONFIG_VALUE_0", "false") |
| 97 | } |
| 98 | |
| 99 | |
| 100 | pub(crate) fn validate_oid(oid: &str) -> Result<()> { |
| 101 | let len_ok = oid.len() == 40 || oid.len() == 64; |
| 102 | if len_ok && oid.bytes().all(|b| b.is_ascii_hexdigit()) { |
| 103 | Ok(()) |
| 104 | } else { |
| 105 | Err(Error::InvalidInput(format!( |
| 106 | "not a full commit oid: {oid:?}" |
| 107 | ))) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | |
| 112 | fn literal_spec(path: &str) -> String { |
| 113 | format!(":(literal){path}") |
| 114 | } |
| 115 | |
| 116 | |
| 117 | fn validate_prefix(prefix: &str) -> Result<()> { |
| 118 | let charset_ok = !prefix.is_empty() |
| 119 | && prefix |
| 120 | .bytes() |
| 121 | .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b'-'); |
| 122 | if !charset_ok || prefix == "." || prefix == ".." || prefix.starts_with('-') { |
| 123 | return Err(Error::InvalidInput(format!( |
| 124 | "invalid archive prefix: {prefix:?}" |
| 125 | ))); |
| 126 | } |
| 127 | Ok(()) |
| 128 | } |
| 129 | |
| 130 | |
| 131 | |
| 132 | |
| 133 | async fn for_each_chunk(cmd: GitCommand, mut f: impl FnMut(&[u8]) -> bool) -> Result<bool> { |
| 134 | let mut command = cmd.into_tokio(); |
| 135 | command.stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 136 | let mut child = command.spawn()?; |
| 137 | let mut stdout = child |
| 138 | .stdout |
| 139 | .take() |
| 140 | .ok_or_else(|| Error::Failed("git stdout missing".into()))?; |
| 141 | let stderr_task = child.stderr.take().map(|mut e| { |
| 142 | tokio::spawn(async move { |
| 143 | let mut v = Vec::new(); |
| 144 | let _ = e.read_to_end(&mut v).await; |
| 145 | v |
| 146 | }) |
| 147 | }); |
| 148 | |
| 149 | let mut chunk = vec![0u8; 64 * 1024]; |
| 150 | let mut total = 0usize; |
| 151 | let mut eof = false; |
| 152 | loop { |
| 153 | let n = stdout.read(&mut chunk).await?; |
| 154 | if n == 0 { |
| 155 | eof = true; |
| 156 | break; |
| 157 | } |
| 158 | let take = n.min(OUTPUT_CAP - total); |
| 159 | let keep = f(&chunk[..take]); |
| 160 | total += take; |
| 161 | if !keep || take < n { |
| 162 | break; |
| 163 | } |
| 164 | } |
| 165 | if !eof { |
| 166 | let _ = child.start_kill(); |
| 167 | } |
| 168 | drop(stdout); |
| 169 | let status = child.wait().await?; |
| 170 | let stderr = match stderr_task { |
| 171 | Some(t) => t.await.unwrap_or_default(), |
| 172 | None => Vec::new(), |
| 173 | }; |
| 174 | if eof && !status.success() { |
| 175 | return Err(Error::Failed(format!( |
| 176 | "git exited with {status}: {}", |
| 177 | String::from_utf8_lossy(&stderr).trim() |
| 178 | ))); |
| 179 | } |
| 180 | Ok(eof) |
| 181 | } |
| 182 | |
| 183 | |
| 184 | async fn read_capped(cmd: GitCommand) -> Result<(Vec<u8>, bool)> { |
| 185 | let mut buf = Vec::new(); |
| 186 | let complete = for_each_chunk(cmd, |c| { |
| 187 | buf.extend_from_slice(c); |
| 188 | true |
| 189 | }) |
| 190 | .await?; |
| 191 | Ok((buf, complete)) |
| 192 | } |
| 193 | |
| 194 | |
| 195 | |
| 196 | fn parse_record(rec: &[u8]) -> Option<(CommitInfo, &[u8])> { |
| 197 | let end = rec.iter().position(|&b| b == END)?; |
| 198 | let fields = &rec[..end]; |
| 199 | let rest = &rec[end + 1..]; |
| 200 | let mut it = fields.splitn(10, |&b| b == FIELD); |
| 201 | let text = |p: Option<&[u8]>| String::from_utf8_lossy(p.unwrap_or_default()).into_owned(); |
| 202 | let time = |p: Option<&[u8]>| text(p).parse::<i64>().unwrap_or(0); |
| 203 | |
| 204 | let oid = text(it.next()); |
| 205 | if oid.is_empty() { |
| 206 | return None; |
| 207 | } |
| 208 | let parents = text(it.next()) |
| 209 | .split_whitespace() |
| 210 | .map(str::to_owned) |
| 211 | .collect(); |
| 212 | let author_name = text(it.next()); |
| 213 | let author_email = text(it.next()); |
| 214 | let author_time = time(it.next()); |
| 215 | let committer_name = text(it.next()); |
| 216 | let committer_email = text(it.next()); |
| 217 | let committer_time = time(it.next()); |
| 218 | let summary = text(it.next()); |
| 219 | let message = text(it.next()).trim_end_matches('\n').to_owned(); |
| 220 | let info = CommitInfo { |
| 221 | oid, |
| 222 | parents, |
| 223 | author_name, |
| 224 | author_email, |
| 225 | author_time, |
| 226 | committer_name, |
| 227 | committer_email, |
| 228 | committer_time, |
| 229 | summary, |
| 230 | message, |
| 231 | }; |
| 232 | Some((info, rest)) |
| 233 | } |
| 234 | |
| 235 | |
| 236 | pub async fn log( |
| 237 | env: &GitEnv, |
| 238 | repo: &Path, |
| 239 | commit: &str, |
| 240 | path: Option<&str>, |
| 241 | skip: usize, |
| 242 | limit: usize, |
| 243 | ) -> Result<Vec<CommitInfo>> { |
| 244 | validate_oid(commit)?; |
| 245 | let mut cmd = git(env, "log") |
| 246 | .git_dir(repo) |
| 247 | .arg("-z") |
| 248 | .arg("--no-color") |
| 249 | .arg(COMMIT_FORMAT) |
| 250 | .user_opt("--skip", &skip.to_string())? |
| 251 | .user_opt("--max-count", &limit.to_string())? |
| 252 | .user_revs(&[commit])?; |
| 253 | if let Some(p) = path { |
| 254 | browse::validate_repo_path(p)?; |
| 255 | let spec = literal_spec(p); |
| 256 | cmd = cmd.paths(&[spec.as_str()]); |
| 257 | } |
| 258 | let (buf, _complete) = read_capped(cmd).await?; |
| 259 | Ok(buf |
| 260 | .split(|&b| b == REC) |
| 261 | .filter_map(parse_record) |
| 262 | .map(|(info, _)| info) |
| 263 | .collect()) |
| 264 | } |
| 265 | |
| 266 | |
| 267 | |
| 268 | |
| 269 | pub async fn new_commits( |
| 270 | env: &GitEnv, |
| 271 | repo: &Path, |
| 272 | old: Option<&str>, |
| 273 | new: &str, |
| 274 | max: usize, |
| 275 | ) -> Result<Vec<CommitInfo>> { |
| 276 | new_commits_with(env, repo, ObjectSource::default(), old, new, max).await |
| 277 | } |
| 278 | |
| 279 | |
| 280 | pub async fn new_commits_with( |
| 281 | env: &GitEnv, |
| 282 | repo: &Path, |
| 283 | src: ObjectSource<'_>, |
| 284 | old: Option<&str>, |
| 285 | new: &str, |
| 286 | max: usize, |
| 287 | ) -> Result<Vec<CommitInfo>> { |
| 288 | validate_oid(new)?; |
| 289 | let exclude = match old { |
| 290 | Some(old) => { |
| 291 | validate_oid(old)?; |
| 292 | Some(format!("^{old}")) |
| 293 | } |
| 294 | None => None, |
| 295 | }; |
| 296 | let mut revs = vec![new]; |
| 297 | if let Some(exclude) = exclude.as_deref() { |
| 298 | revs.push(exclude); |
| 299 | } |
| 300 | let cmd = git(env, "log") |
| 301 | .git_dir(repo) |
| 302 | .arg("-z") |
| 303 | .arg("--no-color") |
| 304 | .arg(COMMIT_FORMAT) |
| 305 | .user_opt("--max-count", &max.to_string())? |
| 306 | .objects(src)? |
| 307 | .user_revs(&revs)?; |
| 308 | let (buf, _complete) = read_capped(cmd).await?; |
| 309 | let mut commits: Vec<CommitInfo> = buf |
| 310 | .split(|&b| b == REC) |
| 311 | .filter_map(parse_record) |
| 312 | .map(|(info, _)| info) |
| 313 | .collect(); |
| 314 | commits.reverse(); |
| 315 | Ok(commits) |
| 316 | } |
| 317 | |
| 318 | |
| 319 | #[derive(Clone, Copy)] |
| 320 | enum DiffMode { |
| 321 | NameStatus, |
| 322 | Patch, |
| 323 | } |
| 324 | |
| 325 | fn diff_tree_cmd( |
| 326 | env: &GitEnv, |
| 327 | repo: &Path, |
| 328 | src: ObjectSource<'_>, |
| 329 | base: &str, |
| 330 | oid: &str, |
| 331 | mode: DiffMode, |
| 332 | ) -> Result<GitCommand> { |
| 333 | let cmd = git(env, "diff-tree") |
| 334 | .git_dir(repo) |
| 335 | .arg("-r") |
| 336 | .arg("-M") |
| 337 | .arg("--no-commit-id") |
| 338 | .arg("--no-color") |
| 339 | .arg("--no-ext-diff") |
| 340 | .arg("--no-textconv"); |
| 341 | let cmd = match mode { |
| 342 | DiffMode::NameStatus => cmd.arg("-z").arg("--name-status"), |
| 343 | DiffMode::Patch => cmd.arg("-p").arg("--full-index"), |
| 344 | }; |
| 345 | cmd.objects(src)?.user_revs(&[base, oid]) |
| 346 | } |
| 347 | |
| 348 | pub(crate) fn empty_tree_for(oid: &str) -> &'static str { |
| 349 | if oid.len() == 64 { |
| 350 | EMPTY_TREE_SHA256 |
| 351 | } else { |
| 352 | EMPTY_TREE_SHA1 |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | |
| 357 | async fn first_parent(env: &GitEnv, repo: &Path, oid: &str) -> Result<Option<String>> { |
| 358 | let out = git(env, "rev-list") |
| 359 | .git_dir(repo) |
| 360 | .arg("--parents") |
| 361 | .arg("-n1") |
| 362 | .user_revs(&[oid])? |
| 363 | .output() |
| 364 | .await?; |
| 365 | let text = String::from_utf8_lossy(&out); |
| 366 | let mut tokens = text.split_whitespace(); |
| 367 | let _oid = tokens.next(); |
| 368 | match tokens.next() { |
| 369 | Some(p) => { |
| 370 | validate_oid(p)?; |
| 371 | Ok(Some(p.to_owned())) |
| 372 | } |
| 373 | None => Ok(None), |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | |
| 378 | struct NameEntry { |
| 379 | status: DiffStatus, |
| 380 | old_path: Option<String>, |
| 381 | new_path: Option<String>, |
| 382 | } |
| 383 | |
| 384 | fn status_of(code: u8) -> DiffStatus { |
| 385 | match code { |
| 386 | b'A' => DiffStatus::Added, |
| 387 | b'D' => DiffStatus::Deleted, |
| 388 | b'R' => DiffStatus::Renamed, |
| 389 | b'C' => DiffStatus::Copied, |
| 390 | b'T' => DiffStatus::TypeChanged, |
| 391 | _ => DiffStatus::Modified, |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | |
| 396 | fn parse_name_status(buf: &[u8]) -> Vec<NameEntry> { |
| 397 | let text = |p: &[u8]| String::from_utf8_lossy(p).into_owned(); |
| 398 | let mut out = Vec::new(); |
| 399 | let mut fields = buf.split(|&b| b == 0); |
| 400 | while let Some(code) = fields.next() { |
| 401 | let Some(&c) = code.first() else { continue }; |
| 402 | if matches!(c, b'R' | b'C') { |
| 403 | let (Some(a), Some(b)) = (fields.next(), fields.next()) else { |
| 404 | break; |
| 405 | }; |
| 406 | out.push(NameEntry { |
| 407 | status: status_of(c), |
| 408 | old_path: Some(text(a)), |
| 409 | new_path: Some(text(b)), |
| 410 | }); |
| 411 | } else { |
| 412 | let Some(p) = fields.next() else { break }; |
| 413 | let p = text(p); |
| 414 | let (old_path, new_path) = match c { |
| 415 | b'A' => (None, Some(p)), |
| 416 | b'D' => (Some(p), None), |
| 417 | _ => (Some(p.clone()), Some(p)), |
| 418 | }; |
| 419 | out.push(NameEntry { |
| 420 | status: status_of(c), |
| 421 | old_path, |
| 422 | new_path, |
| 423 | }); |
| 424 | } |
| 425 | } |
| 426 | out |
| 427 | } |
| 428 | |
| 429 | |
| 430 | fn block_starts(text: &str) -> Vec<usize> { |
| 431 | text.match_indices("diff --git ") |
| 432 | .map(|(i, _)| i) |
| 433 | .filter(|&i| i == 0 || text.as_bytes()[i - 1] == b'\n') |
| 434 | .collect() |
| 435 | } |
| 436 | |
| 437 | |
| 438 | fn parse_hunk_start(line: &str) -> (u32, u32) { |
| 439 | let mut parts = line.split_whitespace().skip(1); |
| 440 | let start = |p: Option<&str>, sign: char| { |
| 441 | p.and_then(|p| p.strip_prefix(sign)) |
| 442 | .and_then(|p| p.split(',').next()) |
| 443 | .and_then(|n| n.parse::<u32>().ok()) |
| 444 | .unwrap_or(1) |
| 445 | }; |
| 446 | let old = start(parts.next(), '-'); |
| 447 | let new = start(parts.next(), '+'); |
| 448 | (old, new) |
| 449 | } |
| 450 | |
| 451 | |
| 452 | #[derive(Default)] |
| 453 | struct BlockOut { |
| 454 | is_binary: bool, |
| 455 | additions: u32, |
| 456 | deletions: u32, |
| 457 | hunks: Vec<Hunk>, |
| 458 | truncated: bool, |
| 459 | } |
| 460 | |
| 461 | |
| 462 | |
| 463 | fn parse_block(block: &str, limits: &DiffLimits, total_left: &mut usize) -> BlockOut { |
| 464 | let mut out = BlockOut::default(); |
| 465 | let mut file_lines = 0usize; |
| 466 | let mut in_hunk = false; |
| 467 | let mut pending_header: Option<String> = None; |
| 468 | let (mut old_no, mut new_no) = (0u32, 0u32); |
| 469 | |
| 470 | for line in block.lines() { |
| 471 | if !in_hunk { |
| 472 | if line.starts_with("@@") { |
| 473 | in_hunk = true; |
| 474 | (old_no, new_no) = parse_hunk_start(line); |
| 475 | pending_header = Some(line.to_owned()); |
| 476 | } else if line.starts_with("Binary files ") || line == "GIT binary patch" { |
| 477 | out.is_binary = true; |
| 478 | } |
| 479 | continue; |
| 480 | } |
| 481 | if line.starts_with("@@") { |
| 482 | (old_no, new_no) = parse_hunk_start(line); |
| 483 | pending_header = Some(line.to_owned()); |
| 484 | continue; |
| 485 | } |
| 486 | |
| 487 | let (kind, text) = match line.as_bytes().first() { |
| 488 | Some(b'+') => (LineKind::Add, &line[1..]), |
| 489 | Some(b'-') => (LineKind::Del, &line[1..]), |
| 490 | Some(b' ') => (LineKind::Context, &line[1..]), |
| 491 | Some(b'\\') => (LineKind::NoNewline, line), |
| 492 | _ => (LineKind::Context, line), |
| 493 | }; |
| 494 | let (dl_old, dl_new) = match kind { |
| 495 | LineKind::Context => (Some(old_no), Some(new_no)), |
| 496 | LineKind::Del => (Some(old_no), None), |
| 497 | LineKind::Add => (None, Some(new_no)), |
| 498 | LineKind::NoNewline => (None, None), |
| 499 | }; |
| 500 | match kind { |
| 501 | LineKind::Add => { |
| 502 | out.additions += 1; |
| 503 | new_no += 1; |
| 504 | } |
| 505 | LineKind::Del => { |
| 506 | out.deletions += 1; |
| 507 | old_no += 1; |
| 508 | } |
| 509 | LineKind::Context => { |
| 510 | old_no += 1; |
| 511 | new_no += 1; |
| 512 | } |
| 513 | LineKind::NoNewline => {} |
| 514 | } |
| 515 | |
| 516 | if file_lines >= limits.max_lines_per_file || *total_left == 0 { |
| 517 | out.truncated = true; |
| 518 | continue; |
| 519 | } |
| 520 | if let Some(h) = pending_header.take() { |
| 521 | out.hunks.push(Hunk { |
| 522 | header: h, |
| 523 | lines: Vec::new(), |
| 524 | }); |
| 525 | } |
| 526 | if let Some(hunk) = out.hunks.last_mut() { |
| 527 | hunk.lines.push(DiffLine { |
| 528 | kind, |
| 529 | old_no: dl_old, |
| 530 | new_no: dl_new, |
| 531 | text: text.to_owned(), |
| 532 | }); |
| 533 | } |
| 534 | file_lines += 1; |
| 535 | *total_left -= 1; |
| 536 | } |
| 537 | out |
| 538 | } |
| 539 | |
| 540 | |
| 541 | |
| 542 | |
| 543 | pub async fn commit_diff( |
| 544 | env: &GitEnv, |
| 545 | repo: &Path, |
| 546 | oid: &str, |
| 547 | limits: DiffLimits, |
| 548 | ) -> Result<CommitDiff> { |
| 549 | validate_oid(oid)?; |
| 550 | let base = match first_parent(env, repo, oid).await? { |
| 551 | Some(p) => p, |
| 552 | None => empty_tree_for(oid).to_owned(), |
| 553 | }; |
| 554 | diff_between(env, repo, ObjectSource::default(), &base, oid, limits).await |
| 555 | } |
| 556 | |
| 557 | |
| 558 | |
| 559 | pub(crate) async fn diff_between( |
| 560 | env: &GitEnv, |
| 561 | repo: &Path, |
| 562 | src: ObjectSource<'_>, |
| 563 | base: &str, |
| 564 | oid: &str, |
| 565 | limits: DiffLimits, |
| 566 | ) -> Result<CommitDiff> { |
| 567 | validate_oid(base)?; |
| 568 | validate_oid(oid)?; |
| 569 | let (name_buf, names_complete) = read_capped(diff_tree_cmd( |
| 570 | env, |
| 571 | repo, |
| 572 | src, |
| 573 | base, |
| 574 | oid, |
| 575 | DiffMode::NameStatus, |
| 576 | )?) |
| 577 | .await?; |
| 578 | let mut entries = parse_name_status(&name_buf); |
| 579 | let mut truncated_files = false; |
| 580 | if !names_complete { |
| 581 | entries.pop(); |
| 582 | truncated_files = true; |
| 583 | } |
| 584 | if entries.len() > limits.max_files { |
| 585 | entries.truncate(limits.max_files); |
| 586 | truncated_files = true; |
| 587 | } |
| 588 | |
| 589 | let (patch_buf, patch_complete) = |
| 590 | read_capped(diff_tree_cmd(env, repo, src, base, oid, DiffMode::Patch)?).await?; |
| 591 | let patch = String::from_utf8_lossy(&patch_buf); |
| 592 | let starts = block_starts(&patch); |
| 593 | |
| 594 | let usable = if patch_complete { |
| 595 | starts.len() |
| 596 | } else { |
| 597 | starts.len().saturating_sub(1) |
| 598 | }; |
| 599 | |
| 600 | let mut total_left = limits.max_total_lines; |
| 601 | let mut files = Vec::with_capacity(entries.len()); |
| 602 | for (i, entry) in entries.into_iter().enumerate() { |
| 603 | let block = if i < usable { |
| 604 | let end = starts.get(i + 1).copied().unwrap_or(patch.len()); |
| 605 | Some(parse_block( |
| 606 | &patch[starts[i]..end], |
| 607 | &limits, |
| 608 | &mut total_left, |
| 609 | )) |
| 610 | } else { |
| 611 | None |
| 612 | }; |
| 613 | let (out, missing) = match block { |
| 614 | Some(b) => (b, false), |
| 615 | None => (BlockOut::default(), true), |
| 616 | }; |
| 617 | files.push(FileDiff { |
| 618 | old_path: entry.old_path, |
| 619 | new_path: entry.new_path, |
| 620 | status: entry.status, |
| 621 | is_binary: out.is_binary, |
| 622 | additions: out.additions, |
| 623 | deletions: out.deletions, |
| 624 | hunks: out.hunks, |
| 625 | truncated: missing || out.truncated, |
| 626 | }); |
| 627 | } |
| 628 | Ok(CommitDiff { |
| 629 | files, |
| 630 | truncated_files, |
| 631 | }) |
| 632 | } |
| 633 | |
| 634 | |
| 635 | |
| 636 | |
| 637 | pub async fn last_commits( |
| 638 | env: &GitEnv, |
| 639 | repo: &Path, |
| 640 | commit: &str, |
| 641 | dir: &str, |
| 642 | names: &[String], |
| 643 | max_walk: usize, |
| 644 | ) -> Result<HashMap<String, CommitInfo>> { |
| 645 | validate_oid(commit)?; |
| 646 | if !dir.is_empty() { |
| 647 | browse::validate_repo_path(dir)?; |
| 648 | } |
| 649 | let wanted: HashSet<&str> = names.iter().map(String::as_str).collect(); |
| 650 | let mut found: HashMap<String, CommitInfo> = HashMap::new(); |
| 651 | if wanted.is_empty() || max_walk == 0 { |
| 652 | return Ok(found); |
| 653 | } |
| 654 | |
| 655 | let prefix = if dir.is_empty() { |
| 656 | String::new() |
| 657 | } else { |
| 658 | format!("{dir}/") |
| 659 | }; |
| 660 | let mut cmd = git(env, "log") |
| 661 | .git_dir(repo) |
| 662 | .arg("-z") |
| 663 | .arg("--no-color") |
| 664 | .arg("--name-only") |
| 665 | .arg(COMMIT_FORMAT) |
| 666 | .user_opt("--max-count", &max_walk.to_string())? |
| 667 | .user_revs(&[commit])?; |
| 668 | if !dir.is_empty() { |
| 669 | let spec = literal_spec(dir); |
| 670 | cmd = cmd.paths(&[spec.as_str()]); |
| 671 | } |
| 672 | |
| 673 | { |
| 674 | let mut handle = |rec: &[u8]| -> bool { |
| 675 | let Some((info, files)) = parse_record(rec) else { |
| 676 | return true; |
| 677 | }; |
| 678 | |
| 679 | let start = files |
| 680 | .iter() |
| 681 | .position(|&b| b != 0 && b != b'\n') |
| 682 | .unwrap_or(files.len()); |
| 683 | let files = &files[start..]; |
| 684 | for seg in files.split(|&b| b == 0).filter(|s| !s.is_empty()) { |
| 685 | let path = String::from_utf8_lossy(seg); |
| 686 | let rest: &str = if prefix.is_empty() { |
| 687 | &path |
| 688 | } else if let Some(r) = path.strip_prefix(prefix.as_str()) { |
| 689 | r |
| 690 | } else { |
| 691 | continue; |
| 692 | }; |
| 693 | let child = rest.split('/').next().unwrap_or(rest); |
| 694 | if wanted.contains(child) && !found.contains_key(child) { |
| 695 | found.insert(child.to_owned(), info.clone()); |
| 696 | } |
| 697 | } |
| 698 | found.len() < wanted.len() |
| 699 | }; |
| 700 | |
| 701 | let mut pending: Vec<u8> = Vec::new(); |
| 702 | let mut started = false; |
| 703 | let mut stopped = false; |
| 704 | let eof = for_each_chunk(cmd, |chunk| { |
| 705 | for &b in chunk { |
| 706 | if b == REC { |
| 707 | if started && !handle(&pending) { |
| 708 | stopped = true; |
| 709 | return false; |
| 710 | } |
| 711 | pending.clear(); |
| 712 | started = true; |
| 713 | } else if started { |
| 714 | pending.push(b); |
| 715 | } |
| 716 | } |
| 717 | true |
| 718 | }) |
| 719 | .await?; |
| 720 | if eof && started && !stopped { |
| 721 | handle(&pending); |
| 722 | } |
| 723 | } |
| 724 | Ok(found) |
| 725 | } |
| 726 | |
| 727 | |
| 728 | pub fn archive_command( |
| 729 | env: &GitEnv, |
| 730 | repo: &Path, |
| 731 | commit: &str, |
| 732 | format: ArchiveFormat, |
| 733 | prefix: &str, |
| 734 | ) -> Result<GitCommand> { |
| 735 | validate_oid(commit)?; |
| 736 | validate_prefix(prefix)?; |
| 737 | let format_arg = match format { |
| 738 | ArchiveFormat::TarGz => "--format=tar.gz", |
| 739 | ArchiveFormat::Zip => "--format=zip", |
| 740 | }; |
| 741 | git(env, "archive") |
| 742 | .git_dir(repo) |
| 743 | .arg(format_arg) |
| 744 | .user_opt("--prefix", &format!("{prefix}/"))? |
| 745 | .user_revs(&[commit]) |
| 746 | } |