OXYGENHUB
# d8c086ca3eb2 oxygen / crates / oxy-core / src / repo / snapshot.rs
49.0 KiB · Rust · last changed Partial commits: a chosen addition hunk carries the new node's content, so a new file is never committed empty 1 month ago source blame history raw
1
//! Working-copy snapshots, commits, and the change builder (spec §12.3).
2
3
use super::*;
4
use crate::objects::AtomRef;
5
6
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
7
pub enum ChangeStatus {
8
    Added,
9
    Modified,
10
    Removed,
11
    /// Moved from another path (detected by similarity, tracked as a placement change).
12
    Renamed,
13
    ModeChanged,
14
    /// The file is conflicted in the base state and still contains conflict markers.
15
    ConflictEdited,
16
    /// The file was conflicted in the base state and the working copy resolves it.
17
    Resolved,
18
}
19
20
/// Lines that mark a conflict region in a materialized file (spec §9.5).
21
pub fn has_conflict_markers(content: &[u8]) -> bool {
22
    split_lines(content).iter().any(|l| {
23
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
24
    })
25
}
26
27
#[derive(Clone, Debug, PartialEq, Eq)]
28
pub struct PathStatus {
29
    pub path: String,
30
    pub status: ChangeStatus,
31
}
32
33
/// Operations of a change built from the working directory against a base.
34
pub(crate) struct BuiltChange {
35
    pub tree_ops: Vec<TreeOp>,
36
    pub edits: Vec<Edit>,
37
    pub contributors: Vec<ChangeHash>,
38
    pub resolves: bool,
39
    pub statuses: Vec<PathStatus>,
40
    /// The stored blob of every opaque path, for the manifest's stat cache.
41
    pub blobs: HashMap<String, ObjectRef>,
42
}
43
44
/// Result of snapshotting the working copy.
45
pub struct SnapshotResult {
46
    pub wc_hash: ChangeHash,
47
    pub wc: Change,
48
    /// The base state (closure of the working-copy change's dependencies).
49
    pub base: State,
50
    pub base_snapshot: Snapshot,
51
    pub statuses: Vec<PathStatus>,
52
    /// Whether the snapshot rewrote the working-copy change.
53
    pub changed: bool,
54
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
55
    pub resolves: bool,
56
    /// Changes that contributed to the conflicts being resolved.
57
    pub contributors: Vec<ChangeHash>,
58
}
59
60
pub fn is_binary(content: &[u8]) -> bool {
61
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
62
}
63
64
impl Repo {
65
    /// Diff the files on disk against `base` and build the operations of a
66
    /// change from `base` to the working directory. `reuse` maps paths to the
67
    /// NodeIds an earlier version of the same change assigned to new paths.
68
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
69
        let fs_entries = self.walk_working_copy_with(None, &Self::tracked_kinds(base_snapshot))?;
70
        self.build_from_entries(base, base_snapshot, reuse, &fs_entries)
71
    }
72
73
    /// Like `build_against`, for files given in memory (imports).
74
    pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>, fs_entries: &BTreeMap<String, FsEntry>) -> Result<BuiltChange> {
75
        // Attributes come from the files being committed, so a new
76
        // `.oxyattributes` applies to the same change that adds it.
77
        let attrs = crate::attributes::Attributes::from_files(
78
            fs_entries.iter().filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes")).map(|(p, e)| (p.as_str(), e.content.as_slice())),
79
        );
80
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
81
        // Rename detection (spec §7.1, §23): a removed file and an added file
82
        // of the same kind whose contents are at least half alike become one
83
        // move of the same node, so identity survives across the rename.
84
        let renames = detect_renames(base_snapshot, fs_entries, |p| self.in_scope(p));
85
        let renamed_from: HashMap<&str, &str> = renames.iter().map(|(old, new)| (new.as_str(), old.as_str())).collect();
86
        let renamed_to: HashMap<&str, &str> = renames.iter().map(|(old, new)| (old.as_str(), new.as_str())).collect();
87
        for (path, fs_entry) in fs_entries {
88
            match base_snapshot.files.get(path) {
89
                None => match renamed_from.get(path.as_str()) {
90
                    Some(old) => b.move_path(self, old, path, fs_entry, &base_snapshot.files[*old])?,
91
                    None => b.add_path(self, path, fs_entry)?,
92
                },
93
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
94
            }
95
        }
96
        for (path, expected) in &base_snapshot.files {
97
            if fs_entries.contains_key(path) || !self.in_scope(path) || renamed_to.contains_key(path.as_str()) {
98
                continue;
99
            }
100
            // A directory stays while anything under it exists or is out of scope.
101
            let still_needed = expected.kind == NodeKind::Dir
102
                && (fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")))
103
                    || base_snapshot.files.keys().any(|p| p.starts_with(&format!("{path}/")) && !self.in_scope(p)));
104
            if !still_needed {
105
                b.remove_path(path, expected);
106
            }
107
        }
108
        let mut statuses = b.statuses;
109
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
110
        Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses, blobs: b.blobs })
111
    }
112
113
    /// Store a change and, when `core.sign = change` and we are its author,
114
    /// sign it. A rewritten change by another author is stored unsigned: only
115
    /// its author (or a delegate) can vouch for it.
116
    pub(crate) fn store_signed(&self, change: &Change) -> Result<ChangeHash> {
117
        let ours = change.author == self.author();
118
        if self.config.sign_changes && ours && self.secret_key()?.is_none() {
119
            return fail("core.sign is \"change\" but no secret key is available for author.key");
120
        }
121
        let hash = self.store.put_change(change)?;
122
        if self.config.sign_changes && ours {
123
            self.sign_change(&hash)?;
124
        }
125
        Ok(hash)
126
    }
