OXYGENHUB
# 728da0f92161 oxygen / crates / oxy-core / src / repo / snapshot.rs
48.6 KiB · Rust · last changed Partial and interactive commits (commit PATH.., commit -i with hunk closure), oxy blame from atom keys, oxy bisect over dependency-closed prefixes with run and skip (spec 0.8 sections 12.3 and 27) 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 { 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
            }
503
        }
504
        for j in 0..wc.edits.len() {
505
            if !edits_sel[j] {
506
                continue;
507
            }
508
            let e = &wc.edits[j];
509
            if let Some(&a) = add_index.get(&e.node()) {
510
                if !ops_sel[a] {
511
                    ops_sel[a] = true;
512
                    changed = true;
513
                }
514
            }
515
            if let Edit::Insert { node, after, before, .. } = e {
516
                for r in [after, before] {
517
                    if let AtomRef::Atom { change, index } = r {
518
                        if change.is_zero() {
519
                            if let Some(k) = insert_for(node, *index) {
520
                                if !edits_sel[k] {
521
                                    edits_sel[k] = true;
522
                                    changed = true;
523
                                }
524
                            }
525
                        }
526
                    }
527
                }
528
            }
529
        }
530
    }
531
    // Renumber self-references: old atom index → new index among selected inserts.
532
    let mut remap: HashMap<(NodeId, u32), u32> = HashMap::new();
533
    let mut new_counts: HashMap<NodeId, u32> = HashMap::new();
534
    for (j, r) in insert_ranges.iter().enumerate() {
535
        if let (true, Some((node, a, b))) = (edits_sel[j], r) {
536
            let next = new_counts.entry(*node).or_default();
537
            for old in *a..*b {
538
                remap.insert((*node, old), *next);
539
                *next += 1;
540
            }
541
        }
542
    }
543
    let map_ref = |r: AtomRef, node: &NodeId| -> AtomRef {
544
        match r {
545
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change, index: remap.get(&(*node, index)).copied().unwrap_or(index) },
546
            other => other,
547
        }
548
    };
549
    let ops = wc.tree_ops.iter().enumerate().filter(|(i, _)| ops_sel[*i]).map(|(_, o)| o.clone()).collect();
550
    let edits = wc
551
        .edits
552
        .iter()
553
        .enumerate()
554
        .filter(|(j, _)| edits_sel[*j])
555
        .map(|(_, e)| match e {
556
            Edit::Insert { node, after, before, lines } => Edit::Insert { node: *node, after: map_ref(*after, node), before: map_ref(*before, node), lines: lines.clone() },
557
            other => other.clone(),
558
        })
559
        .collect();
560
    (ops, edits)
561
}
562
563
/// Render each tree operation and edit of `change` on its own, numbered.
564
pub fn render_hunks(change: &Change, base: &State, base_snapshot: &Snapshot) -> Vec<Hunk> {
565
    let by_node = base_snapshot.tree.by_node();
566
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
567
    for op in &change.tree_ops {
568
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
569
            new_names.insert(*node, (*parent, name.clone(), *kind));
570
        }
571
    }
572
    let mut out = Vec::new();
573
    for (i, op) in change.tree_ops.iter().enumerate() {
574
        let one = Change { tree_ops: vec![op.clone()], edits: vec![], ..change.clone() };
575
        let path = path_of_node(&op.node(), &by_node, &new_names).unwrap_or_default();
576
        out.push(Hunk { index: i, path, text: render_change(&one, base, base_snapshot) });
577
    }
578
    for (j, e) in change.edits.iter().enumerate() {
579
        let one = Change { tree_ops: vec![], edits: vec![e.clone()], ..change.clone() };
580
        let path = path_of_node(&e.node(), &by_node, &new_names).unwrap_or_default();
581
        out.push(Hunk { index: change.tree_ops.len() + j, path, text: render_change(&one, base, base_snapshot) });
582
    }
583
    out
584
}
585
586
/// Builds the tree ops and edits of a working-copy change against a base.
587
struct ChangeBuilder<'a> {
588
    base: &'a State,
589
    base_snapshot: &'a Snapshot,
590
    tree_ops: Vec<TreeOp>,
591
    edits: Vec<Edit>,
592
    statuses: Vec<PathStatus>,
593
    dir_nodes: HashMap<String, NodeId>,
