OXYGENHUB
# c6aa5bd9d1c8 oxygen / crates / oxy-core / src / repo / snapshot.rs
62.0 KiB · Rust · last changed Machine interface for editors (spec 0.8b): oxy root, cat, version, hunks, discard by path or hunk; --json on log (with --path), show, diff, resolve, op log, bookmark list, remote list, fetch, pull, push, submodule status, bisect status, verify, policy show; commit --hunks; integration test over every JSON command 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
/// One file's part of a diff, for `oxy diff --json`.
587
#[derive(Clone, Debug)]
588
pub struct FileDiff {
589
    pub path: String,
590
    /// `added`, `removed`, `modified`, `renamed`, `mode`, `replaced`.
591
    pub kind: String,
592
    pub hunks: Vec<DiffHunk>,
593
    /// For renames, the previous path.
594
    pub from: Option<String>,
595
}
596
597
/// Lines inserted after `base_line` (0 = before the first line) and lines
598
/// removed, with their base line numbers.
599
#[derive(Clone, Debug, Default)]
600
pub struct DiffHunk {
601
    pub base_line: u32,
602
    pub added: Vec<Vec<u8>>,
603
    pub removed: Vec<(u32, Vec<u8>)>,
604
}
605
606
impl Repo {
607
    /// The content of `path` in a state: `heads`, or the working copy's base.
608
    pub fn file_at(&self, path: &str, heads: Option<&[ChangeHash]>) -> Result<Vec<u8>> {
609
        let snapshot = match heads {
610
            Some(h) => self.load_state(h)?.materialize(),
611
            None => {
612
                let (_, wc) = self.working_copy()?;
613
                self.load_state(&wc.deps)?.materialize()
614
            }
615
        };
616
        let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in that state")) };
617
        match &f.content {
618
            FileContent::Dir => fail(format!("{path} is a directory")),
619
            FileContent::Text { bytes, .. } => Ok(bytes.clone()),
620
            FileContent::Opaque { blobs } => match blobs.first() {
621
                Some(b) => self.read_blob(b),
622
                None => Ok(vec![]),
623
            },
624
        }
625
    }
626
627
    /// Restore `paths` (files or directories) to the working copy's base:
628
    /// edits are undone, additions removed, removals put back. Returns the
629
    /// number of paths touched.
630
    pub fn discard(&self, paths: &[String]) -> Result<usize> {
631
        let snap = self.snapshot("oxy discard")?;
632
        let under = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
633
        let attrs = Repo::state_attributes(&snap.base_snapshot);
634
        let mut n = 0;
635
        for s in &snap.statuses {
636
            if !under(&s.path) {
637
                continue;
638
            }
639
            match snap.base_snapshot.files.get(&s.path) {
640
                None => {
641
                    // Added (or the new name of a rename): remove it.
642
                    let full = self.root.join(&s.path);
643
                    if full.is_dir() {
644
                        let _ = fs::remove_dir_all(&full);
645
                    } else {
646
                        let _ = fs::remove_file(&full);
647
                    }
648
                    n += 1;
649
                }
650
                Some(f) => {
651
                    match &f.content {
652
                        FileContent::Dir => fs::create_dir_all(self.root.join(&s.path))?,
653
                        FileContent::Text { bytes, .. } => {
654
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&s.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
655
                            self.write_file(&s.path, &on_disk, f.mode.exec)?;
656
                        }
657
                        FileContent::Opaque { blobs } => {
658
                            let bytes = match blobs.first() {
659
                                Some(b) => self.read_blob(b)?,
660
                                None => vec![],
661
                            };
662
                            if f.kind == NodeKind::Symlink {
663
                                self.write_symlink(&s.path, &bytes)?;
664
                            } else if f.kind == NodeKind::File {
665
                                self.write_file(&s.path, &bytes, f.mode.exec)?;
666
                            }
667
                        }
668
                    }
669
                    n += 1;
670
                }
671
            }
672
        }
673
        // A rename's old name is not in the statuses; put it back too.
674
        for (p, f) in &snap.base_snapshot.files {
675
            if under(p) && !self.root.join(p).exists() && f.kind != NodeKind::Dir && f.kind != NodeKind::Subrepo && !snap.statuses.iter().any(|s| s.path == *p) {
676
                let bytes = match &f.content {
677
                    FileContent::Text { bytes, .. } => bytes.clone(),
678
                    FileContent::Opaque { blobs } => blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default(),
679
                    FileContent::Dir => continue,
680
                };
681
                if f.kind == NodeKind::Symlink { self.write_symlink(p, &bytes)? } else { self.write_file(p, &bytes, f.mode.exec)? }
682
                n += 1;
683
            }
684
        }
685
        self.drop_manifest();
686
        Ok(n)
687
    }
688
689
    /// Undo the working-copy hunks `indices` (as `hunks` numbers them) on
690
    /// disk, keeping the others: the files are rewritten from the base plus
691
    /// the remaining hunks.
692
    pub fn discard_hunks(&self, indices: &[usize]) -> Result<usize> {
693
        let (snap, hunks) = self.hunks()?;
694
        let keep: Vec<usize> = hunks.iter().map(|h| h.index).filter(|i| !indices.contains(i)).collect();
695
        let (ops, edits) = select_hunks(&snap.wc, &keep);
696
        let dropped_nodes: BTreeSet<NodeId> = hunks
697
            .iter()
698
            .filter(|h| indices.contains(&h.index))
699
            .map(|h| if h.index < snap.wc.tree_ops.len() { snap.wc.tree_ops[h.index].node() } else { snap.wc.edits[h.index - snap.wc.tree_ops.len()].node() })
700
            .collect();
701
        let mut partial = snap.wc.clone();
702
        partial.tree_ops = ops;
703
        partial.edits = edits;
704
        partial.normalize();
705
        let mut st = snap.base.clone();
706
        st.add_unchecked(partial);
707
        let target = st.materialize();
708
        let by_node_target = target.tree.by_node();
709
        let by_node_base = snap.base_snapshot.tree.by_node();
710
        let attrs = Repo::state_attributes(&target);
711
        let mut n = 0;
712
        for node in dropped_nodes {
713
            match by_node_target.get(&node) {
714
                Some(e) => {
715
                    let f = &target.files[&e.path];
716
                    match &f.content {
717
                        FileContent::Dir => {}
718
                        FileContent::Text { bytes, .. } => {
719
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&e.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
720
                            self.write_file(&e.path, &on_disk, f.mode.exec)?;
721
                        }
722
                        FileContent::Opaque { blobs } => {
723
                            let bytes = blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default();
724
                            if f.kind == NodeKind::Symlink { self.write_symlink(&e.path, &bytes)? } else { self.write_file(&e.path, &bytes, f.mode.exec)? }
725
                        }
726
                    }
727
                    // A discarded rename: the new name goes.
728
                    if let Some(old_path) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Renamed && !target.files.contains_key(&s.path)).map(|s| s.path.clone()) {
729
                        let _ = fs::remove_file(self.root.join(old_path));
730
                    }
731
                    n += 1;
732
                }
733
                None => {
734
                    // The node exists in neither target nor base with a live path: an addition to remove.
735
                    if let Some(added) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Added && !target.files.contains_key(&s.path) && !by_node_base.contains_key(&node)) {
736
                        let full = self.root.join(&added.path);
737
                        if full.is_dir() { let _ = fs::remove_dir_all(&full); } else { let _ = fs::remove_file(&full); }
738
                        n += 1;
739
                    }
740
                }
741
            }
742
        }