127
128
    /// Snapshot the working copy into the working-copy change (spec §12.3).
129
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
130
        let _lock = self.lock_snapshot()?;
131
        let batch = self.store.begin_batch()?;
132
        let result = self.snapshot_locked(command);
133
        batch.finish()?;
134
        result
135
    }
136
137
    fn snapshot_locked(&self, command: &str) -> Result<SnapshotResult> {
138
        let (op_ref, op) = self.head_op()?;
139
        let (old_hash, old_wc) = self.working_copy()?;
140
        let base = self.load_state(&old_wc.deps)?;
141
        let base_snapshot = base.materialize();
142
143
        // Reuse the NodeIds the previous working-copy version assigned to new
144
        // paths, so an unchanged working copy hashes identically (spec §12.3).
145
        let reuse = added_node_paths(&old_wc, &base_snapshot);
146
        // The last manifest lets unchanged opaque files go unread.
147
        let manifest = self.read_manifest()?.map(|(_, _, m)| m);
148
        let fs_entries = self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?;
149
        let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?;
150
151
        let mut wc = Change {
152
            change_id: old_wc.change_id,
153
            deps: vec![],
154
            author: self.author(),
155
            hlc: old_wc.hlc,
156
            message: old_wc.message.clone(),
157
            tree_ops: built.tree_ops,
158
            edits: built.edits,
159
            kind: ChangeKind::WorkingCopy,
160
        };
161
        let base_heads = base.maximal(&old_wc.deps);
162
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
163
        deps.extend(referenced_changes(&wc, &base));
164
        deps.extend(built.contributors.iter().copied());
165
        wc.deps = deps.into_iter().collect();
166
        wc.normalize();
167
        let resolves = built.resolves;
168
        let contributors = built.contributors;
169
        let statuses = built.statuses;
170
        // Empty snapshot: hash the candidate before storing; only record an op on change.
171
        let new_hash = wc.hash();
172
        let changed = new_hash != old_hash;
173
        if changed {
174
            self.store.put_change(&wc)?;
175
            let mut view = op.view.clone();
176
            view.workspaces.insert(self.workspace, new_hash);
177
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
178
        }
179
        // The prompt compares against this without recording anything.
180
        self.write_manifest_entries(base_snapshot.is_conflicted(), !wc.tree_ops.is_empty() || !wc.edits.is_empty(), &fs_entries, &built.blobs)?;
181
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
182
    }
183
184
    /// `oxy status --json` (spec §9.5): the changed paths and every conflict
185
    /// of the working copy's base with its kind, path, and contributing
186
    /// ChangeIds.
187
    pub fn status_json(&self, snap: &SnapshotResult) -> Result<String> {
188
        let heads = self.bookmark_heads()?;
189
        let base_snapshot = &snap.base_snapshot;
190
        let by_node = base_snapshot.tree.by_node();
191
        let path_of = |n: &NodeId| by_node.get(n).map(|e| e.path.clone());
192
        let id_of = |h: &ChangeHash| snap.base.get(h).map(|m| m.change_id.to_hex()).unwrap_or_else(|| h.to_hex());
193
        let paths = json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))));
194
        let mut conflicts: Vec<String> = Vec::new();
195
        for (p, f) in &base_snapshot.files {
196
            if !f.is_conflicted() {
197
                continue;
198
            }
199
            let (kind, changes) = match &f.content {
200
                FileContent::Text { .. } => ("text", snap.base.conflict_regions(&f.node).contributors),
201
                FileContent::Opaque { blobs } => ("replace", snap.base.blob_contributors(&f.node, blobs)),
202
                FileContent::Dir => continue,
203
            };
204
            let resolved = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
205
                Some(ChangeStatus::Resolved) => "true",
206
                Some(ChangeStatus::ConflictEdited) => "\"markers\"",
207
                _ => "false",
208
            };
209
            conflicts.push(format!(
210
                "{{\"kind\":{},\"path\":{},\"node\":{},\"changes\":{},\"resolved\":{resolved}}}",
211
                json_str(kind),
212
                json_str(p),
213
                json_str(&f.node.to_hex()),
214
                json_array(changes.iter().map(|h| json_str(&id_of(h))))
215
            ));
216
        }
217
        for c in &base_snapshot.tree.conflicts {
218
            let (kind, nodes): (&str, Vec<NodeId>) = match c {
219
                crate::tree::TreeConflict::Name { nodes, .. } => ("name", nodes.clone()),
220
                crate::tree::TreeConflict::MultiPlacement { node, .. } => ("multi-placement", vec![*node]),
221
                crate::tree::TreeConflict::Orphan { node, .. } => ("orphan", vec![*node]),
222
                crate::tree::TreeConflict::Cycle { node } => ("cycle", vec![*node]),
223
                crate::tree::TreeConflict::Mode { node, .. } => ("mode", vec![*node]),
224
            };
225
            let detail = match c {
226
                crate::tree::TreeConflict::Name { name, parent, .. } => {
227
                    let dir = if *parent == NodeId::ROOT { String::new() } else { path_of(parent).unwrap_or_default() };
228
                    format!(",\"path\":{}", json_str(&if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") }))
229
                }
230
                _ => nodes.first().and_then(path_of).map(|p| format!(",\"path\":{}", json_str(&p))).unwrap_or_default(),
231
            };
232
            conflicts.push(format!(
233
                "{{\"kind\":{},\"nodes\":{}{detail}}}",
234
                json_str(kind),
235
                json_array(nodes.iter().map(|n| json_str(&n.to_hex())))
236
            ));
237
        }
238
        Ok(format!(
239
            "{{\"bookmark\":{},\"working_copy\":{},\"heads\":{},\"clean\":{},\"conflicted\":{},\"paths\":{paths},\"conflicts\":{}}}",
240
            json_str(&self.config.bookmark),
241
            json_str(&snap.wc.change_id.to_hex()),
242
            heads.len(),
243
            snap.statuses.is_empty(),
244
            base_snapshot.is_conflicted(),
245
            json_array(conflicts)
246
        ))
247
    }
248
249
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
250
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
251
        self.commit_selection(message, extra_deps, &Selection::All)
252
    }
