Jump to…
snowinitial commitqoxwzsukwmkx1mo
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
18use std::collections::BTreeSet;
19
20use 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.
27const 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.
31const 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.
39pub 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).
130fn 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.
160fn 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