743
        self.drop_manifest();
744
        Ok(n)
745
    }
746
747
    /// The diff of the working copy (`None`) or of a change, per file with
748
    /// line-numbered hunks (spec: `oxy diff --json`).
749
    pub fn diff_files(&self, change: Option<&ChangeHash>) -> Result<Vec<FileDiff>> {
750
        let (c, base) = match change {
751
            Some(h) => {
752
                let c = self.store.get_change(h)?;
753
                let base = self.load_state(&c.deps)?;
754
                (c, base)
755
            }
756
            None => {
757
                let snap = self.snapshot("oxy diff")?;
758
                (snap.wc, snap.base)
759
            }
760
        };
761
        let base_snapshot = base.materialize();
762
        Ok(diff_files_of(&c, &base, &base_snapshot))
763
    }
764
765
    /// The log restricted to changes touching `path` (its node, so renames
766
    /// are followed), on the current bookmark.
767
    pub fn log_for_path(&self, path: &str) -> Result<Vec<LogEntry>> {
768
        let entries = self.log()?;
769
        let (_, wc) = self.working_copy()?;
770
        let base_snapshot = self.load_state(&wc.deps)?.materialize();
771
        let heads = self.bookmark_heads()?;
772
        let state_snapshot = self.load_state(&heads)?.materialize();
773
        let node = base_snapshot.files.get(path).or_else(|| state_snapshot.files.get(path)).map(|f| f.node);
774
        let Some(node) = node else { return fail(format!("{path} is not tracked")) };
775
        let mut out = Vec::new();
776
        for e in entries {
777
            let Ok(c) = self.store.get_change(&e.hash) else { continue };
778
            if c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node) {
779
                out.push(e);
780
            }
781
        }