253
254
    /// The hunks of the working copy, for `oxy commit --interactive`: each
255
    /// tree operation and each edit, in the order `select_hunks` numbers them.
256
    pub fn hunks(&self) -> Result<(SnapshotResult, Vec<Hunk>)> {
257
        let snap = self.snapshot("oxy commit")?;
258
        let hunks = render_hunks(&snap.wc, &snap.base, &snap.base_snapshot);
259
        Ok((snap, hunks))
260
    }
261
262
    /// Commit part of the working copy (spec §12.3). What is not selected
263
    /// stays on disk as uncommitted work: the next snapshot rebuilds it
264
    /// against the new base, so the working copy always describes the
265
    /// directory.
266
    pub fn commit_selection(&self, message: &str, extra_deps: &[ChangeHash], selection: &Selection) -> Result<ChangeHash> {
267
        let snap = self.snapshot("oxy commit")?;
268
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
269
            return fail("nothing to commit: the working copy matches its base");
270
        }
271
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
272
            return fail(format!("{} still contains conflict markers", s.path));
273
        }
274
        if message.trim().is_empty() {
275
            return fail("a commit message is required");
276
        }
277
        let (tree_ops, edits, partial) = match selection {
278
            Selection::All => (snap.wc.tree_ops.clone(), snap.wc.edits.clone(), false),
279
            Selection::Paths(paths) => {
280
                let (o, e) = select_paths(&snap.wc, &snap.base_snapshot, paths);
281
                let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len();
282
                (o, e, partial)
283
            }
284
            Selection::Hunks(indices) => {
285
                let (o, e) = select_hunks(&snap.wc, indices);
286
                let partial = o.len() != snap.wc.tree_ops.len() || e.len() != snap.wc.edits.len();
287
                (o, e, partial)
288
            }
289
        };
290
        if tree_ops.is_empty() && edits.is_empty() {
291
            return fail("nothing selected to commit");
292
        }
293
        if partial && snap.resolves {
294
            return fail("a conflict resolution must be committed whole");
295
        }
296
        // pre-commit hook: may veto.
297
        self.run_hook(
298
            "pre-commit",
299
            &format!(
300
                "{{\"event\":\"pre-commit\",\"message\":{},\"paths\":{}}}",
301
                json_str(message),
302
                json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))))
303
            ),
304
        )?;
305
        let (op_ref, op) = self.head_op()?;
306
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
307
        // semantic-deps hook: may add explicit dependencies (spec §8.1).
308
        if self.config.semantic_deps {
309
            let touched: Vec<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
310
            let by_node = snap.base_snapshot.tree.by_node();
311
            let mut candidates: Vec<(ChangeId, ChangeHash, Vec<String>)> = Vec::new();
312
            let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
313
            for h in deps.iter().chain(snap.base.maximal(&snap.wc.deps).iter()) {
314
                if !seen.insert(*h) {
315
                    continue;
316
                }
317
                let c = self.store.get_change(h)?;
318
                let mut paths: BTreeSet<String> = BTreeSet::new();
319
                for n in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|e| e.node())) {
320
                    if let Some(e) = by_node.get(&n) {
321
                        paths.insert(e.path.clone());
322
                    }
323
                }
324
                candidates.push((c.change_id, *h, paths.into_iter().collect()));
325
            }
326
            for d in self.semantic_deps(&snap.wc.change_id, &touched, &candidates)? {
327
                if snap.base.contains(&d) {
328
                    deps.insert(d);
329
                }
330
            }
331
        }
332
        // A resolution depends on every change that contributed to the
333
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
334
        // ordinary operations whose references already cover the contributors.
335
        deps.extend(snap.contributors.iter().copied());
336
        for d in extra_deps {
337
            if !snap.base.contains(d) {
338
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
339
            }
340
            deps.insert(*d);
341
        }
342
        let mut change = Change {
343
            change_id: if partial { ChangeId::random() } else { snap.wc.change_id },
344
            deps: vec![],
345
            author: self.author(),
346
            hlc: Hlc::now(),
347
            message: message.trim_end().to_string(),
348
            tree_ops,
349
            edits,
350
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
351
        };
352
        if partial {
353
            // Dependencies come from what the selected part references, not
354
            // from the whole working copy.
355
            deps = referenced_changes(&change, &snap.base);
356
            for d in extra_deps {
357
                deps.insert(*d);
358
            }
359
        }
360
        change.deps = deps.into_iter().collect();
361
        change.normalize();
362
        let hash = self.store_signed(&change)?;
363
364
        // New bookmark heads: maximal elements of old heads plus the new change.
365
        // The working copy continues on its own base plus the new change, which
366
        // is exactly what the files on disk contain; the bookmark may hold
367
        // other heads (and conflicts) the working copy has not checked out.