594
    reuse: HashMap<String, (NodeId, NodeKind)>,
595
    /// Changes that contributed to conflicts this working copy resolves.
596
    contributors: BTreeSet<ChangeHash>,
597
    resolves: bool,
598
    attrs: crate::attributes::Attributes,
599
    blobs: HashMap<String, ObjectRef>,
600
}
601
602
impl<'a> ChangeBuilder<'a> {
603
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
604
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
605
        dir_nodes.insert(String::new(), NodeId::ROOT);
606
        for (p, f) in &base_snapshot.files {
607
            if f.kind == NodeKind::Dir {
608
                dir_nodes.insert(p.clone(), f.node);
609
            }
610
        }
611
        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() }
612
    }
613
614
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
615
        self.attrs = attrs;
616
        self
617
    }
618
619
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
620
    /// only when it has no NUL bytes, else sniffed.
621
    /// The blob for an opaque entry: the cached one, else stored now.
622
    fn blob_of(&mut self, repo: &Repo, path: &str, e: &FsEntry, nocompress: bool) -> Result<ObjectRef> {
623
        let b = match e.blob {
624
            Some(b) => b,
625
            None => repo.store_blob_for(&e.content, nocompress)?,
626
        };
627
        self.blobs.insert(path.to_string(), b);
628
        Ok(b)
629
    }
630
631
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
632
        if e.blob.is_some() {
633
            return true;
634
        }
635
        if e.kind == NodeKind::Symlink || e.kind == NodeKind::Subrepo {
636
            return true;
637
        }
638
        match self.attrs.lookup(path).binary {
639
            Some(true) => true,
640
            Some(false) => is_binary(&e.content),
641
            None => is_binary(&e.content),
642
        }
643
    }
644
645
    /// Text content as stored: `\r\n` normalized for `eol=` files.
646
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
647
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
648
    }
649
650
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
651
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
652
    }
653
654
    fn ensure_dir(&mut self, path: &str) -> NodeId {
655
        if let Some(n) = self.dir_nodes.get(path) {
656
            return *n;
657
        }
658
        let mut parent = NodeId::ROOT;
659
        let mut prefix = String::new();
660
        for comp in path.split('/') {
661
            if !prefix.is_empty() {
662
                prefix.push('/');
663
            }
664
            prefix.push_str(comp);
665
            parent = match self.dir_nodes.get(&prefix) {
666
                Some(n) => *n,
667
                None => {
668
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
669
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
670
                    self.dir_nodes.insert(prefix.clone(), node);
671
                    node
672
                }
673
            };
674
        }
675
        parent
676
    }
677
678
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
679
        let (dir, name) = split_path(path);
680
        let parent = self.ensure_dir(dir);
681
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
682
        let opaque = self.opaque_for(path, e);
683
        let nocompress = self.attrs.lookup(path).nocompress;
684
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
685
        if opaque {
686
            let blob = self.blob_of(repo, path, e, nocompress)?;
687
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
688
        } else if !e.content.is_empty() {
689
            let content = self.stored_text(path, &e.content);
690
            self.edits.push(Edit::Insert {
691
                node,
692
                after: crate::objects::AtomRef::Start,
693
                before: crate::objects::AtomRef::End,
694
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
695
            });
696
        }
697
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
698
        Ok(())
699
    }
700
701
    /// Move the node at `old` to `new` and apply `e`'s content to it.
702
    fn move_path(&mut self, repo: &Repo, old: &str, new: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
703
        let entry = &self.base_snapshot.tree.entries[old];
704
        let (dir, name) = split_path(new);
705
        let parent = self.ensure_dir(dir);
706
        self.tree_ops.push(TreeOp::Move { node: expected.node, old: op_ref(entry.placement), parent, name: name.to_string() });
707
        self.update_path(repo, old, e, expected)?;
708
        // update_path reports under the old path; the rename is what the user sees.
709
        self.statuses.retain(|s| s.path != old);
710
        self.statuses.push(PathStatus { path: new.to_string(), status: ChangeStatus::Renamed });
711
        Ok(())
712
    }
713
714
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
715
        let entry = &self.base_snapshot.tree.entries[path];
716
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
717
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
718
            self.resolves = true;
719
        }
720
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
721
    }