782
        Ok(out)
783
    }
784
}
785
786
/// Per-file hunks of `change` against `base`.
787
pub fn diff_files_of(change: &Change, base: &State, base_snapshot: &Snapshot) -> Vec<FileDiff> {
788
    let by_node = base_snapshot.tree.by_node();
789
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
790
    for op in &change.tree_ops {
791
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
792
            new_names.insert(*node, (*parent, name.clone(), *kind));
793
        }
794
    }
795
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
796
    let mut files: BTreeMap<NodeId, FileDiff> = BTreeMap::new();
797
    let mut order: Vec<NodeId> = Vec::new();
798
    let mut entry = |files: &mut BTreeMap<NodeId, FileDiff>, n: NodeId, kind: &str| {
799
        if let std::collections::btree_map::Entry::Vacant(v) = files.entry(n) {
800
            order.push(n);
801
            v.insert(FileDiff { path: path_of(&n), kind: kind.to_string(), hunks: vec![], from: None });
802
        }
803
    };
804
    for op in &change.tree_ops {
805
        match op {
806
            TreeOp::AddNode { node, kind, .. } => {
807
                if *kind != NodeKind::Dir {
808
                    entry(&mut files, *node, "added");
809
                }
810
            }
811
            TreeOp::RemoveNode { node, .. } => {
812
                entry(&mut files, *node, "removed");
813
                if let Some(e) = by_node.get(node) {
814
                    files.get_mut(node).unwrap().path = e.path.clone();
815
                }
816
            }
817
            TreeOp::Move { node, parent, name, .. } => {
818
                entry(&mut files, *node, "renamed");
819
                let f = files.get_mut(node).unwrap();
820
                f.from = by_node.get(node).map(|e| e.path.clone());
821
                let dir = if parent.is_root() { String::new() } else { path_of_node(parent, &by_node, &new_names).unwrap_or_default() };
822
                f.path = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
823
            }
824
            TreeOp::SetMode { node, .. } => entry(&mut files, *node, "mode"),
825
        }
826
    }
827
    // Base line numbers per node, 1-based.
828
    let mut line_of: HashMap<NodeId, HashMap<(ChangeHash, u32), u32>> = HashMap::new();
829
    let mut lines_for = |n: &NodeId| -> HashMap<(ChangeHash, u32), u32> {
830
        line_of.entry(*n).or_insert_with(|| base.live_lines(n).into_iter().enumerate().map(|(i, (k, _))| (k, i as u32 + 1)).collect()).clone()
831
    };