368
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
369
        let old_base = snap.base.maximal(&snap.wc.deps);
370
        let mut all: Vec<ChangeHash> = old_heads.clone();
371
        all.extend(old_base.iter().copied());
372
        all.push(hash);
373
        let mut st = self.load_state(&all)?;
374
        st.add(change)?;
375
        let mut with_new = old_heads.clone();
376
        with_new.push(hash);
377
        let new_heads = st.maximal(&with_new);
378
        // Automatic checkpoint once enough changes accumulated (spec §11).
379
        let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0);
380
        if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval {
381
            let cp_state = self.load_state_full(&new_heads)?;
382
            self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?;
383
        }
384
        let mut wc_base = old_base.clone();
385
        wc_base.push(hash);
386
        let wc_base = st.maximal(&wc_base);
387
388
        let wc = self.new_working_copy(wc_base, "");
389
        let wc_hash = self.store.put_change(&wc)?;
390
        let mut view = op.view.clone();
391
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
392
        view.workspaces.insert(self.workspace, wc_hash);
393
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
394
        if partial {
395
            // The rest is rebuilt from disk by the next snapshot.
396
            self.drop_manifest();
397
        } else {
398
            self.rewrite_manifest_flags(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
399
        }
400
        self.maybe_autopack()?;
401
        let _ = self.run_hook("post-commit", &format!("{{\"event\":\"post-commit\",\"change_id\":{},\"hash\":{},\"message\":{}}}", json_str(&snap.wc.change_id.to_hex()), json_str(&hash.to_hex()), json_str(message)));
402
        Ok(hash)
403
    }
404
}
405
406
/// What part of the working copy a commit takes (spec §12.3).
407
#[derive(Clone, Debug)]
408
pub enum Selection {
409
    All,
410
    /// Every operation and edit on nodes under these paths.
411
    Paths(Vec<String>),
412
    /// Hunk indices as `Repo::hunks` numbers them; dependent hunks are added.
413
    Hunks(Vec<usize>),
414
}
415
416
/// One selectable piece of the working copy: a tree operation or an edit.
417
#[derive(Clone, Debug)]
418
pub struct Hunk {
419
    pub index: usize,
420
    pub path: String,
421
    /// Rendered like `oxy diff`.
422
    pub text: String,
423
}
424
425
/// The tree operations and edits on nodes under `paths`, plus the
426
/// additions of any new parent directories they need.
427
fn select_paths(wc: &Change, base_snapshot: &Snapshot, paths: &[String]) -> (Vec<TreeOp>, Vec<Edit>) {
428
    let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
429
    let by_node = base_snapshot.tree.by_node();
430
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
431
    for op in &wc.tree_ops {
432
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
433
            new_names.insert(*node, (*parent, name.clone(), *kind));
434
        }
435
    }
436
    let mut nodes: BTreeSet<NodeId> = BTreeSet::new();
437
    for n in wc.tree_ops.iter().map(|o| o.node()).chain(wc.edits.iter().map(|e| e.node())) {
438
        if path_of_node(&n, &by_node, &new_names).is_some_and(|p| selected(&p)) {
439
            nodes.insert(n);
440
        }
441
    }
442
    // New parents of selected new nodes come along.
443
    let mut changed = true;
444
    while changed {
445
        changed = false;
446
        for (n, (parent, _, _)) in &new_names {
447
            if nodes.contains(n) && new_names.contains_key(parent) && nodes.insert(*parent) {
448
                changed = true;
449
            }
450
        }
451
    }
452
    let ops = wc.tree_ops.iter().filter(|o| nodes.contains(&o.node())).cloned().collect();
453
    let edits = wc.edits.iter().filter(|e| nodes.contains(&e.node())).cloned().collect();
454
    (ops, edits)
