samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
1023 lines· 31.2 KB ·Rust
Raw
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
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9use anyhow::{Context, Result, bail};
10use git2::{
11 BranchType, Commit, DiffFindOptions, DiffOptions, ObjectType, Oid, Patch, Repository,
12 RepositoryInitOptions, Signature, Sort, Tree,
13};
14
15use crate::highlight;
16
17// ---------------------------------------------------------------------------
18// Repository lifecycle
19// ---------------------------------------------------------------------------
20
21pub 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.
31fn 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
38pub 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.
43pub 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
66pub 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
72pub fn branch_exists(repo: &Repository, name: &str) -> bool {
73 repo.find_branch(name, BranchType::Local).is_ok()
74}
75
76pub 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
82pub 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.
90pub 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
110pub 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.
118pub 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
128pub 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)]
137pub 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
150impl 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
181pub 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)]
186pub 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)]
195pub struct Resolved {
196 pub name: String,
197 pub kind: RefKind,
198 pub commit: Oid,
199 pub path: String,
200}
201
202pub 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
241pub 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
253pub 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`.
264fn 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)]
292pub 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`.
301pub 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)]
330pub struct TagInfo {
331 pub name: String,
332 pub commit: CommitInfo,
333 pub message: Option<String>,
334}
335
336pub 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
360fn 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.
369fn 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.
379pub 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
409pub 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.
416pub 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
428pub fn merge_base(repo: &Repository, a: Oid, b: Oid) -> Option<Oid> {
429 repo.merge_base(a, b).ok()
430}
431
432pub 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)]
441pub enum EntryKind {
442 Dir,
443 File,
444 Symlink,
445 Submodule,
446}
447
448#[derive(Debug, Clone)]
449pub struct TreeEntry {
450 pub name: String,
451 pub path: String,
452 pub kind: EntryKind,
453 pub last_commit: Option<CommitInfo>,
454}
455
456impl 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
465pub 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
473fn 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.
488pub 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.
530fn 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
582pub struct Blob {
583 pub data: Vec<u8>,
584 pub size: usize,
585 pub is_binary: bool,
586}
587
588pub 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.
605pub 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)]
639pub enum LineKind {
640 Add,
641 Del,
642 Context,
643}
644
645#[derive(Debug, Clone)]
646pub struct DiffLine {
647 pub kind: LineKind,
648 pub old_no: Option<u32>,
649 pub new_no: Option<u32>,
650 pub html: String,
651}
652
653impl 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)]
671pub struct Hunk {
672 pub header: String,
673 pub lines: Vec<DiffLine>,
674}
675
676#[derive(Debug, Clone)]
677pub 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
689impl 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)]
719pub struct DiffSummary {
720 pub files: Vec<FileDiff>,
721 pub additions: usize,
722 pub deletions: usize,
723}
724
725const MAX_FILE_LINES: usize = 3000;
726const MAX_TOTAL_LINES: usize = 20000;
727
728pub 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.
827pub 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.
838pub 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).
848pub 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)]
859pub 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
868pub 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)]
883pub enum MergeMethod {
884 Merge,
885 Squash,
886 Rebase,
887}
888
889impl 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
900pub 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.
911pub 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)]
984mod 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}