832
    for e in &change.edits {
833
        let n = e.node();
834
        let is_new = new_names.contains_key(&n);
835
        entry(&mut files, n, if is_new { "added" } else { "modified" });
836
        let f = files.get_mut(&n).unwrap();
837
        match e {
838
            Edit::Insert { after, lines, .. } => {
839
                let map = lines_for(&n);
840
                let base_line = match after {
841
                    AtomRef::Start => 0,
842
                    AtomRef::End => map.len() as u32,
843
                    AtomRef::Atom { change, index } if change.is_zero() => f.hunks.last().map(|h| h.base_line).unwrap_or(0),
844
                    AtomRef::Atom { change, index } => map.get(&(*change, *index)).copied().unwrap_or(0),
845
                };
846
                f.hunks.push(DiffHunk { base_line, added: lines.clone(), removed: vec![] });
847
            }
848
            Edit::Delete { atoms, .. } => {
849
                let map = lines_for(&n);
850
                let mut removed = Vec::new();
851
                for r in atoms {
852
                    for i in r.from..r.to {
853
                        let line = map.get(&(r.change, i)).copied().unwrap_or(0);
854
                        let text = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"").to_vec();
855
                        removed.push((line, text));
856
                    }
857
                }
858
                let base_line = removed.first().map(|(l, _)| l.saturating_sub(1)).unwrap_or(0);
859
                f.hunks.push(DiffHunk { base_line, added: vec![], removed });
860
            }
861
            Edit::Replace { .. } => {
862
                if !is_new {
863
                    f.kind = "replaced".into();
864
                }
865
            }
866
        }
867
    }
868
    order.into_iter().filter_map(|n| files.remove(&n)).collect()
869
}
870
871
/// Builds the tree ops and edits of a working-copy change against a base.
872
struct ChangeBuilder<'a> {
873
    base: &'a State,
874
    base_snapshot: &'a Snapshot,
875
    tree_ops: Vec<TreeOp>,
876
    edits: Vec<Edit>,
877
    statuses: Vec<PathStatus>,
878
    dir_nodes: HashMap<String, NodeId>,
879
    reuse: HashMap<String, (NodeId, NodeKind)>,
880
    /// Changes that contributed to conflicts this working copy resolves.
881
    contributors: BTreeSet<ChangeHash>,
882
    resolves: bool,
883
    attrs: crate::attributes::Attributes,
884
    blobs: HashMap<String, ObjectRef>,
885
}
886
887
impl<'a> ChangeBuilder<'a> {
888
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
889
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
890
        dir_nodes.insert(String::new(), NodeId::ROOT);
891
        for (p, f) in &base_snapshot.files {
892
            if f.kind == NodeKind::Dir {
893
                dir_nodes.insert(p.clone(), f.node);
894
            }
895
        }
896
        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() }
897
    }
898
899
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
900
        self.attrs = attrs;
901
        self
902
    }
903
904
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
905
    /// only when it has no NUL bytes, else sniffed.
906
    /// The blob for an opaque entry: the cached one, else stored now.
907
    fn blob_of(&mut self, repo: &Repo, path: &str, e: &FsEntry, nocompress: bool) -> Result<ObjectRef> {
908
        let b = match e.blob {
909
            Some(b) => b,
910
            None => repo.store_blob_for(&e.content, nocompress)?,
911
        };
912
        self.blobs.insert(path.to_string(), b);
913
        Ok(b)
914
    }
915
916
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
917
        if e.blob.is_some() {
918
            return true;
919
        }
920
        if e.kind == NodeKind::Symlink || e.kind == NodeKind::Subrepo {
921
            return true;
922
        }
923
        match self.attrs.lookup(path).binary {
924
            Some(true) => true,
925
            Some(false) => is_binary(&e.content),
926
            None => is_binary(&e.content),
927
        }
928
    }
929
930
    /// Text content as stored: `\r\n` normalized for `eol=` files.
931
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
932
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
933
    }
934
935
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
936
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
937
    }