722
723
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
724
        if expected.kind != e.kind {
725
            self.remove_path(path, expected);
726
            self.statuses.pop();
727
            self.add_path(repo, path, e)?;
728
            self.statuses.pop();
729
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
730
            return Ok(());
731
        }
732
        let entry = &self.base_snapshot.tree.entries[path];
733
        let node = expected.node;
734
        let mode_atom = entry.mode_atom.map(op_ref);
735
        let mut status: Option<ChangeStatus> = None;
736
        let wants_opaque = self.opaque_for(path, e);
737
        let nocompress = self.attrs.lookup(path).nocompress;
738
        let disk_text = self.stored_text(path, &e.content);
739
        match &expected.content {
740
            FileContent::Dir => {}
741
            FileContent::Text { bytes, materialized } => {
742
                if materialized.is_conflicted() {
743
                    if bytes == &disk_text {
744
                        return Ok(());
745
                    }
746
                    if has_conflict_markers(&e.content) {
747
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
748
                        return Ok(());
749
                    }
750
                    // Resolution (spec §9.6): diff against the unconflicted lines
751
                    // with each region as a gap, then retire every region atom.
752
                    // Whatever the author kept from a region is re-inserted as
753
                    // new atoms, which leaves the graph totally ordered.
754
                    let regions = self.base.conflict_regions(&node);
755
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
756
                    if !regions.region_atoms.is_empty() {
757
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
758
                    }
759
                    self.edits.extend(edits);
760
                    self.contributors.extend(regions.contributors);
761
                    self.resolves = true;
762
                    if e.exec != expected.mode.exec {
763
                        if let Some(old) = mode_atom {
764
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
765
                        }
766
                    }
767
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
768
                    return Ok(());
769
                }
770
                if wants_opaque {
771
                    // Text → opaque (spec §7.5), by content or by attribute.
772
                    if let Some(old) = mode_atom {
773
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
774
                    }
775
                    let live: Vec<_> = repo_live_keys(self.base, &node);
776
                    if !live.is_empty() {
777
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
778
                    }
779
                    let blob = self.blob_of(repo, path, e, nocompress)?;
780
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
781
                    status = Some(ChangeStatus::Modified);
782
                } else if bytes != &disk_text {
783
                    let old = self.base.live_lines(&node);
784
                    let edits = text_edits(node, &old, &disk_text);
785
                    if !edits.is_empty() {
786
                        self.edits.extend(edits);
787
                        status = Some(ChangeStatus::Modified);
788
                    }
789
                }
790
                let switched_to_opaque = wants_opaque;
791
                if !switched_to_opaque && e.exec != expected.mode.exec {
792
                    if let Some(old) = mode_atom {
793
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
794
                        status.get_or_insert(ChangeStatus::ModeChanged);
795
                    }
796
                }
797
            }
798
            FileContent::Opaque { blobs } => {
799
                let new_ref = e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content));
800
                if e.kind == NodeKind::File && wants_opaque {
801
                    // Unchanged or replaced, the file stays opaque: remember its blob.
802
                    self.blobs.insert(path.to_string(), new_ref);
803
                }
804
                if blobs.len() > 1 {
805
                    if !blobs.contains(&new_ref) {
806
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
807
                    }
808
                    return Ok(());
809
                }
810
                let current = blobs.first().copied();
811
                if current != Some(new_ref) {
812
                    if e.kind == NodeKind::File && !wants_opaque {
813
                        // Opaque → text (spec §7.5).
814
                        if let Some(old) = mode_atom {
815
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
816
                        }
817
                        self.edits.push(Edit::Replace { node, old: current, new: None });
818
                        if !e.content.is_empty() {
819
                            self.edits.push(Edit::Insert {
820
                                node,
821
                                after: crate::objects::AtomRef::Start,
822
                                before: crate::objects::AtomRef::End,
823
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
824
                            });
825
                        }
826
                    } else {
827
                        let blob = self.blob_of(repo, path, e, nocompress)?;
828
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
829
                    }
830
                    status = Some(ChangeStatus::Modified);
831
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
832
                    if let Some(old) = mode_atom {
833
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
834
                        status = Some(ChangeStatus::ModeChanged);
835
                    }
836
                }
837
            }
838
        }
839
        if let Some(s) = status {
840
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
841
        }
842
        Ok(())
843
    }
844
}
845
846
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
847
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
848
}
849
850
/// The changes a change's scope references (spec §8.1): introducers of every
851
/// atom, placement, and mode it touches, and of every node it operates on.
852
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
853
    let mut out = BTreeSet::new();