455
}
456
457
/// The hunks `indices` name plus whatever they refer to within the working
458
/// copy (the parent directory of an added node, the insert that introduced
459
/// an anchor line), renumbered so the result is a well-formed change.
460
fn select_hunks(wc: &Change, indices: &[usize]) -> (Vec<TreeOp>, Vec<Edit>) {
461
    let n_ops = wc.tree_ops.len();
462
    let mut ops_sel = vec![false; n_ops];
463
    let mut edits_sel = vec![false; wc.edits.len()];
464
    for &i in indices {
465
        if i < n_ops {
466
            ops_sel[i] = true;
467
        } else if i - n_ops < wc.edits.len() {
468
            edits_sel[i - n_ops] = true;
469
        }
470
    }
471
    // Atom ranges each insert introduces, per node, in the working copy's numbering.
472
    let mut per_node: HashMap<NodeId, u32> = HashMap::new();
473
    let mut insert_ranges: Vec<Option<(NodeId, u32, u32)>> = Vec::new();
474
    for e in &wc.edits {
475
        insert_ranges.push(match e {
476
            Edit::Insert { node, lines, .. } => {
477
                let start = per_node.entry(*node).or_default();
478
                let r = (*node, *start, *start + lines.len() as u32);
479
                *start += lines.len() as u32;
480
                Some(r)
481
            }
482
            _ => None,
483
        });
484
    }
485
    let add_index: HashMap<NodeId, usize> = wc.tree_ops.iter().enumerate().filter_map(|(i, o)| if let TreeOp::AddNode { node, .. } = o { Some((*node, i)) } else { None }).collect();
486
    let insert_for = |node: &NodeId, index: u32| -> Option<usize> { insert_ranges.iter().position(|r| matches!(r, Some((n, a, b)) if n == node && *a <= index && index < *b)) };
487
    // Closure.
488
    let mut changed = true;
489
    while changed {
490
        changed = false;
491
        for i in 0..n_ops {
492
            if !ops_sel[i] {
493
                continue;
494
            }
495
            if let TreeOp::AddNode { node, parent, .. } = &wc.tree_ops[i] {
496
                if let Some(&p) = add_index.get(parent) {
497
                    if !ops_sel[p] {
498
                        ops_sel[p] = true;
499
                        changed = true;
500
                    }
501
                }
502
                // A new node's content belongs with its addition: nobody
503
                // wants an empty file committed apart from its lines.
504
                for (j, e) in wc.edits.iter().enumerate() {
505
                    if e.node() == *node && !edits_sel[j] {
506
                        edits_sel[j] = true;
507
                        changed = true;
508
                    }
509
                }
510
            }
511
        }
512
        for j in 0..wc.edits.len() {
513
            if !edits_sel[j] {
514
                continue;
515
            }
516
            let e = &wc.edits[j];
517
            if let Some(&a) = add_index.get(&e.node()) {
518
                if !ops_sel[a] {
519
                    ops_sel[a] = true;
520
                    changed = true;
521
                }
522
            }
523
            if let Edit::Insert { node, after, before, .. } = e {
524
                for r in [after, before] {
525
                    if let AtomRef::Atom { change, index } = r {
526
                        if change.is_zero() {
527
                            if let Some(k) = insert_for(node, *index) {
528
                                if !edits_sel[k] {
529
                                    edits_sel[k] = true;
530
                                    changed = true;
531
                                }
532
                            }
533
                        }
534
                    }
535
                }
536
            }
537
        }
538
    }
539
    // Renumber self-references: old atom index → new index among selected inserts.
540
    let mut remap: HashMap<(NodeId, u32), u32> = HashMap::new();
541
    let mut new_counts: HashMap<NodeId, u32> = HashMap::new();
542
    for (j, r) in insert_ranges.iter().enumerate() {
543
        if let (true, Some((node, a, b))) = (edits_sel[j], r) {
544
            let next = new_counts.entry(*node).or_default();
545
            for old in *a..*b {
546
                remap.insert((*node, old), *next);
547
                *next += 1;
548
            }
549
        }
550
    }
551
    let map_ref = |r: AtomRef, node: &NodeId| -> AtomRef {
552
        match r {
553
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change, index: remap.get(&(*node, index)).copied().unwrap_or(index) },
554
            other => other,
555
        }
556
    };
557
    let ops = wc.tree_ops.iter().enumerate().filter(|(i, _)| ops_sel[*i]).map(|(_, o)| o.clone()).collect();
558
    let edits = wc
559
        .edits
560
        .iter()
561
        .enumerate()
562
        .filter(|(j, _)| edits_sel[*j])
563
        .map(|(_, e)| match e {
564
            Edit::Insert { node, after, before, lines } => Edit::Insert { node: *node, after: map_ref(*after, node), before: map_ref(*before, node), lines: lines.clone() },
565
            other => other.clone(),
566
        })
567
        .collect();
568
    (ops, edits)
569
}
570
571
/// Render each tree operation and edit of `change` on its own, numbered.
572
pub fn render_hunks(change: &Change, base: &State, base_snapshot: &Snapshot) -> Vec<Hunk> {
573
    let by_node = base_snapshot.tree.by_node();
574
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
575
    for op in &change.tree_ops {
576
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
577
            new_names.insert(*node, (*parent, name.clone(), *kind));
578
        }
579
    }
580
    let mut out = Vec::new();
581
    for (i, op) in change.tree_ops.iter().enumerate() {
582
        let one = Change { tree_ops: vec![op.clone()], edits: vec![], ..change.clone() };
583
        let path = path_of_node(&op.node(), &by_node, &new_names).unwrap_or_default();
584
        out.push(Hunk { index: i, path, text: render_change(&one, base, base_snapshot) });
585
    }
586
    for (j, e) in change.edits.iter().enumerate() {
587
        let one = Change { tree_ops: vec![], edits: vec![e.clone()], ..change.clone() };
588
        let path = path_of_node(&e.node(), &by_node, &new_names).unwrap_or_default();
589
        out.push(Hunk { index: change.tree_ops.len() + j, path, text: render_change(&one, base, base_snapshot) });
590
    }
591
    out
592
}
593
594
/// Builds the tree ops and edits of a working-copy change against a base.
595
struct ChangeBuilder<'a> {
596
    base: &'a State,
597
    base_snapshot: &'a Snapshot,
598
    tree_ops: Vec<TreeOp>,
599
    edits: Vec<Edit>,
600
    statuses: Vec<PathStatus>,
601
    dir_nodes: HashMap<String, NodeId>,
602
    reuse: HashMap<String, (NodeId, NodeKind)>,
603
    /// Changes that contributed to conflicts this working copy resolves.
604
    contributors: BTreeSet<ChangeHash>,
605
    resolves: bool,
606
    attrs: crate::attributes::Attributes,
607
    blobs: HashMap<String, ObjectRef>,
608
}
609
610
impl<'a> ChangeBuilder<'a> {
611
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
612
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
613
        dir_nodes.insert(String::new(), NodeId::ROOT);
614
        for (p, f) in &base_snapshot.files {
615
            if f.kind == NodeKind::Dir {
616
                dir_nodes.insert(p.clone(), f.node);
617
            }
618
        }
619
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false, attrs: crate::attributes::Attributes::default(), blobs: HashMap::new() }
620
    }
621
622
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
623
        self.attrs = attrs;
624
        self
625
    }