938
939
    fn ensure_dir(&mut self, path: &str) -> NodeId {
940
        if let Some(n) = self.dir_nodes.get(path) {
941
            return *n;
942
        }
943
        let mut parent = NodeId::ROOT;
944
        let mut prefix = String::new();
945
        for comp in path.split('/') {
946
            if !prefix.is_empty() {
947
                prefix.push('/');
948
            }
949
            prefix.push_str(comp);
950
            parent = match self.dir_nodes.get(&prefix) {
951
                Some(n) => *n,
952
                None => {
953
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
954
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
955
                    self.dir_nodes.insert(prefix.clone(), node);
956
                    node
957
                }
958
            };
959
        }
960
        parent
961
    }
962
963
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
964
        let (dir, name) = split_path(path);
965
        let parent = self.ensure_dir(dir);
966
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
967
        let opaque = self.opaque_for(path, e);
968
        let nocompress = self.attrs.lookup(path).nocompress;
969
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
970
        if opaque {
971
            let blob = self.blob_of(repo, path, e, nocompress)?;
972
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
973
        } else if !e.content.is_empty() {
974
            let content = self.stored_text(path, &e.content);
975
            self.edits.push(Edit::Insert {
976
                node,
977
                after: crate::objects::AtomRef::Start,
978
                before: crate::objects::AtomRef::End,
979
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
980
            });
981
        }
982
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
983
        Ok(())
984
    }
985
986
    /// Move the node at `old` to `new` and apply `e`'s content to it.
987
    fn move_path(&mut self, repo: &Repo, old: &str, new: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
988
        let entry = &self.base_snapshot.tree.entries[old];
989
        let (dir, name) = split_path(new);
990
        let parent = self.ensure_dir(dir);
991
        self.tree_ops.push(TreeOp::Move { node: expected.node, old: op_ref(entry.placement), parent, name: name.to_string() });
992
        self.update_path(repo, old, e, expected)?;
993
        // update_path reports under the old path; the rename is what the user sees.
994
        self.statuses.retain(|s| s.path != old);
995
        self.statuses.push(PathStatus { path: new.to_string(), status: ChangeStatus::Renamed });
996
        Ok(())
997
    }
998
999
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
1000
        let entry = &self.base_snapshot.tree.entries[path];
1001
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
1002
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
1003
            self.resolves = true;
1004
        }
1005
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
1006
    }
1007
1008
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
1009
        if expected.kind != e.kind {
1010
            self.remove_path(path, expected);
1011
            self.statuses.pop();
1012
            self.add_path(repo, path, e)?;
1013
            self.statuses.pop();
1014
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
1015
            return Ok(());
1016
        }
1017
        let entry = &self.base_snapshot.tree.entries[path];
1018
        let node = expected.node;
1019
        let mode_atom = entry.mode_atom.map(op_ref);
1020
        let mut status: Option<ChangeStatus> = None;
1021
        let wants_opaque = self.opaque_for(path, e);
1022
        let nocompress = self.attrs.lookup(path).nocompress;
1023
        let disk_text = self.stored_text(path, &e.content);