854
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
855
        if !r.change.is_zero() {
856
            out.insert(r.change);
857
        }
858
    };
859
    let mut nodes: Vec<NodeId> = Vec::new();
860
    for op in &change.tree_ops {
861
        match op {
862
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
863
            TreeOp::RemoveNode { node, placement } => {
864
                nodes.push(*node);
865
                opref(placement, &mut out);
866
            }
867
            TreeOp::Move { node, old, parent, .. } => {
868
                nodes.push(*node);
869
                nodes.push(*parent);
870
                opref(old, &mut out);
871
            }
872
            TreeOp::SetMode { node, old, .. } => {
873
                nodes.push(*node);
874
                opref(old, &mut out);
875
            }
876
        }
877
    }
878
    for e in &change.edits {
879
        nodes.push(e.node());
880
        match e {
881
            Edit::Insert { after, before, .. } => {
882
                for r in [after, before] {
883
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
884
                        if !change.is_zero() {
885
                            out.insert(*change);
886
                        }
887
                    }
888
                }
889
            }
890
            Edit::Delete { atoms, .. } => {
891
                for r in atoms {
892
                    if !r.change.is_zero() {
893
                        out.insert(r.change);
894
                    }
895
                }
896
            }
897
            Edit::Replace { .. } => {}
898
        }
899
    }
900
    for n in nodes {
901
        if let Some(h) = base.tree().introducer(&n) {
902
            out.insert(h);
903
        }
904
    }
905
    out
906
}
907
908
/// Paths of the nodes a change adds on top of `base_snapshot`.
909
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
910
    let by_node = base_snapshot.tree.by_node();
911
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
912
    for op in &change.tree_ops {
913
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
914
            new_names.insert(*node, (*parent, name.clone(), *kind));
915
        }
916
    }
917
    let mut out = HashMap::new();
918
    for (node, (_, _, kind)) in &new_names {
919
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
920
            out.insert(path, (*node, *kind));
921
        }
922
    }
923
    out
924
}
925
926
fn path_of_node(
927
    n: &NodeId,
928
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
929
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
930
) -> Option<String> {
931
    if let Some(e) = by_node.get(n) {
932
        return Some(e.path.clone());
933
    }
934
    let mut parts = Vec::new();
935
    let mut cur = *n;
936
    loop {
937
        if cur.is_root() {
938
            break;
939
        }
940
        if let Some(e) = by_node.get(&cur) {
941
            if !e.path.is_empty() {
942
                parts.push(e.path.clone());
943
            }
944
            break;
945
        }
946
        let (parent, name, _) = new_names.get(&cur)?;
947
        parts.push(name.clone());
948
        cur = *parent;
949
    }
950
    parts.reverse();
951
    Some(parts.join("/"))
952
}
953
954
/// Render a change's operations with line content, using `base` for deleted lines.
955
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
956
    let by_node = base_snapshot.tree.by_node();
957
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
958
    for op in &change.tree_ops {
959
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
960
            new_names.insert(*node, (*parent, name.clone(), *kind));
961
        }
962
    }
963
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
964
965
    let mut out = String::new();
966
    for op in &change.tree_ops {
967
        match op {
968
            TreeOp::AddNode { node, kind, mode, .. } => {
969
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
970
            }
971
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
972
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
973
            TreeOp::SetMode { node, mode, .. } => {
974
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
975
            }
976
        }
977
    }
978
    let mut last_node: Option<NodeId> = None;
979
    for e in &change.edits {
980
        let n = e.node();
981
        if last_node != Some(n) {
982
            out.push_str(&format!("--- {}\n", path_of(&n)));
983
            last_node = Some(n);
984
        }
985
        match e {
986
            Edit::Insert { lines, .. } => {
987
                for l in lines {
988
                    out.push('+');
989
                    out.push_str(&String::from_utf8_lossy(l));
990
                    if !l.ends_with(b"\n") {
991
                        out.push('\n');
992
                    }
993
                }
994
            }
995
            Edit::Delete { atoms, .. } => {
996
                for r in atoms {
997
                    for i in r.from..r.to {
998
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
999
                        out.push('-');
1000
                        out.push_str(&String::from_utf8_lossy(content));
1001
                        if !content.ends_with(b"\n") {
1002
                            out.push('\n');
1003
                        }
1004
                    }
1005
                }
1006
            }
1007
            Edit::Replace { old, new, .. } => {
1008
                out.push_str(&format!(
1009
                    "  opaque content {} -> {}\n",
1010
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
1011
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
1012
                ));
1013
            }
1014
        }
1015
    }