626
627
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
628
    /// only when it has no NUL bytes, else sniffed.
629
    /// The blob for an opaque entry: the cached one, else stored now.
630
    fn blob_of(&mut self, repo: &Repo, path: &str, e: &FsEntry, nocompress: bool) -> Result<ObjectRef> {
631
        let b = match e.blob {
632
            Some(b) => b,
633
            None => repo.store_blob_for(&e.content, nocompress)?,
634
        };
635
        self.blobs.insert(path.to_string(), b);
636
        Ok(b)
637
    }
638
639
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
640
        if e.blob.is_some() {
641
            return true;
642
        }
643
        if e.kind == NodeKind::Symlink || e.kind == NodeKind::Subrepo {
644
            return true;
645
        }
646
        match self.attrs.lookup(path).binary {
647
            Some(true) => true,
648
            Some(false) => is_binary(&e.content),
649
            None => is_binary(&e.content),
650
        }
651
    }
652
653
    /// Text content as stored: `\r\n` normalized for `eol=` files.
654
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
655
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
656
    }
657
658
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
659
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
660
    }
661
662
    fn ensure_dir(&mut self, path: &str) -> NodeId {
663
        if let Some(n) = self.dir_nodes.get(path) {
664
            return *n;
665
        }
666
        let mut parent = NodeId::ROOT;
667
        let mut prefix = String::new();
668
        for comp in path.split('/') {
669
            if !prefix.is_empty() {
670
                prefix.push('/');
671
            }
672
            prefix.push_str(comp);
673
            parent = match self.dir_nodes.get(&prefix) {
674
                Some(n) => *n,
675
                None => {
676
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
677
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
678
                    self.dir_nodes.insert(prefix.clone(), node);
679
                    node
680
                }
681
            };
682
        }
683
        parent
684
    }
685
686
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
687
        let (dir, name) = split_path(path);
688
        let parent = self.ensure_dir(dir);
689
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
690
        let opaque = self.opaque_for(path, e);
691
        let nocompress = self.attrs.lookup(path).nocompress;
692
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
693
        if opaque {
694
            let blob = self.blob_of(repo, path, e, nocompress)?;
695
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
696
        } else if !e.content.is_empty() {
697
            let content = self.stored_text(path, &e.content);
698
            self.edits.push(Edit::Insert {
699
                node,
700
                after: crate::objects::AtomRef::Start,
701
                before: crate::objects::AtomRef::End,
702
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
703
            });
704
        }
705
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
706
        Ok(())
707
    }
708
709
    /// Move the node at `old` to `new` and apply `e`'s content to it.
710
    fn move_path(&mut self, repo: &Repo, old: &str, new: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
711
        let entry = &self.base_snapshot.tree.entries[old];
712
        let (dir, name) = split_path(new);
713
        let parent = self.ensure_dir(dir);
714
        self.tree_ops.push(TreeOp::Move { node: expected.node, old: op_ref(entry.placement), parent, name: name.to_string() });
715
        self.update_path(repo, old, e, expected)?;
716
        // update_path reports under the old path; the rename is what the user sees.
717
        self.statuses.retain(|s| s.path != old);
718
        self.statuses.push(PathStatus { path: new.to_string(), status: ChangeStatus::Renamed });
719
        Ok(())
720
    }
721
722
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
723
        let entry = &self.base_snapshot.tree.entries[path];
724
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
725
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
726
            self.resolves = true;
727
        }
728
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
729
    }
730
731
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
732
        if expected.kind != e.kind {
733
            self.remove_path(path, expected);
734
            self.statuses.pop();
735
            self.add_path(repo, path, e)?;
736
            self.statuses.pop();
737
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
738
            return Ok(());
739
        }
740
        let entry = &self.base_snapshot.tree.entries[path];
741
        let node = expected.node;
742
        let mode_atom = entry.mode_atom.map(op_ref);
743
        let mut status: Option<ChangeStatus> = None;
744
        let wants_opaque = self.opaque_for(path, e);
745
        let nocompress = self.attrs.lookup(path).nocompress;
746
        let disk_text = self.stored_text(path, &e.content);
747
        match &expected.content {
748
            FileContent::Dir => {}
749
            FileContent::Text { bytes, materialized } => {
750
                if materialized.is_conflicted() {
751
                    if bytes == &disk_text {
752
                        return Ok(());
753
                    }
754
                    if has_conflict_markers(&e.content) {
755
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
756
                        return Ok(());
757
                    }
758
                    // Resolution (spec §9.6): diff against the unconflicted lines
759
                    // with each region as a gap, then retire every region atom.
760
                    // Whatever the author kept from a region is re-inserted as
761
                    // new atoms, which leaves the graph totally ordered.
762
                    let regions = self.base.conflict_regions(&node);
763
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
764
                    if !regions.region_atoms.is_empty() {
765
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
766
                    }
767
                    self.edits.extend(edits);
768
                    self.contributors.extend(regions.contributors);
769
                    self.resolves = true;
770
                    if e.exec != expected.mode.exec {
771
                        if let Some(old) = mode_atom {
772
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
773
                        }
774
                    }
775
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
776
                    return Ok(());
777
                }
778
                if wants_opaque {
779
                    // Text → opaque (spec §7.5), by content or by attribute.
780
                    if let Some(old) = mode_atom {
781
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
782
                    }
783
                    let live: Vec<_> = repo_live_keys(self.base, &node);
784
                    if !live.is_empty() {
785
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
786
                    }
787
                    let blob = self.blob_of(repo, path, e, nocompress)?;
788
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
789
                    status = Some(ChangeStatus::Modified);
790
                } else if bytes != &disk_text {
791
                    let old = self.base.live_lines(&node);
792
                    let edits = text_edits(node, &old, &disk_text);
793
                    if !edits.is_empty() {
794
                        self.edits.extend(edits);
795
                        status = Some(ChangeStatus::Modified);
796
                    }
797
                }
798
                let switched_to_opaque = wants_opaque;
799
                if !switched_to_opaque && e.exec != expected.mode.exec {
800
                    if let Some(old) = mode_atom {
801
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
802
                        status.get_or_insert(ChangeStatus::ModeChanged);
803
                    }
804
                }
805
            }
806
            FileContent::Opaque { blobs } => {
807
                let new_ref = e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content));