1024
        match &expected.content {
1025
            FileContent::Dir => {}
1026
            FileContent::Text { bytes, materialized } => {
1027
                if materialized.is_conflicted() {
1028
                    if bytes == &disk_text {
1029
                        return Ok(());
1030
                    }
1031
                    if has_conflict_markers(&e.content) {
1032
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1033
                        return Ok(());
1034
                    }
1035
                    // Resolution (spec §9.6): diff against the unconflicted lines
1036
                    // with each region as a gap, then retire every region atom.
1037
                    // Whatever the author kept from a region is re-inserted as
1038
                    // new atoms, which leaves the graph totally ordered.
1039
                    let regions = self.base.conflict_regions(&node);
1040
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
1041
                    if !regions.region_atoms.is_empty() {
1042
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
1043
                    }
1044
                    self.edits.extend(edits);
1045
                    self.contributors.extend(regions.contributors);
1046
                    self.resolves = true;
1047
                    if e.exec != expected.mode.exec {
1048
                        if let Some(old) = mode_atom {
1049
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1050
                        }
1051
                    }
1052
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
1053
                    return Ok(());
1054
                }
1055
                if wants_opaque {
1056
                    // Text → opaque (spec §7.5), by content or by attribute.
1057
                    if let Some(old) = mode_atom {
1058
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1059
                    }
1060
                    let live: Vec<_> = repo_live_keys(self.base, &node);
1061
                    if !live.is_empty() {
1062
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
1063
                    }
1064
                    let blob = self.blob_of(repo, path, e, nocompress)?;
1065
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1066
                    status = Some(ChangeStatus::Modified);
1067
                } else if bytes != &disk_text {
1068
                    let old = self.base.live_lines(&node);
1069
                    let edits = text_edits(node, &old, &disk_text);
1070
                    if !edits.is_empty() {
1071
                        self.edits.extend(edits);
1072
                        status = Some(ChangeStatus::Modified);
1073
                    }
1074
                }
1075
                let switched_to_opaque = wants_opaque;
1076
                if !switched_to_opaque && e.exec != expected.mode.exec {
1077
                    if let Some(old) = mode_atom {
1078
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1079
                        status.get_or_insert(ChangeStatus::ModeChanged);
1080
                    }
1081
                }
1082
            }
1083
            FileContent::Opaque { blobs } => {
1084
                let new_ref = e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content));
1085
                if e.kind == NodeKind::File && wants_opaque {
1086
                    // Unchanged or replaced, the file stays opaque: remember its blob.
1087
                    self.blobs.insert(path.to_string(), new_ref);
1088
                }
1089
                if blobs.len() > 1 {
1090
                    if !blobs.contains(&new_ref) {
1091
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1092
                    }
1093
                    return Ok(());
1094
                }
1095
                let current = blobs.first().copied();
1096
                if current != Some(new_ref) {
1097
                    if e.kind == NodeKind::File && !wants_opaque {
1098
                        // Opaque → text (spec §7.5).
1099
                        if let Some(old) = mode_atom {
1100
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1101
                        }
1102
                        self.edits.push(Edit::Replace { node, old: current, new: None });
1103
                        if !e.content.is_empty() {
1104
                            self.edits.push(Edit::Insert {
1105
                                node,
1106
                                after: crate::objects::AtomRef::Start,
1107
                                before: crate::objects::AtomRef::End,
1108
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
1109
                            });
1110
                        }
1111
                    } else {
1112
                        let blob = self.blob_of(repo, path, e, nocompress)?;
1113
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
1114
                    }
1115
                    status = Some(ChangeStatus::Modified);
1116
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
1117
                    if let Some(old) = mode_atom {
1118
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1119
                        status = Some(ChangeStatus::ModeChanged);
1120
                    }
1121
                }
1122
            }
1123
        }
1124
        if let Some(s) = status {
1125
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
1126
        }
1127
        Ok(())
1128
    }
1129
}
1130
1131
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
1132
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
1133
}
1134
1135
/// The changes a change's scope references (spec §8.1): introducers of every
1136
/// atom, placement, and mode it touches, and of every node it operates on.
1137
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
1138
    let mut out = BTreeSet::new();
1139
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
1140
        if !r.change.is_zero() {
1141
            out.insert(r.change);
1142
        }
1143
    };
1144
    let mut nodes: Vec<NodeId> = Vec::new();
1145
    for op in &change.tree_ops {
1146
        match op {
1147
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
1148
            TreeOp::RemoveNode { node, placement } => {
1149
                nodes.push(*node);
1150
                opref(placement, &mut out);
1151
            }
1152
            TreeOp::Move { node, old, parent, .. } => {
1153
                nodes.push(*node);
1154
                nodes.push(*parent);
1155
                opref(old, &mut out);
1156
            }
1157
            TreeOp::SetMode { node, old, .. } => {
1158
                nodes.push(*node);
1159
                opref(old, &mut out);
1160
            }
1161
        }
1162
    }