1016
    out
1017
}
1018
1019
/// Pair removed files with added files of the same kind whose contents are
1020
/// at least `RENAME_THRESHOLD` percent alike, best matches first. Beyond
1021
/// `RENAME_LIMIT` candidate pairs only identical contents are paired.
1022
const RENAME_THRESHOLD: u32 = 50;
1023
const RENAME_LIMIT: usize = 10_000;
1024
1025
fn detect_renames(base_snapshot: &Snapshot, fs_entries: &BTreeMap<String, FsEntry>, in_scope: impl Fn(&str) -> bool) -> Vec<(String, String)> {
1026
    let removed: Vec<(&String, &crate::state::FileState)> = base_snapshot
1027
        .files
1028
        .iter()
1029
        .filter(|(p, f)| f.kind != NodeKind::Dir && !fs_entries.contains_key(*p) && in_scope(p) && !f.is_conflicted())
1030
        .collect();
1031
    let added: Vec<(&String, &FsEntry)> = fs_entries.iter().filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p)).collect();
1032
    if removed.is_empty() || added.is_empty() {
1033
        return vec![];
1034
    }
1035
    let exact_only = removed.len() * added.len() > RENAME_LIMIT;
1036
    let old_content = |f: &crate::state::FileState| -> Option<Vec<u8>> {
1037
        match &f.content {
1038
            FileContent::Text { bytes, .. } => Some(bytes.clone()),
1039
            _ => None,
1040
        }
1041
    };
1042
    let mut scored: Vec<(u32, usize, usize)> = Vec::new();
1043
    for (ri, (_, f)) in removed.iter().enumerate() {
1044
        let old = old_content(f);
1045
        let old_blob = match &f.content {
1046
            FileContent::Opaque { blobs } if blobs.len() == 1 => Some(blobs[0]),
1047
            _ => None,
1048
        };
1049
        for (ai, (_, e)) in added.iter().enumerate() {
1050
            if e.kind != f.kind {
1051
                continue;
1052
            }
1053
            let score = match (&old, old_blob) {
1054
                (Some(o), _) => {
1055
                    if exact_only {
1056
                        if *o == e.content { 100 } else { 0 }
1057
                    } else {
1058
                        similarity(o, &e.content)
1059
                    }
1060
                }
1061
                (None, Some(b)) if e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content)) == b => 100,
1062
                _ => 0,
1063
            };
1064
            if score >= RENAME_THRESHOLD {
1065
                scored.push((score, ri, ai));
1066
            }
1067
        }
1068
    }
1069
    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)));
1070
    let mut used_r = vec![false; removed.len()];
1071
    let mut used_a = vec![false; added.len()];
1072
    let mut out = Vec::new();
1073
    for (_, ri, ai) in scored {
1074
        if used_r[ri] || used_a[ai] {
1075
            continue;
1076
        }
1077
        used_r[ri] = true;
1078
        used_a[ai] = true;
1079
        out.push((removed[ri].0.clone(), added[ai].0.clone()));
1080
    }
1081
    out
1082
}
1083
1084
/// Percentage of the larger file's bytes found in lines the two share
1085
/// (multiset intersection of lines), like Git's similarity index.
1086
pub fn similarity(a: &[u8], b: &[u8]) -> u32 {
1087
    if a == b {
1088
        return 100;
1089
    }
1090
    if a.is_empty() || b.is_empty() {
1091
        return 0;
1092
    }
1093
    let mut counts: HashMap<&[u8], usize> = HashMap::new();
1094
    for l in split_lines(a) {
1095
        *counts.entry(l).or_default() += 1;
1096
    }
1097
    let mut shared = 0usize;
1098
    for l in split_lines(b) {
1099
        if let Some(n) = counts.get_mut(l) {
1100
            if *n > 0 {
1101
                *n -= 1;
1102
                shared += l.len();
1103
            }
1104
        }
1105
    }
1106
    (shared * 100 / a.len().max(b.len())) as u32
1107
}
1108