808
                if e.kind == NodeKind::File && wants_opaque {
809
                    // Unchanged or replaced, the file stays opaque: remember its blob.
810
                    self.blobs.insert(path.to_string(), new_ref);
811
                }
812
                if blobs.len() > 1 {
813
                    if !blobs.contains(&new_ref) {
814
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
815
                    }
816
                    return Ok(());
817
                }
818
                let current = blobs.first().copied();
819
                if current != Some(new_ref) {
820
                    if e.kind == NodeKind::File && !wants_opaque {
821
                        // Opaque → text (spec §7.5).
822
                        if let Some(old) = mode_atom {
823
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
824
                        }
825
                        self.edits.push(Edit::Replace { node, old: current, new: None });
826
                        if !e.content.is_empty() {
827
                            self.edits.push(Edit::Insert {
828
                                node,
829
                                after: crate::objects::AtomRef::Start,
830
                                before: crate::objects::AtomRef::End,
831
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
832
                            });
833
                        }
834
                    } else {
835
                        let blob = self.blob_of(repo, path, e, nocompress)?;
836
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
837
                    }
838
                    status = Some(ChangeStatus::Modified);
839
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
840
                    if let Some(old) = mode_atom {
841
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
842
                        status = Some(ChangeStatus::ModeChanged);
843
                    }
844
                }
845
            }
846
        }
847
        if let Some(s) = status {
848
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
849
        }
850
        Ok(())
851
    }
852
}
853
854
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
855
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
856
}
857
858
/// The changes a change's scope references (spec §8.1): introducers of every
859
/// atom, placement, and mode it touches, and of every node it operates on.
860
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
861
    let mut out = BTreeSet::new();
862
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
863
        if !r.change.is_zero() {
864
            out.insert(r.change);
865
        }
866
    };
867
    let mut nodes: Vec<NodeId> = Vec::new();
868
    for op in &change.tree_ops {
869
        match op {
870
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
871
            TreeOp::RemoveNode { node, placement } => {
872
                nodes.push(*node);
873
                opref(placement, &mut out);
874
            }
875
            TreeOp::Move { node, old, parent, .. } => {
876
                nodes.push(*node);
877
                nodes.push(*parent);
878
                opref(old, &mut out);
879
            }
880
            TreeOp::SetMode { node, old, .. } => {
881
                nodes.push(*node);
882
                opref(old, &mut out);
883
            }
884
        }
885
    }
886
    for e in &change.edits {
887
        nodes.push(e.node());
888
        match e {
889
            Edit::Insert { after, before, .. } => {
890
                for r in [after, before] {
891
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
892
                        if !change.is_zero() {
893
                            out.insert(*change);
894
                        }
895
                    }
896
                }
897
            }
898
            Edit::Delete { atoms, .. } => {
899
                for r in atoms {
900
                    if !r.change.is_zero() {
901
                        out.insert(r.change);
902
                    }
903
                }
904
            }
905
            Edit::Replace { .. } => {}
906
        }
907
    }
908
    for n in nodes {
909
        if let Some(h) = base.tree().introducer(&n) {
910
            out.insert(h);
911
        }
912
    }
913
    out
914
}
915
916
/// Paths of the nodes a change adds on top of `base_snapshot`.
917
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
918
    let by_node = base_snapshot.tree.by_node();
919
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
920
    for op in &change.tree_ops {
921
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
922
            new_names.insert(*node, (*parent, name.clone(), *kind));
923
        }
924
    }
925
    let mut out = HashMap::new();
926
    for (node, (_, _, kind)) in &new_names {
927
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
928
            out.insert(path, (*node, *kind));
929
        }
930
    }
931
    out
932
}
933
934
fn path_of_node(
935
    n: &NodeId,
936
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
937
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
938
) -> Option<String> {
939
    if let Some(e) = by_node.get(n) {
940
        return Some(e.path.clone());
941
    }
942
    let mut parts = Vec::new();
943
    let mut cur = *n;
944
    loop {
945
        if cur.is_root() {
946
            break;
947
        }
948
        if let Some(e) = by_node.get(&cur) {
949
            if !e.path.is_empty() {
950
                parts.push(e.path.clone());
951
            }
952
            break;
953
        }
954
        let (parent, name, _) = new_names.get(&cur)?;
955
        parts.push(name.clone());
956
        cur = *parent;
957
    }
958
    parts.reverse();
959
    Some(parts.join("/"))
960
}
961
962
/// Render a change's operations with line content, using `base` for deleted lines.
963
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
964
    let by_node = base_snapshot.tree.by_node();
965
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
966
    for op in &change.tree_ops {
967
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
968
            new_names.insert(*node, (*parent, name.clone(), *kind));
969
        }
970
    }
971
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
972
973
    let mut out = String::new();