1163
    for e in &change.edits {
1164
        nodes.push(e.node());
1165
        match e {
1166
            Edit::Insert { after, before, .. } => {
1167
                for r in [after, before] {
1168
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
1169
                        if !change.is_zero() {
1170
                            out.insert(*change);
1171
                        }
1172
                    }
1173
                }
1174
            }
1175
            Edit::Delete { atoms, .. } => {
1176
                for r in atoms {
1177
                    if !r.change.is_zero() {
1178
                        out.insert(r.change);
1179
                    }
1180
                }
1181
            }
1182
            Edit::Replace { .. } => {}
1183
        }
1184
    }
1185
    for n in nodes {
1186
        if let Some(h) = base.tree().introducer(&n) {
1187
            out.insert(h);
1188
        }
1189
    }
1190
    out
1191
}
1192
1193
/// Paths of the nodes a change adds on top of `base_snapshot`.
1194
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
1195
    let by_node = base_snapshot.tree.by_node();
1196
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1197
    for op in &change.tree_ops {
1198
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1199
            new_names.insert(*node, (*parent, name.clone(), *kind));
1200
        }
1201
    }
1202
    let mut out = HashMap::new();
1203
    for (node, (_, _, kind)) in &new_names {
1204
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
1205
            out.insert(path, (*node, *kind));
1206
        }
1207
    }
1208
    out
1209
}
1210
1211
fn path_of_node(
1212
    n: &NodeId,
1213
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
1214
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
1215
) -> Option<String> {
1216
    if let Some(e) = by_node.get(n) {
1217
        return Some(e.path.clone());
1218
    }
1219
    let mut parts = Vec::new();
1220
    let mut cur = *n;
1221
    loop {
1222
        if cur.is_root() {
1223
            break;
1224
        }
1225
        if let Some(e) = by_node.get(&cur) {
1226
            if !e.path.is_empty() {
1227
                parts.push(e.path.clone());
1228
            }
1229
            break;
1230
        }
1231
        let (parent, name, _) = new_names.get(&cur)?;
1232
        parts.push(name.clone());
1233
        cur = *parent;
1234
    }
1235
    parts.reverse();
1236
    Some(parts.join("/"))
1237
}
1238
1239
/// Render a change's operations with line content, using `base` for deleted lines.
1240
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
1241
    let by_node = base_snapshot.tree.by_node();
1242
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1243
    for op in &change.tree_ops {
1244
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1245
            new_names.insert(*node, (*parent, name.clone(), *kind));
1246
        }
1247
    }
1248
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
1249
1250
    let mut out = String::new();
1251
    for op in &change.tree_ops {
1252
        match op {
1253
            TreeOp::AddNode { node, kind, mode, .. } => {
1254
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
1255
            }
1256
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
1257
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
1258
            TreeOp::SetMode { node, mode, .. } => {
1259
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
1260
            }
1261
        }
1262
    }
1263
    let mut last_node: Option<NodeId> = None;
1264
    for e in &change.edits {
1265
        let n = e.node();
1266
        if last_node != Some(n) {
1267
            out.push_str(&format!("--- {}\n", path_of(&n)));
1268
            last_node = Some(n);
1269
        }
1270
        match e {
1271
            Edit::Insert { lines, .. } => {
1272
                for l in lines {
1273
                    out.push('+');
1274
                    out.push_str(&String::from_utf8_lossy(l));
1275
                    if !l.ends_with(b"\n") {
1276
                        out.push('\n');
1277
                    }
1278
                }
1279
            }
1280
            Edit::Delete { atoms, .. } => {
1281
                for r in atoms {
1282
                    for i in r.from..r.to {
1283
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
1284
                        out.push('-');
1285
                        out.push_str(&String::from_utf8_lossy(content));
1286
                        if !content.ends_with(b"\n") {
1287
                            out.push('\n');
1288
                        }
1289
                    }
1290
                }
1291
            }
1292
            Edit::Replace { old, new, .. } => {
1293
                out.push_str(&format!(
1294
                    "  opaque content {} -> {}\n",
1295
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
1296
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
1297
                ));
1298
            }
1299
        }
1300
    }
1301
    out
