| 1 | //! Reading a conflicted revision's sides (spec §4). |
| 2 | //! |
| 3 | //! > jj represents a conflicted state in the Git backend as a specially |
| 4 | //! > structured tree. Detect that structure in the indexer and set |
| 5 | //! > `revisions.conflicted`. Store the conflict sides so the conflict viewer can |
| 6 | //! > render them without re-walking objects on every request. |
| 7 | //! |
| 8 | //! jj's conflict representation is an alternating list of trees carried in the |
| 9 | //! commit's `jj:trees` header: `side₀ base₀ side₁ base₁ … sideₙ`, always one |
| 10 | //! more side than base. The materialised value of a conflicted file is |
| 11 | //! `side₀ - base₀ + side₁ - base₁ + …`; a path is in conflict exactly when it is |
| 12 | //! not the same in every one of those trees. |
| 13 | //! |
| 14 | //! This module resolves that into plain strings so the conflict viewer never |
| 15 | //! sees a tree id. v1 shows conflicts **read-only** — resolution happens in the |
| 16 | //! user's working copy (spec §4), so nothing here writes. |
| 17 | |
| 18 | use std::collections::BTreeSet; |
| 19 | |
| 20 | use crate::{ConflictSide, ConflictedFile, Result, RevId, StoreError}; |
| 21 | |
| 22 | /// Never materialise more conflicted files than this into one page. |
| 23 | /// |
| 24 | /// A repository-wide conflict is possible (a rebase across a moved directory) |
| 25 | /// and rendering ten thousand files would take the process down, which spec §8 |
| 26 | /// says a pathological input must never do. |
| 27 | const MAX_CONFLICTED_FILES: usize = 200; |
| 28 | |
| 29 | /// Per-side content cap. Conflict sides are shown inline; a large one is |
| 30 | /// reported by size rather than rendered. |
| 31 | const MAX_SIDE_BYTES: usize = 512 * 1024; |
| 32 | |
| 33 | /// Read every conflicted path in `rev`. |
| 34 | /// |
| 35 | /// Returns an empty vector for an unconflicted revision rather than an error — |
| 36 | /// "is it conflicted" is answered by [`crate::Revision::conflicted`], and a |
| 37 | /// caller asking for the detail of a clean revision is asking a reasonable |
| 38 | /// question with a boring answer. |
| 39 | pub fn read(repo: &gix::Repository, rev: &RevId) -> Result<Vec<ConflictedFile>> { |
| 40 | let commit = super::convert::find_commit(repo, rev)?; |
| 41 | |
| 42 | let Some(trees) = df_index::extract_conflict_trees(commit.data.as_ref()) else { |
| 43 | return Ok(Vec::new()); |
| 44 | }; |
| 45 | |
| 46 | // Sides and bases interleaved back into the order jj records them, tagged so |
| 47 | // the viewer can label each column. The alternating order is what makes |
| 48 | // "side 1 / base 1 / side 2" read correctly in the UI. |
| 49 | let mut columns: Vec<(ConflictSide, String)> = Vec::new(); |
| 50 | let mut sides = trees.sides.into_iter(); |
| 51 | let mut bases = trees.bases.into_iter(); |
| 52 | let mut n = 0usize; |
| 53 | loop { |
| 54 | match sides.next() { |
| 55 | Some(s) => { |
| 56 | columns.push((ConflictSide::Side(n), s)); |
| 57 | n += 1; |
| 58 | } |
| 59 | None => break, |
| 60 | } |
| 61 | if let Some(b) = bases.next() { |
| 62 | columns.push((ConflictSide::Base(n - 1), b)); |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | // Load every tree once. A conflict with three sides walks three trees, not |
| 67 | // three trees per file. |
| 68 | let loaded: Vec<(ConflictSide, gix::Tree<'_>)> = columns |
| 69 | .iter() |
| 70 | .filter_map(|(label, id)| { |
| 71 | let oid = gix::ObjectId::from_hex(id.as_bytes()).ok()?; |
| 72 | let tree = repo.find_object(oid).ok()?.try_into_tree().ok()?; |
| 73 | Some((*label, tree)) |
| 74 | }) |
| 75 | .collect(); |
| 76 | |
| 77 | if loaded.len() != columns.len() { |
| 78 | // A tree named by the header is missing from the object database. That |
| 79 | // is real corruption, and inventing a partial conflict view from what |
| 80 | // remains would misrepresent it. |
| 81 | return Err(StoreError::Other(anyhow::anyhow!( |
| 82 | "conflicted revision {rev} names a tree that is not in the repository" |
| 83 | ))); |
| 84 | } |
| 85 | |
| 86 | // Every path present in any column, with the blob it resolves to there. |
| 87 | let mut per_column: Vec<std::collections::BTreeMap<String, gix::ObjectId>> = Vec::new(); |
| 88 | for (_, tree) in &loaded { |
| 89 | per_column.push(flatten(tree)?); |
| 90 | } |
| 91 | |
| 92 | let mut paths: BTreeSet<String> = BTreeSet::new(); |
| 93 | for m in &per_column { |
| 94 | paths.extend(m.keys().cloned()); |
| 95 | } |
| 96 | |
| 97 | let mut out = Vec::new(); |
| 98 | for path in paths { |
| 99 | // A path is in conflict only where the columns disagree. jj's conflict |
| 100 | // trees carry the *whole* tree, not just the conflicted files, so |
| 101 | // without this check every file in the repository would be reported. |
| 102 | let first = per_column[0].get(&path); |
| 103 | if per_column.iter().all(|m| m.get(&path) == first) { |
| 104 | continue; |
| 105 | } |
| 106 | |
| 107 | if out.len() >= MAX_CONFLICTED_FILES { |
| 108 | break; |
| 109 | } |
| 110 | |
| 111 | let mut sides = Vec::new(); |
| 112 | for (i, (label, _)) in loaded.iter().enumerate() { |
| 113 | let content = match per_column[i].get(&path) { |
| 114 | None => None, // this side deleted the file |
| 115 | Some(oid) => Some(read_blob_text(repo, *oid)), |
| 116 | }; |
| 117 | sides.push((*label, content)); |
| 118 | } |
| 119 | |
| 120 | out.push(ConflictedFile { path, sides }); |
| 121 | } |
| 122 | |
| 123 | Ok(out) |
| 124 | } |
| 125 | |
| 126 | /// Every blob in a tree, keyed by full path. |
| 127 | /// |
| 128 | /// Recursive, and deliberately skips jj's own conflict bookkeeping entries: |
| 129 | /// `.jjconflict-*` is storage detail and must never surface as a file (spec §4). |
| 130 | fn flatten(tree: &gix::Tree<'_>) -> Result<std::collections::BTreeMap<String, gix::ObjectId>> { |
| 131 | use gix::traverse::tree::Recorder; |
| 132 | |
| 133 | let mut recorder = Recorder::default(); |
| 134 | tree.traverse() |
| 135 | .breadthfirst(&mut recorder) |
| 136 | .map_err(|e| StoreError::Other(anyhow::anyhow!("walking conflict tree: {e}")))?; |
| 137 | |
| 138 | let mut out = std::collections::BTreeMap::new(); |
| 139 | for entry in recorder.records { |
| 140 | if !entry.mode.is_blob_or_symlink() { |
| 141 | continue; |
| 142 | } |
| 143 | let path = entry.filepath.to_string(); |
| 144 | if path |
| 145 | .split('/') |
| 146 | .any(df_index::is_conflict_artifact) |
| 147 | { |
| 148 | continue; |
| 149 | } |
| 150 | out.insert(path, entry.oid); |
| 151 | } |
| 152 | Ok(out) |
| 153 | } |
| 154 | |
| 155 | /// A side's content, as text. |
| 156 | /// |
| 157 | /// Binary and oversized sides come back as a description rather than bytes: the |
| 158 | /// conflict viewer is a text view, and pushing a megabyte of binary into a |
| 159 | /// `<pre>` helps nobody. |
| 160 | fn read_blob_text(repo: &gix::Repository, oid: gix::ObjectId) -> String { |
| 161 | let Ok(obj) = repo.find_object(oid) else { |
| 162 | return String::from("(unreadable)"); |
| 163 | }; |
| 164 | let data = obj.data.as_slice(); |
| 165 | |
| 166 | if data.len() > MAX_SIDE_BYTES { |
| 167 | return format!("({} bytes — too large to show)", data.len()); |
| 168 | } |
| 169 | match std::str::from_utf8(data) { |
| 170 | Ok(s) => s.to_owned(), |
| 171 | Err(_) => format!("(binary, {} bytes)", data.len()), |
| 172 | } |
| 173 | } |
173 lines · Rust