974
    for op in &change.tree_ops {
975
        match op {
976
            TreeOp::AddNode { node, kind, mode, .. } => {
977
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
978
            }
979
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
980
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
981
            TreeOp::SetMode { node, mode, .. } => {
982
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
983
            }
984
        }
985
    }
986
    let mut last_node: Option<NodeId> = None;
987
    for e in &change.edits {
988
        let n = e.node();
989
        if last_node != Some(n) {
990
            out.push_str(&format!("--- {}\n", path_of(&n)));
991
            last_node = Some(n);
992
        }
993
        match e {
994
            Edit::Insert { lines, .. } => {
995
                for l in lines {
996
                    out.push('+');
997
                    out.push_str(&String::from_utf8_lossy(l));
998
                    if !l.ends_with(b"\n") {
999
                        out.push('\n');
1000
                    }
1001
                }
1002
            }
1003
            Edit::Delete { atoms, .. } => {
1004
                for r in atoms {
1005
                    for i in r.from..r.to {
1006
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
1007
                        out.push('-');
1008
                        out.push_str(&String::from_utf8_lossy(content));
1009
                        if !content.ends_with(b"\n") {
1010
                            out.push('\n');
1011
                        }
1012
                    }
1013
                }
1014
            }
1015
            Edit::Replace { old, new, .. } => {
1016
                out.push_str(&format!(
1017
                    "  opaque content {} -> {}\n",
1018
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
1019
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
1020
                ));
1021
            }
1022
        }
1023
    }
1024
    out
1025
}
1026
1027
/// Pair removed files with added files of the same kind whose contents are
1028
/// at least `RENAME_THRESHOLD` percent alike, best matches first. Beyond
1029
/// `RENAME_LIMIT` candidate pairs only identical contents are paired.
1030
const RENAME_THRESHOLD: u32 = 50;
1031
const RENAME_LIMIT: usize = 10_000;
1032
1033
fn detect_renames(base_snapshot: &Snapshot, fs_entries: &BTreeMap<String, FsEntry>, in_scope: impl Fn(&str) -> bool) -> Vec<(String, String)> {
1034
    let removed: Vec<(&String, &crate::state::FileState)> = base_snapshot
1035
        .files
1036
        .iter()
1037
        .filter(|(p, f)| f.kind != NodeKind::Dir && !fs_entries.contains_key(*p) && in_scope(p) && !f.is_conflicted())
1038
        .collect();
1039
    let added: Vec<(&String, &FsEntry)> = fs_entries.iter().filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p)).collect();
1040
    if removed.is_empty() || added.is_empty() {
1041
        return vec![];
1042
    }
1043
    let exact_only = removed.len() * added.len() > RENAME_LIMIT;
1044
    let old_content = |f: &crate::state::FileState| -> Option<Vec<u8>> {
1045
        match &f.content {
1046
            FileContent::Text { bytes, .. } => Some(bytes.clone()),
1047
            _ => None,
1048
        }
1049
    };
1050
    let mut scored: Vec<(u32, usize, usize)> = Vec::new();
1051
    for (ri, (_, f)) in removed.iter().enumerate() {
1052
        let old = old_content(f);
1053
        let old_blob = match &f.content {
1054
            FileContent::Opaque { blobs } if blobs.len() == 1 => Some(blobs[0]),
1055
            _ => None,
1056
        };
1057
        for (ai, (_, e)) in added.iter().enumerate() {
1058
            if e.kind != f.kind {
1059
                continue;
1060
            }
1061
            let score = match (&old, old_blob) {
1062
                (Some(o), _) => {
1063
                    if exact_only {
1064
                        if *o == e.content { 100 } else { 0 }
1065
                    } else {
1066
                        similarity(o, &e.content)
1067
                    }
1068
                }
1069
                (None, Some(b)) if e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content)) == b => 100,
1070
                _ => 0,
1071
            };
1072
            if score >= RENAME_THRESHOLD {
1073
                scored.push((score, ri, ai));
1074
            }
1075
        }
1076
    }
1077
    scored.sort_by(|a, b| b.0.cmp(&a.0).then(removed[a.1].0.cmp(removed[b.1].0)).then(added[a.2].0.cmp(added[b.2].0)));
1078
    let mut used_r = vec![false; removed.len()];
1079
    let mut used_a = vec![false; added.len()];
1080
    let mut out = Vec::new();
1081
    for (_, ri, ai) in scored {
1082
        if used_r[ri] || used_a[ai] {
1083
            continue;
1084
        }
1085
        used_r[ri] = true;
1086
        used_a[ai] = true;
1087
        out.push((removed[ri].0.clone(), added[ai].0.clone()));
1088
    }
1089
    out
1090
}
1091
1092
/// Percentage of the larger file's bytes found in lines the two share
1093
/// (multiset intersection of lines), like Git's similarity index.
1094
pub fn similarity(a: &[u8], b: &[u8]) -> u32 {
1095
    if a == b {
1096
        return 100;
1097
    }
1098
    if a.is_empty() || b.is_empty() {
1099
        return 0;
1100
    }
1101
    let mut counts: HashMap<&[u8], usize> = HashMap::new();
1102
    for l in split_lines(a) {
1103
        *counts.entry(l).or_default() += 1;
1104
    }
1105
    let mut shared = 0usize;
1106
    for l in split_lines(b) {
1107
        if let Some(n) = counts.get_mut(l) {
1108
            if *n > 0 {
1109
                *n -= 1;
1110
                shared += l.len();
1111
            }
1112
        }
1113
    }
1114
    (shared * 100 / a.len().max(b.len())) as u32
1115
}
1116