1302
}
1303
1304
/// Pair removed files with added files of the same kind whose contents are
1305
/// at least `RENAME_THRESHOLD` percent alike, best matches first. Beyond
1306
/// `RENAME_LIMIT` candidate pairs only identical contents are paired.
1307
const RENAME_THRESHOLD: u32 = 50;
1308
const RENAME_LIMIT: usize = 10_000;
1309
1310
fn detect_renames(base_snapshot: &Snapshot, fs_entries: &BTreeMap<String, FsEntry>, in_scope: impl Fn(&str) -> bool) -> Vec<(String, String)> {
1311
    let removed: Vec<(&String, &crate::state::FileState)> = base_snapshot
1312
        .files
1313
        .iter()
1314
        .filter(|(p, f)| f.kind != NodeKind::Dir && !fs_entries.contains_key(*p) && in_scope(p) && !f.is_conflicted())
1315
        .collect();
1316
    let added: Vec<(&String, &FsEntry)> = fs_entries.iter().filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p)).collect();
1317
    if removed.is_empty() || added.is_empty() {
1318
        return vec![];
1319
    }
1320
    let exact_only = removed.len() * added.len() > RENAME_LIMIT;
1321
    let old_content = |f: &crate::state::FileState| -> Option<Vec<u8>> {
1322
        match &f.content {
1323
            FileContent::Text { bytes, .. } => Some(bytes.clone()),
1324
            _ => None,
1325
        }
1326
    };
1327
    let mut scored: Vec<(u32, usize, usize)> = Vec::new();
1328
    for (ri, (_, f)) in removed.iter().enumerate() {
1329
        let old = old_content(f);
1330
        let old_blob = match &f.content {
1331
            FileContent::Opaque { blobs } if blobs.len() == 1 => Some(blobs[0]),
1332
            _ => None,
1333
        };
1334
        for (ai, (_, e)) in added.iter().enumerate() {
1335
            if e.kind != f.kind {
1336
                continue;
1337
            }
1338
            let score = match (&old, old_blob) {
1339
                (Some(o), _) => {
1340
                    if exact_only {
1341
                        if *o == e.content { 100 } else { 0 }
1342
                    } else {
1343
                        similarity(o, &e.content)
1344
                    }
1345
                }
1346
                (None, Some(b)) if e.blob.unwrap_or_else(|| Repo::blob_ref(&e.content)) == b => 100,
1347
                _ => 0,
1348
            };
1349
            if score >= RENAME_THRESHOLD {
1350
                scored.push((score, ri, ai));
1351
            }
1352
        }
1353
    }
1354
    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)));
1355
    let mut used_r = vec![false; removed.len()];
1356
    let mut used_a = vec![false; added.len()];
1357
    let mut out = Vec::new();
1358
    for (_, ri, ai) in scored {
1359
        if used_r[ri] || used_a[ai] {
1360
            continue;
1361
        }
1362
        used_r[ri] = true;
1363
        used_a[ai] = true;
1364
        out.push((removed[ri].0.clone(), added[ai].0.clone()));
1365
    }
1366
    out
1367
}
1368
1369
/// Percentage of the larger file's bytes found in lines the two share
1370
/// (multiset intersection of lines), like Git's similarity index.
1371
pub fn similarity(a: &[u8], b: &[u8]) -> u32 {
1372
    if a == b {
1373
        return 100;
1374
    }
1375
    if a.is_empty() || b.is_empty() {
1376
        return 0;
1377
    }
1378
    let mut counts: HashMap<&[u8], usize> = HashMap::new();
1379
    for l in split_lines(a) {
1380
        *counts.entry(l).or_default() += 1;
1381
    }
1382
    let mut shared = 0usize;
1383
    for l in split_lines(b) {
1384
        if let Some(n) = counts.get_mut(l) {
1385
            if *n > 0 {
1386
                *n -= 1;
1387
                shared += l.len();
1388
            }
1389
        }
1390
    }
1391
    (shared * 100 / a.len().max(b.len())) as u32
1392
}
1393