Sam Jeston
History
1023 lines·
31.2 KB
·Rust
| 1 | //! Read and write access to bare repositories via libgit2. |
| 2 | //! |
| 3 | //! Everything here is synchronous; web handlers call into it from |
| 4 | //! `spawn_blocking`. |
| 5 | |
| 6 | use std::collections::{HashMap, HashSet}; |
| 7 | use std::path::Path; |
| 8 | |
| 9 | use anyhow::{Context, Result, bail}; |
| 10 | use git2::{ |
| 11 | BranchType, Commit, DiffFindOptions, DiffOptions, ObjectType, Oid, Patch, Repository, |
| 12 | RepositoryInitOptions, Signature, Sort, Tree, |
| 13 | }; |
| 14 | |
| 15 | use crate::highlight; |
| 16 | |
| 17 | // --------------------------------------------------------------------------- |
| 18 | // Repository lifecycle |
| 19 | // --------------------------------------------------------------------------- |
| 20 | |
| 21 | pub fn init_bare(path: &Path, default_branch: &str) -> Result<Repository> { |
| 22 | let mut opts = RepositoryInitOptions::new(); |
| 23 | opts.bare(true).initial_head(default_branch); |
| 24 | let repo = Repository::init_opts(path, &opts) |
| 25 | .with_context(|| format!("initializing repository at {}", path.display()))?; |
| 26 | configure(&repo)?; |
| 27 | Ok(repo) |
| 28 | } |
| 29 | |
| 30 | /// Server-side configuration applied to every hosted repository. |
| 31 | fn configure(repo: &Repository) -> Result<()> { |
| 32 | let mut cfg = repo.config()?; |
| 33 | // refs/pull/* is managed by Openhub; clients may fetch but never push to it. |
| 34 | cfg.set_str("receive.hideRefs", "refs/pull")?; |
| 35 | Ok(()) |
| 36 | } |
| 37 | |
| 38 | pub fn open(path: &Path) -> Result<Repository> { |
| 39 | Repository::open_bare(path).with_context(|| format!("opening repository {}", path.display())) |
| 40 | } |
| 41 | |
| 42 | /// Creates the first commit of an empty repository with the given files. |
| 43 | pub fn initial_commit( |
| 44 | repo: &Repository, |
| 45 | branch: &str, |
| 46 | files: &[(&str, &[u8])], |
| 47 | sig: &Signature, |
| 48 | ) -> Result<Oid> { |
| 49 | let mut builder = repo.treebuilder(None)?; |
| 50 | for (name, content) in files { |
| 51 | let blob = repo.blob(content)?; |
| 52 | builder.insert(name, blob, 0o100644)?; |
| 53 | } |
| 54 | let tree = repo.find_tree(builder.write()?)?; |
| 55 | let oid = repo.commit( |
| 56 | Some(&format!("refs/heads/{branch}")), |
| 57 | sig, |
| 58 | sig, |
| 59 | "Initial commit", |
| 60 | &tree, |
| 61 | &[], |
| 62 | )?; |
| 63 | Ok(oid) |
| 64 | } |
| 65 | |
| 66 | pub fn is_empty(repo: &Repository) -> bool { |
| 67 | repo.branches(Some(BranchType::Local)) |
| 68 | .map(|mut b| b.next().is_none()) |
| 69 | .unwrap_or(true) |
| 70 | } |
| 71 | |
| 72 | pub fn branch_exists(repo: &Repository, name: &str) -> bool { |
| 73 | repo.find_branch(name, BranchType::Local).is_ok() |
| 74 | } |
| 75 | |
| 76 | pub fn branch_tip(repo: &Repository, name: &str) -> Option<Oid> { |
| 77 | repo.find_branch(name, BranchType::Local) |
| 78 | .ok() |
| 79 | .and_then(|b| b.get().target()) |
| 80 | } |
| 81 | |
| 82 | pub fn set_head(repo: &Repository, branch: &str) -> Result<()> { |
| 83 | repo.set_head(&format!("refs/heads/{branch}"))?; |
| 84 | Ok(()) |
| 85 | } |
| 86 | |
| 87 | /// Makes sure HEAD points at an existing branch, preferring `preferred`. |
| 88 | /// Returns the branch HEAD points at afterwards, or `None` if the repository |
| 89 | /// has no branches yet. |
| 90 | pub fn ensure_head(repo: &Repository, preferred: &str) -> Result<Option<String>> { |
| 91 | let names = branch_names(repo)?; |
| 92 | if names.is_empty() { |
| 93 | return Ok(None); |
| 94 | } |
| 95 | let pick = [preferred, "main", "master"] |
| 96 | .into_iter() |
| 97 | .find(|c| names.iter().any(|n| n == c)) |
| 98 | .map(str::to_string) |
| 99 | .unwrap_or_else(|| names[0].clone()); |
| 100 | let current = repo |
| 101 | .find_reference("HEAD") |
| 102 | .ok() |
| 103 | .and_then(|r| r.symbolic_target().ok().flatten().map(str::to_string)); |
| 104 | if current.as_deref() != Some(&format!("refs/heads/{pick}")) { |
| 105 | set_head(repo, &pick)?; |
| 106 | } |
| 107 | Ok(Some(pick)) |
| 108 | } |
| 109 | |
| 110 | pub fn delete_branch(repo: &Repository, name: &str) -> Result<()> { |
| 111 | let mut branch = repo.find_branch(name, BranchType::Local)?; |
| 112 | branch.delete()?; |
| 113 | Ok(()) |
| 114 | } |
| 115 | |
| 116 | /// Points `refs/pull/<number>/head` at the given commit so the pull request's |
| 117 | /// history survives even if its branch is deleted. |
| 118 | pub fn set_pull_ref(repo: &Repository, number: i64, oid: Oid) -> Result<()> { |
| 119 | repo.reference( |
| 120 | &format!("refs/pull/{number}/head"), |
| 121 | oid, |
| 122 | true, |
| 123 | "openhub: update pull request head", |
| 124 | )?; |
| 125 | Ok(()) |
| 126 | } |
| 127 | |
| 128 | pub fn pull_ref(repo: &Repository, number: i64) -> Option<Oid> { |
| 129 | repo.refname_to_id(&format!("refs/pull/{number}/head")).ok() |
| 130 | } |
| 131 | |
| 132 | // --------------------------------------------------------------------------- |
| 133 | // Commits and refs |
| 134 | // --------------------------------------------------------------------------- |
| 135 | |
| 136 | #[derive(Debug, Clone)] |
| 137 | pub struct CommitInfo { |
| 138 | pub id: String, |
| 139 | pub short_id: String, |
| 140 | pub summary: String, |
| 141 | pub body: String, |
| 142 | pub author_name: String, |
| 143 | pub author_email: String, |
| 144 | pub author_time: i64, |
| 145 | pub committer_name: String, |
| 146 | pub committer_time: i64, |
| 147 | pub parents: Vec<String>, |
| 148 | } |
| 149 | |
| 150 | impl CommitInfo { |
| 151 | pub fn from_commit(c: &Commit) -> Self { |
| 152 | let message = String::from_utf8_lossy(c.message_bytes()).to_string(); |
| 153 | let mut parts = message.splitn(2, '\n'); |
| 154 | let summary = parts.next().unwrap_or("").trim().to_string(); |
| 155 | let body = parts.next().unwrap_or("").trim().to_string(); |
| 156 | let author = c.author(); |
| 157 | let committer = c.committer(); |
| 158 | let id = c.id().to_string(); |
| 159 | CommitInfo { |
| 160 | short_id: id[..7].to_string(), |
| 161 | id, |
| 162 | summary, |
| 163 | body, |
| 164 | author_name: String::from_utf8_lossy(author.name_bytes()).to_string(), |
| 165 | author_email: String::from_utf8_lossy(author.email_bytes()).to_string(), |
| 166 | author_time: author.when().seconds(), |
| 167 | committer_name: String::from_utf8_lossy(committer.name_bytes()).to_string(), |
| 168 | committer_time: committer.when().seconds(), |
| 169 | parents: c.parent_ids().map(|p| p.to_string()).collect(), |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | pub fn short_parents(&self) -> Vec<(String, String)> { |
| 174 | self.parents |
| 175 | .iter() |
| 176 | .map(|p| (p.clone(), p[..7].to_string())) |
| 177 | .collect() |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | pub fn commit_info(repo: &Repository, oid: Oid) -> Result<CommitInfo> { |
| 182 | Ok(CommitInfo::from_commit(&repo.find_commit(oid)?)) |
| 183 | } |
| 184 | |
| 185 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 186 | pub enum RefKind { |
| 187 | Branch, |
| 188 | Tag, |
| 189 | Commit, |
| 190 | } |
| 191 | |
| 192 | /// A ref (branch, tag or commit) plus a path inside its tree, parsed from a |
| 193 | /// URL tail such as `feature/login/src/main.rs`. |
| 194 | #[derive(Debug, Clone)] |
| 195 | pub struct Resolved { |
| 196 | pub name: String, |
| 197 | pub kind: RefKind, |
| 198 | pub commit: Oid, |
| 199 | pub path: String, |
| 200 | } |
| 201 | |
| 202 | pub fn resolve(repo: &Repository, rest: &str) -> Option<Resolved> { |
| 203 | let rest = rest.trim_matches('/'); |
| 204 | if rest.is_empty() { |
| 205 | return None; |
| 206 | } |
| 207 | let segs: Vec<&str> = rest.split('/').collect(); |
| 208 | for i in 1..=segs.len() { |
| 209 | let candidate = segs[..i].join("/"); |
| 210 | let path = segs[i..].join("/"); |
| 211 | for (prefix, kind) in [ |
| 212 | ("refs/heads/", RefKind::Branch), |
| 213 | ("refs/tags/", RefKind::Tag), |
| 214 | ] { |
| 215 | if let Ok(r) = repo.find_reference(&format!("{prefix}{candidate}")) { |
| 216 | if let Ok(c) = r.peel_to_commit() { |
| 217 | return Some(Resolved { |
| 218 | name: candidate, |
| 219 | kind, |
| 220 | commit: c.id(), |
| 221 | path, |
| 222 | }); |
| 223 | } |
| 224 | } |
| 225 | } |
| 226 | } |
| 227 | let first = segs[0]; |
| 228 | if (4..=40).contains(&first.len()) && first.chars().all(|c| c.is_ascii_hexdigit()) { |
| 229 | if let Ok(c) = repo.revparse_single(first).and_then(|o| o.peel_to_commit()) { |
| 230 | return Some(Resolved { |
| 231 | name: first.to_string(), |
| 232 | kind: RefKind::Commit, |
| 233 | commit: c.id(), |
| 234 | path: segs[1..].join("/"), |
| 235 | }); |
| 236 | } |
| 237 | } |
| 238 | None |
| 239 | } |
| 240 | |
| 241 | pub fn branch_names(repo: &Repository) -> Result<Vec<String>> { |
| 242 | let mut names = Vec::new(); |
| 243 | for b in repo.branches(Some(BranchType::Local))? { |
| 244 | let (b, _) = b?; |
| 245 | if let Some(name) = b.name()? { |
| 246 | names.push(name.to_string()); |
| 247 | } |
| 248 | } |
| 249 | names.sort(); |
| 250 | Ok(names) |
| 251 | } |
| 252 | |
| 253 | pub fn tag_names(repo: &Repository) -> Result<Vec<String>> { |
| 254 | let mut names: Vec<String> = repo |
| 255 | .tag_names(None)? |
| 256 | .iter() |
| 257 | .filter_map(|t| t.ok().flatten().map(String::from)) |
| 258 | .collect(); |
| 259 | names.sort_by(|a, b| natural_cmp(b, a)); |
| 260 | Ok(names) |
| 261 | } |
| 262 | |
| 263 | /// Orders version-like names so that `v1.10` sorts after `v1.9`. |
| 264 | fn natural_cmp(a: &str, b: &str) -> std::cmp::Ordering { |
| 265 | fn chunks(s: &str) -> Vec<(bool, String)> { |
| 266 | let mut out: Vec<(bool, String)> = Vec::new(); |
| 267 | for c in s.chars() { |
| 268 | let digit = c.is_ascii_digit(); |
| 269 | match out.last_mut() { |
| 270 | Some((d, buf)) if *d == digit => buf.push(c), |
| 271 | _ => out.push((digit, c.to_string())), |
| 272 | } |
| 273 | } |
| 274 | out |
| 275 | } |
| 276 | let (ca, cb) = (chunks(a), chunks(b)); |
| 277 | for (x, y) in ca.iter().zip(cb.iter()) { |
| 278 | let ord = if x.0 && y.0 { |
| 279 | let (nx, ny) = (x.1.trim_start_matches('0'), y.1.trim_start_matches('0')); |
| 280 | nx.len().cmp(&ny.len()).then_with(|| nx.cmp(ny)) |
| 281 | } else { |
| 282 | x.1.cmp(&y.1) |
| 283 | }; |
| 284 | if ord.is_ne() { |
| 285 | return ord; |
| 286 | } |
| 287 | } |
| 288 | ca.len().cmp(&cb.len()) |
| 289 | } |
| 290 | |
| 291 | #[derive(Debug, Clone)] |
| 292 | pub struct BranchInfo { |
| 293 | pub name: String, |
| 294 | pub commit: CommitInfo, |
| 295 | pub ahead: usize, |
| 296 | pub behind: usize, |
| 297 | } |
| 298 | |
| 299 | /// Branches sorted by most recent commit, with ahead/behind counts relative |
| 300 | /// to `default_branch`. |
| 301 | pub fn branches(repo: &Repository, default_branch: &str) -> Result<Vec<BranchInfo>> { |
| 302 | let default_tip = branch_tip(repo, default_branch); |
| 303 | let mut out = Vec::new(); |
| 304 | for b in repo.branches(Some(BranchType::Local))? { |
| 305 | let (b, _) = b?; |
| 306 | let Some(name) = b.name()?.map(str::to_string) else { |
| 307 | continue; |
| 308 | }; |
| 309 | let Ok(commit) = b.get().peel_to_commit() else { |
| 310 | continue; |
| 311 | }; |
| 312 | let (ahead, behind) = match default_tip { |
| 313 | Some(base) if name != default_branch => { |
| 314 | repo.graph_ahead_behind(commit.id(), base).unwrap_or((0, 0)) |
| 315 | } |
| 316 | _ => (0, 0), |
| 317 | }; |
| 318 | out.push(BranchInfo { |
| 319 | name, |
| 320 | commit: CommitInfo::from_commit(&commit), |
| 321 | ahead, |
| 322 | behind, |
| 323 | }); |
| 324 | } |
| 325 | out.sort_by(|a, b| b.commit.committer_time.cmp(&a.commit.committer_time)); |
| 326 | Ok(out) |
| 327 | } |
| 328 | |
| 329 | #[derive(Debug, Clone)] |
| 330 | pub struct TagInfo { |
| 331 | pub name: String, |
| 332 | pub commit: CommitInfo, |
| 333 | pub message: Option<String>, |
| 334 | } |
| 335 | |
| 336 | pub fn tags(repo: &Repository) -> Result<Vec<TagInfo>> { |
| 337 | let mut out = Vec::new(); |
| 338 | for name in tag_names(repo)? { |
| 339 | let Ok(r) = repo.find_reference(&format!("refs/tags/{name}")) else { |
| 340 | continue; |
| 341 | }; |
| 342 | let message = r |
| 343 | .peel(ObjectType::Tag) |
| 344 | .ok() |
| 345 | .and_then(|o| o.into_tag().ok()) |
| 346 | .and_then(|t| t.message().ok().flatten().map(|m| m.trim().to_string())) |
| 347 | .filter(|m| !m.is_empty()); |
| 348 | let Ok(commit) = r.peel_to_commit() else { |
| 349 | continue; |
| 350 | }; |
| 351 | out.push(TagInfo { |
| 352 | name, |
| 353 | commit: CommitInfo::from_commit(&commit), |
| 354 | message, |
| 355 | }); |
| 356 | } |
| 357 | Ok(out) |
| 358 | } |
| 359 | |
| 360 | fn entry_id_at(commit: &Commit, path: &str) -> Option<Oid> { |
| 361 | let tree = commit.tree().ok()?; |
| 362 | if path.is_empty() { |
| 363 | return Some(tree.id()); |
| 364 | } |
| 365 | tree.get_path(Path::new(path)).ok().map(|e| e.id()) |
| 366 | } |
| 367 | |
| 368 | /// True if `commit` changed `path` relative to all of its parents. |
| 369 | fn touches_path(commit: &Commit, path: &str) -> bool { |
| 370 | let mine = entry_id_at(commit, path); |
| 371 | if commit.parent_count() == 0 { |
| 372 | return mine.is_some(); |
| 373 | } |
| 374 | commit.parents().all(|p| entry_id_at(&p, path) != mine) |
| 375 | } |
| 376 | |
| 377 | /// A page of history starting at `start`, optionally limited to commits |
| 378 | /// touching `path`. Returns the commits and whether more exist. |
| 379 | pub fn log( |
| 380 | repo: &Repository, |
| 381 | start: Oid, |
| 382 | path: Option<&str>, |
| 383 | skip: usize, |
| 384 | limit: usize, |
| 385 | ) -> Result<(Vec<CommitInfo>, bool)> { |
| 386 | let mut walk = repo.revwalk()?; |
| 387 | walk.set_sorting(Sort::TIME)?; |
| 388 | walk.push(start)?; |
| 389 | let mut out = Vec::new(); |
| 390 | let mut seen = 0usize; |
| 391 | for oid in walk { |
| 392 | let commit = repo.find_commit(oid?)?; |
| 393 | if let Some(p) = path.filter(|p| !p.is_empty()) { |
| 394 | if !touches_path(&commit, p) { |
| 395 | continue; |
| 396 | } |
| 397 | } |
| 398 | if seen >= skip + limit { |
| 399 | return Ok((out, true)); |
| 400 | } |
| 401 | if seen >= skip { |
| 402 | out.push(CommitInfo::from_commit(&commit)); |
| 403 | } |
| 404 | seen += 1; |
| 405 | } |
| 406 | Ok((out, false)) |
| 407 | } |
| 408 | |
| 409 | pub fn commit_count(repo: &Repository, start: Oid) -> Result<usize> { |
| 410 | let mut walk = repo.revwalk()?; |
| 411 | walk.push(start)?; |
| 412 | Ok(walk.count()) |
| 413 | } |
| 414 | |
| 415 | /// Commits reachable from `head` but not from `base`, oldest first. |
| 416 | pub fn commits_between(repo: &Repository, base: Oid, head: Oid) -> Result<Vec<CommitInfo>> { |
| 417 | let mut walk = repo.revwalk()?; |
| 418 | walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; |
| 419 | walk.push(head)?; |
| 420 | walk.hide(base)?; |
| 421 | let mut out = Vec::new(); |
| 422 | for oid in walk { |
| 423 | out.push(CommitInfo::from_commit(&repo.find_commit(oid?)?)); |
| 424 | } |
| 425 | Ok(out) |
| 426 | } |
| 427 | |
| 428 | pub fn merge_base(repo: &Repository, a: Oid, b: Oid) -> Option<Oid> { |
| 429 | repo.merge_base(a, b).ok() |
| 430 | } |
| 431 | |
| 432 | pub fn is_ancestor(repo: &Repository, ancestor: Oid, of: Oid) -> bool { |
| 433 | ancestor == of || repo.graph_descendant_of(of, ancestor).unwrap_or(false) |
| 434 | } |
| 435 | |
| 436 | // --------------------------------------------------------------------------- |
| 437 | // Trees and blobs |
| 438 | // --------------------------------------------------------------------------- |
| 439 | |
| 440 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 441 | pub enum EntryKind { |
| 442 | Dir, |
| 443 | File, |
| 444 | Symlink, |
| 445 | Submodule, |
| 446 | } |
| 447 | |
| 448 | #[derive(Debug, Clone)] |
| 449 | pub struct TreeEntry { |
| 450 | pub name: String, |
| 451 | pub path: String, |
| 452 | pub kind: EntryKind, |
| 453 | pub last_commit: Option<CommitInfo>, |
| 454 | } |
| 455 | |
| 456 | impl TreeEntry { |
| 457 | pub fn is_dir(&self) -> bool { |
| 458 | self.kind == EntryKind::Dir |
| 459 | } |
| 460 | pub fn is_submodule(&self) -> bool { |
| 461 | self.kind == EntryKind::Submodule |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | pub fn path_type(repo: &Repository, commit: Oid, path: &str) -> Result<Option<ObjectType>> { |
| 466 | let tree = repo.find_commit(commit)?.tree()?; |
| 467 | if path.is_empty() { |
| 468 | return Ok(Some(ObjectType::Tree)); |
| 469 | } |
| 470 | Ok(tree.get_path(Path::new(path)).ok().and_then(|e| e.kind())) |
| 471 | } |
| 472 | |
| 473 | fn subtree<'r>(repo: &'r Repository, commit: &Commit<'r>, path: &str) -> Result<Option<Tree<'r>>> { |
| 474 | let tree = commit.tree()?; |
| 475 | if path.is_empty() { |
| 476 | return Ok(Some(tree)); |
| 477 | } |
| 478 | let Ok(entry) = tree.get_path(Path::new(path)) else { |
| 479 | return Ok(None); |
| 480 | }; |
| 481 | if entry.kind() != Some(ObjectType::Tree) { |
| 482 | return Ok(None); |
| 483 | } |
| 484 | Ok(Some(repo.find_tree(entry.id())?)) |
| 485 | } |
| 486 | |
| 487 | /// Lists a directory, directories first, each with the last commit that touched it. |
| 488 | pub fn list_tree(repo: &Repository, commit_id: Oid, path: &str) -> Result<Option<Vec<TreeEntry>>> { |
| 489 | let commit = repo.find_commit(commit_id)?; |
| 490 | let Some(tree) = subtree(repo, &commit, path)? else { |
| 491 | return Ok(None); |
| 492 | }; |
| 493 | let mut entries: Vec<TreeEntry> = tree |
| 494 | .iter() |
| 495 | .map(|e| { |
| 496 | let name = String::from_utf8_lossy(e.name_bytes()).to_string(); |
| 497 | let kind = match (e.kind(), e.filemode()) { |
| 498 | (Some(ObjectType::Tree), _) => EntryKind::Dir, |
| 499 | (Some(ObjectType::Commit), _) => EntryKind::Submodule, |
| 500 | (_, 0o120000) => EntryKind::Symlink, |
| 501 | _ => EntryKind::File, |
| 502 | }; |
| 503 | TreeEntry { |
| 504 | path: if path.is_empty() { |
| 505 | name.clone() |
| 506 | } else { |
| 507 | format!("{path}/{name}") |
| 508 | }, |
| 509 | name, |
| 510 | kind, |
| 511 | last_commit: None, |
| 512 | } |
| 513 | }) |
| 514 | .collect(); |
| 515 | entries.sort_by(|a, b| { |
| 516 | b.is_dir() |
| 517 | .cmp(&a.is_dir()) |
| 518 | .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) |
| 519 | }); |
| 520 | |
| 521 | let mut last = last_commits(repo, commit_id, path, &entries)?; |
| 522 | for e in &mut entries { |
| 523 | e.last_commit = last.remove(&e.name); |
| 524 | } |
| 525 | Ok(Some(entries)) |
| 526 | } |
| 527 | |
| 528 | /// Finds the most recent commit that changed each entry of a directory. |
| 529 | /// Bounded so very long histories cannot stall a page load. |
| 530 | fn last_commits( |
| 531 | repo: &Repository, |
| 532 | start: Oid, |
| 533 | dir: &str, |
| 534 | entries: &[TreeEntry], |
| 535 | ) -> Result<HashMap<String, CommitInfo>> { |
| 536 | const MAX_WALK: usize = 5000; |
| 537 | let mut remaining: HashSet<&str> = entries.iter().map(|e| e.name.as_str()).collect(); |
| 538 | let mut found = HashMap::new(); |
| 539 | let mut walk = repo.revwalk()?; |
| 540 | walk.set_sorting(Sort::TIME)?; |
| 541 | walk.push(start)?; |
| 542 | |
| 543 | let entry_ids = |c: &Commit| -> HashMap<String, Oid> { |
| 544 | subtree(repo, c, dir) |
| 545 | .ok() |
| 546 | .flatten() |
| 547 | .map(|t| { |
| 548 | t.iter() |
| 549 | .map(|e| (String::from_utf8_lossy(e.name_bytes()).to_string(), e.id())) |
| 550 | .collect() |
| 551 | }) |
| 552 | .unwrap_or_default() |
| 553 | }; |
| 554 | |
| 555 | for oid in walk.take(MAX_WALK) { |
| 556 | if remaining.is_empty() { |
| 557 | break; |
| 558 | } |
| 559 | let commit = repo.find_commit(oid?)?; |
| 560 | let mine = entry_ids(&commit); |
| 561 | let parents: Vec<HashMap<String, Oid>> = commit.parents().map(|p| entry_ids(&p)).collect(); |
| 562 | let changed: Vec<&str> = remaining |
| 563 | .iter() |
| 564 | .copied() |
| 565 | .filter(|name| { |
| 566 | let id = mine.get(*name); |
| 567 | id.is_some() && parents.iter().all(|p| p.get(*name) != id) |
| 568 | }) |
| 569 | .collect(); |
| 570 | if changed.is_empty() { |
| 571 | continue; |
| 572 | } |
| 573 | let info = CommitInfo::from_commit(&commit); |
| 574 | for name in changed { |
| 575 | remaining.remove(name); |
| 576 | found.insert(name.to_string(), info.clone()); |
| 577 | } |
| 578 | } |
| 579 | Ok(found) |
| 580 | } |
| 581 | |
| 582 | pub struct Blob { |
| 583 | pub data: Vec<u8>, |
| 584 | pub size: usize, |
| 585 | pub is_binary: bool, |
| 586 | } |
| 587 | |
| 588 | pub fn read_blob(repo: &Repository, commit: Oid, path: &str) -> Result<Option<Blob>> { |
| 589 | let tree = repo.find_commit(commit)?.tree()?; |
| 590 | let Ok(entry) = tree.get_path(Path::new(path)) else { |
| 591 | return Ok(None); |
| 592 | }; |
| 593 | if entry.kind() != Some(ObjectType::Blob) { |
| 594 | return Ok(None); |
| 595 | } |
| 596 | let blob = repo.find_blob(entry.id())?; |
| 597 | Ok(Some(Blob { |
| 598 | size: blob.size(), |
| 599 | is_binary: blob.is_binary(), |
| 600 | data: blob.content().to_vec(), |
| 601 | })) |
| 602 | } |
| 603 | |
| 604 | /// Finds a README in the given directory, returning its file name and contents. |
| 605 | pub fn find_readme(repo: &Repository, commit: Oid, dir: &str) -> Result<Option<(String, String)>> { |
| 606 | let c = repo.find_commit(commit)?; |
| 607 | let Some(tree) = subtree(repo, &c, dir)? else { |
| 608 | return Ok(None); |
| 609 | }; |
| 610 | let candidates = [ |
| 611 | "readme.md", |
| 612 | "readme.markdown", |
| 613 | "readme", |
| 614 | "readme.txt", |
| 615 | "readme.rst", |
| 616 | ]; |
| 617 | let found = candidates.iter().find_map(|cand| { |
| 618 | tree.iter().find(|e| { |
| 619 | e.kind() == Some(ObjectType::Blob) |
| 620 | && e.name().is_ok_and(|n| n.eq_ignore_ascii_case(cand)) |
| 621 | }) |
| 622 | }); |
| 623 | let Some(entry) = found else { return Ok(None) }; |
| 624 | let blob = repo.find_blob(entry.id())?; |
| 625 | if blob.is_binary() || blob.size() > 1024 * 1024 { |
| 626 | return Ok(None); |
| 627 | } |
| 628 | Ok(Some(( |
| 629 | entry.name().unwrap_or("README").to_string(), |
| 630 | String::from_utf8_lossy(blob.content()).to_string(), |
| 631 | ))) |
| 632 | } |
| 633 | |
| 634 | // --------------------------------------------------------------------------- |
| 635 | // Diffs |
| 636 | // --------------------------------------------------------------------------- |
| 637 | |
| 638 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 639 | pub enum LineKind { |
| 640 | Add, |
| 641 | Del, |
| 642 | Context, |
| 643 | } |
| 644 | |
| 645 | #[derive(Debug, Clone)] |
| 646 | pub struct DiffLine { |
| 647 | pub kind: LineKind, |
| 648 | pub old_no: Option<u32>, |
| 649 | pub new_no: Option<u32>, |
| 650 | pub html: String, |
| 651 | } |
| 652 | |
| 653 | impl DiffLine { |
| 654 | pub fn class(&self) -> &'static str { |
| 655 | match self.kind { |
| 656 | LineKind::Add => "add", |
| 657 | LineKind::Del => "del", |
| 658 | LineKind::Context => "ctx", |
| 659 | } |
| 660 | } |
| 661 | pub fn marker(&self) -> &'static str { |
| 662 | match self.kind { |
| 663 | LineKind::Add => "+", |
| 664 | LineKind::Del => "-", |
| 665 | LineKind::Context => " ", |
| 666 | } |
| 667 | } |
| 668 | } |
| 669 | |
| 670 | #[derive(Debug, Clone)] |
| 671 | pub struct Hunk { |
| 672 | pub header: String, |
| 673 | pub lines: Vec<DiffLine>, |
| 674 | } |
| 675 | |
| 676 | #[derive(Debug, Clone)] |
| 677 | pub struct FileDiff { |
| 678 | pub index: usize, |
| 679 | pub old_path: String, |
| 680 | pub new_path: String, |
| 681 | pub status: &'static str, |
| 682 | pub additions: usize, |
| 683 | pub deletions: usize, |
| 684 | pub binary: bool, |
| 685 | pub too_large: bool, |
| 686 | pub hunks: Vec<Hunk>, |
| 687 | } |
| 688 | |
| 689 | impl FileDiff { |
| 690 | pub fn display_path(&self) -> String { |
| 691 | if self.status == "renamed" && self.old_path != self.new_path { |
| 692 | format!("{} → {}", self.old_path, self.new_path) |
| 693 | } else { |
| 694 | self.new_path.clone() |
| 695 | } |
| 696 | } |
| 697 | /// Five-square summary bar like `+++--`. |
| 698 | pub fn stat_blocks(&self) -> Vec<&'static str> { |
| 699 | let total = self.additions + self.deletions; |
| 700 | if total == 0 { |
| 701 | return vec!["neutral"; 5]; |
| 702 | } |
| 703 | let share = |n: usize| { |
| 704 | let blocks = ((n as f64 / total as f64) * 5.0).round() as usize; |
| 705 | if n > 0 { blocks.max(1) } else { 0 } |
| 706 | }; |
| 707 | let (mut adds, mut dels) = (share(self.additions), share(self.deletions)); |
| 708 | while adds + dels > 5 { |
| 709 | if adds > dels { adds -= 1 } else { dels -= 1 } |
| 710 | } |
| 711 | let mut v = vec!["add"; adds]; |
| 712 | v.extend(std::iter::repeat_n("del", dels)); |
| 713 | v.resize(5, "neutral"); |
| 714 | v |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | #[derive(Debug, Clone, Default)] |
| 719 | pub struct DiffSummary { |
| 720 | pub files: Vec<FileDiff>, |
| 721 | pub additions: usize, |
| 722 | pub deletions: usize, |
| 723 | } |
| 724 | |
| 725 | const MAX_FILE_LINES: usize = 3000; |
| 726 | const MAX_TOTAL_LINES: usize = 20000; |
| 727 | |
| 728 | pub fn diff_trees( |
| 729 | repo: &Repository, |
| 730 | old: Option<&Tree>, |
| 731 | new: Option<&Tree>, |
| 732 | ) -> Result<DiffSummary> { |
| 733 | let mut opts = DiffOptions::new(); |
| 734 | opts.context_lines(3).ignore_submodules(true); |
| 735 | let mut diff = repo.diff_tree_to_tree(old, new, Some(&mut opts))?; |
| 736 | let mut find = DiffFindOptions::new(); |
| 737 | find.renames(true); |
| 738 | diff.find_similar(Some(&mut find))?; |
| 739 | |
| 740 | let mut summary = DiffSummary::default(); |
| 741 | let mut rendered_lines = 0usize; |
| 742 | for idx in 0..diff.deltas().len() { |
| 743 | let Some(patch) = Patch::from_diff(&diff, idx)? else { |
| 744 | continue; |
| 745 | }; |
| 746 | let delta = patch.delta(); |
| 747 | let status = match delta.status() { |
| 748 | git2::Delta::Added => "added", |
| 749 | git2::Delta::Deleted => "deleted", |
| 750 | git2::Delta::Renamed => "renamed", |
| 751 | git2::Delta::Copied => "copied", |
| 752 | _ => "modified", |
| 753 | }; |
| 754 | let path_of = |f: git2::DiffFile| { |
| 755 | f.path() |
| 756 | .map(|p| p.to_string_lossy().to_string()) |
| 757 | .unwrap_or_default() |
| 758 | }; |
| 759 | let old_path = path_of(delta.old_file()); |
| 760 | let new_path = path_of(delta.new_file()); |
| 761 | let binary = delta.flags().is_binary(); |
| 762 | let (_, additions, deletions) = patch.line_stats()?; |
| 763 | let too_large = additions + deletions > MAX_FILE_LINES |
| 764 | || rendered_lines + additions + deletions > MAX_TOTAL_LINES; |
| 765 | |
| 766 | let mut hunks = Vec::new(); |
| 767 | if !binary && !too_large { |
| 768 | let lang_path = if new_path.is_empty() { |
| 769 | &old_path |
| 770 | } else { |
| 771 | &new_path |
| 772 | }; |
| 773 | for h in 0..patch.num_hunks() { |
| 774 | let (hunk, n) = patch.hunk(h)?; |
| 775 | let mut raw = Vec::with_capacity(n); |
| 776 | for l in 0..n { |
| 777 | let line = patch.line_in_hunk(h, l)?; |
| 778 | let kind = match line.origin() { |
| 779 | '+' => LineKind::Add, |
| 780 | '-' => LineKind::Del, |
| 781 | ' ' => LineKind::Context, |
| 782 | _ => continue, // "\ No newline at end of file" markers |
| 783 | }; |
| 784 | let text = String::from_utf8_lossy(line.content()); |
| 785 | let text = text.trim_end_matches(['\n', '\r']).to_string(); |
| 786 | raw.push((kind, line.old_lineno(), line.new_lineno(), text)); |
| 787 | } |
| 788 | let texts: Vec<&str> = raw.iter().map(|r| r.3.as_str()).collect(); |
| 789 | let htmls = highlight::highlight_lines(lang_path, &texts); |
| 790 | let lines = raw |
| 791 | .into_iter() |
| 792 | .zip(htmls) |
| 793 | .map(|((kind, old_no, new_no, _), html)| DiffLine { |
| 794 | kind, |
| 795 | old_no, |
| 796 | new_no, |
| 797 | html, |
| 798 | }) |
| 799 | .collect(); |
| 800 | hunks.push(Hunk { |
| 801 | header: String::from_utf8_lossy(hunk.header()) |
| 802 | .trim_end() |
| 803 | .to_string(), |
| 804 | lines, |
| 805 | }); |
| 806 | } |
| 807 | rendered_lines += additions + deletions; |
| 808 | } |
| 809 | summary.additions += additions; |
| 810 | summary.deletions += deletions; |
| 811 | summary.files.push(FileDiff { |
| 812 | index: summary.files.len(), |
| 813 | old_path, |
| 814 | new_path, |
| 815 | status, |
| 816 | additions, |
| 817 | deletions, |
| 818 | binary, |
| 819 | too_large, |
| 820 | hunks, |
| 821 | }); |
| 822 | } |
| 823 | Ok(summary) |
| 824 | } |
| 825 | |
| 826 | /// Diff introduced by a commit, against its first parent. |
| 827 | pub fn commit_diff(repo: &Repository, oid: Oid) -> Result<DiffSummary> { |
| 828 | let commit = repo.find_commit(oid)?; |
| 829 | let parent_tree = match commit.parent(0) { |
| 830 | Ok(p) => Some(p.tree()?), |
| 831 | Err(_) => None, |
| 832 | }; |
| 833 | diff_trees(repo, parent_tree.as_ref(), Some(&commit.tree()?)) |
| 834 | } |
| 835 | |
| 836 | /// Files changed, additions and deletions between two commits, without |
| 837 | /// rendering the patch. |
| 838 | pub fn diff_stats(repo: &Repository, base: Oid, head: Oid) -> Result<(usize, usize, usize)> { |
| 839 | let base_tree = repo.find_commit(base)?.tree()?; |
| 840 | let head_tree = repo.find_commit(head)?.tree()?; |
| 841 | let mut diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), None)?; |
| 842 | diff.find_similar(Some(DiffFindOptions::new().renames(true)))?; |
| 843 | let stats = diff.stats()?; |
| 844 | Ok((stats.files_changed(), stats.insertions(), stats.deletions())) |
| 845 | } |
| 846 | |
| 847 | /// Diff between two commits (`base..head` as trees). |
| 848 | pub fn range_diff(repo: &Repository, base: Oid, head: Oid) -> Result<DiffSummary> { |
| 849 | let base_tree = repo.find_commit(base)?.tree()?; |
| 850 | let head_tree = repo.find_commit(head)?.tree()?; |
| 851 | diff_trees(repo, Some(&base_tree), Some(&head_tree)) |
| 852 | } |
| 853 | |
| 854 | // --------------------------------------------------------------------------- |
| 855 | // Merging |
| 856 | // --------------------------------------------------------------------------- |
| 857 | |
| 858 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 859 | pub enum Mergeability { |
| 860 | /// Can be merged without conflicts. |
| 861 | Clean, |
| 862 | /// A three-way merge produces conflicts. |
| 863 | Conflicts, |
| 864 | /// The head has no commits that are not already in the base. |
| 865 | UpToDate, |
| 866 | } |
| 867 | |
| 868 | pub fn mergeability(repo: &Repository, base: Oid, head: Oid) -> Result<Mergeability> { |
| 869 | if is_ancestor(repo, head, base) { |
| 870 | return Ok(Mergeability::UpToDate); |
| 871 | } |
| 872 | let ours = repo.find_commit(base)?; |
| 873 | let theirs = repo.find_commit(head)?; |
| 874 | let index = repo.merge_commits(&ours, &theirs, None)?; |
| 875 | Ok(if index.has_conflicts() { |
| 876 | Mergeability::Conflicts |
| 877 | } else { |
| 878 | Mergeability::Clean |
| 879 | }) |
| 880 | } |
| 881 | |
| 882 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 883 | pub enum MergeMethod { |
| 884 | Merge, |
| 885 | Squash, |
| 886 | Rebase, |
| 887 | } |
| 888 | |
| 889 | impl MergeMethod { |
| 890 | pub fn parse(s: &str) -> Option<Self> { |
| 891 | match s { |
| 892 | "merge" => Some(Self::Merge), |
| 893 | "squash" => Some(Self::Squash), |
| 894 | "rebase" => Some(Self::Rebase), |
| 895 | _ => None, |
| 896 | } |
| 897 | } |
| 898 | } |
| 899 | |
| 900 | pub struct MergeRequest<'a> { |
| 901 | pub base_branch: &'a str, |
| 902 | pub base: Oid, |
| 903 | pub head: Oid, |
| 904 | pub method: MergeMethod, |
| 905 | pub message: &'a str, |
| 906 | pub committer: &'a Signature<'a>, |
| 907 | } |
| 908 | |
| 909 | /// Merges `head` into the base branch and returns the new tip of the base. |
| 910 | /// Fails without changing anything if the base branch moved or the merge conflicts. |
| 911 | pub fn merge(repo: &Repository, req: &MergeRequest) -> Result<Oid> { |
| 912 | let base_commit = repo.find_commit(req.base)?; |
| 913 | let head_commit = repo.find_commit(req.head)?; |
| 914 | |
| 915 | let new_tip = match req.method { |
| 916 | MergeMethod::Merge | MergeMethod::Squash => { |
| 917 | let mut index = repo.merge_commits(&base_commit, &head_commit, None)?; |
| 918 | if index.has_conflicts() { |
| 919 | bail!("This branch has conflicts that must be resolved"); |
| 920 | } |
| 921 | let tree = repo.find_tree(index.write_tree_to(repo)?)?; |
| 922 | let parents: Vec<&Commit> = if req.method == MergeMethod::Merge { |
| 923 | vec![&base_commit, &head_commit] |
| 924 | } else { |
| 925 | vec![&base_commit] |
| 926 | }; |
| 927 | let author = if req.method == MergeMethod::Squash { |
| 928 | head_commit.author().to_owned() |
| 929 | } else { |
| 930 | req.committer.to_owned() |
| 931 | }; |
| 932 | repo.commit(None, &author, req.committer, req.message, &tree, &parents)? |
| 933 | } |
| 934 | MergeMethod::Rebase => { |
| 935 | let mut walk = repo.revwalk()?; |
| 936 | walk.set_sorting(Sort::TOPOLOGICAL | Sort::REVERSE)?; |
| 937 | walk.push(req.head)?; |
| 938 | walk.hide(req.base)?; |
| 939 | let mut onto = base_commit; |
| 940 | for oid in walk { |
| 941 | let commit = repo.find_commit(oid?)?; |
| 942 | if commit.parent_count() > 1 { |
| 943 | continue; // merge commits are dropped, as with `git rebase` |
| 944 | } |
| 945 | let mut index = repo.cherrypick_commit(&commit, &onto, 0, None)?; |
| 946 | if index.has_conflicts() { |
| 947 | bail!( |
| 948 | "Commit {} cannot be rebased cleanly", |
| 949 | &commit.id().to_string()[..7] |
| 950 | ); |
| 951 | } |
| 952 | let tree_id = index.write_tree_to(repo)?; |
| 953 | if tree_id == onto.tree_id() { |
| 954 | continue; // became empty |
| 955 | } |
| 956 | let tree = repo.find_tree(tree_id)?; |
| 957 | let message = String::from_utf8_lossy(commit.message_raw_bytes()).to_string(); |
| 958 | let new = repo.commit( |
| 959 | None, |
| 960 | &commit.author(), |
| 961 | req.committer, |
| 962 | &message, |
| 963 | &tree, |
| 964 | &[&onto], |
| 965 | )?; |
| 966 | onto = repo.find_commit(new)?; |
| 967 | } |
| 968 | onto.id() |
| 969 | } |
| 970 | }; |
| 971 | |
| 972 | repo.reference_matching( |
| 973 | &format!("refs/heads/{}", req.base_branch), |
| 974 | new_tip, |
| 975 | true, |
| 976 | req.base, |
| 977 | "openhub: merge pull request", |
| 978 | ) |
| 979 | .context("The base branch changed while merging; please try again")?; |
| 980 | Ok(new_tip) |
| 981 | } |
| 982 | |
| 983 | #[cfg(test)] |
| 984 | mod tests { |
| 985 | use super::*; |
| 986 | |
| 987 | #[test] |
| 988 | fn natural_ordering() { |
| 989 | let mut v = vec!["v1.9", "v1.10", "v1.2", "v0.9"]; |
| 990 | v.sort_by(|a, b| natural_cmp(a, b)); |
| 991 | assert_eq!(v, vec!["v0.9", "v1.2", "v1.9", "v1.10"]); |
| 992 | } |
| 993 | |
| 994 | #[test] |
| 995 | fn stat_blocks_sum_to_five() { |
| 996 | for (a, d) in [ |
| 997 | (0, 0), |
| 998 | (1, 0), |
| 999 | (0, 1), |
| 1000 | (1, 1), |
| 1001 | (10, 1), |
| 1002 | (1, 10), |
| 1003 | (3, 2), |
| 1004 | (100, 99), |
| 1005 | ] { |
| 1006 | let f = FileDiff { |
| 1007 | index: 0, |
| 1008 | old_path: String::new(), |
| 1009 | new_path: String::new(), |
| 1010 | status: "modified", |
| 1011 | additions: a, |
| 1012 | deletions: d, |
| 1013 | binary: false, |
| 1014 | too_large: false, |
| 1015 | hunks: vec![], |
| 1016 | }; |
| 1017 | let b = f.stat_blocks(); |
| 1018 | assert_eq!(b.len(), 5, "{a}/{d}"); |
| 1019 | assert_eq!(b.contains(&"add"), a > 0, "{a}/{d}"); |
| 1020 | assert_eq!(b.contains(&"del"), d > 0, "{a}/{d}"); |
| 1021 | } |
| 1022 | } |
| 1023 | } |