OXYGENHUB
# cdb5beba7f05 oxygen / crates / oxy-core / src / repo / snapshot.rs
27.4 KiB · Rust · last changed .oxyattributes: binary, text, eol=lf|crlf, nocompress (raw chunks in packs), lockable with advisory local oxy lock/unlock (spec 0.6j) 1 month ago source blame history raw
1
//! Working-copy snapshots, commits, and the change builder (spec §12.3).
2
3
use super::*;
4
5
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6
pub enum ChangeStatus {
7
    Added,
8
    Modified,
9
    Removed,
10
    ModeChanged,
11
    /// The file is conflicted in the base state and still contains conflict markers.
12
    ConflictEdited,
13
    /// The file was conflicted in the base state and the working copy resolves it.
14
    Resolved,
15
}
16
17
/// Lines that mark a conflict region in a materialized file (spec §9.5).
18
pub fn has_conflict_markers(content: &[u8]) -> bool {
19
    split_lines(content).iter().any(|l| {
20
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
21
    })
22
}
23
24
#[derive(Clone, Debug, PartialEq, Eq)]
25
pub struct PathStatus {
26
    pub path: String,
27
    pub status: ChangeStatus,
28
}
29
30
/// Operations of a change built from the working directory against a base.
31
pub(crate) struct BuiltChange {
32
    pub tree_ops: Vec<TreeOp>,
33
    pub edits: Vec<Edit>,
34
    pub contributors: Vec<ChangeHash>,
35
    pub resolves: bool,
36
    pub statuses: Vec<PathStatus>,
37
}
38
39
/// Result of snapshotting the working copy.
40
pub struct SnapshotResult {
41
    pub wc_hash: ChangeHash,
42
    pub wc: Change,
43
    /// The base state (closure of the working-copy change's dependencies).
44
    pub base: State,
45
    pub base_snapshot: Snapshot,
46
    pub statuses: Vec<PathStatus>,
47
    /// Whether the snapshot rewrote the working-copy change.
48
    pub changed: bool,
49
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
50
    pub resolves: bool,
51
    /// Changes that contributed to the conflicts being resolved.
52
    pub contributors: Vec<ChangeHash>,
53
}
54
55
pub fn is_binary(content: &[u8]) -> bool {
56
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
57
}
58
59
impl Repo {
60
    /// Diff the files on disk against `base` and build the operations of a
61
    /// change from `base` to the working directory. `reuse` maps paths to the
62
    /// NodeIds an earlier version of the same change assigned to new paths.
63
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
64
        let fs_entries = self.walk_working_copy()?;
65
        self.build_from_entries(base, base_snapshot, reuse, &fs_entries)
66
    }
67
68
    /// Like `build_against`, for files given in memory (imports).
69
    pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>, fs_entries: &BTreeMap<String, FsEntry>) -> Result<BuiltChange> {
70
        // Attributes come from the files being committed, so a new
71
        // `.oxyattributes` applies to the same change that adds it.
72
        let attrs = crate::attributes::Attributes::from_files(
73
            fs_entries.iter().filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes")).map(|(p, e)| (p.as_str(), e.content.as_slice())),
74
        );
75
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
76
        for (path, fs_entry) in fs_entries {
77
            match base_snapshot.files.get(path) {
78
                None => b.add_path(self, path, fs_entry)?,
79
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
80
            }
81
        }
82
        for (path, expected) in &base_snapshot.files {
83
            if fs_entries.contains_key(path) || !self.in_scope(path) {
84
                continue;
85
            }
86
            // A directory stays while anything under it exists or is out of scope.
87
            let still_needed = expected.kind == NodeKind::Dir
88
                && (fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")))
89
                    || base_snapshot.files.keys().any(|p| p.starts_with(&format!("{path}/")) && !self.in_scope(p)));
90
            if !still_needed {
91
                b.remove_path(path, expected);
92
            }
93
        }
94
        let mut statuses = b.statuses;
95
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
96
        Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses })
97
    }
98
99
    /// Store a change and, when `core.sign = change` and we are its author,
100
    /// sign it. A rewritten change by another author is stored unsigned: only
101
    /// its author (or a delegate) can vouch for it.
102
    pub(crate) fn store_signed(&self, change: &Change) -> Result<ChangeHash> {
103
        let ours = change.author == self.author();
104
        if self.config.sign_changes && ours && self.secret_key()?.is_none() {
105
            return fail("core.sign is \"change\" but no secret key is available for author.key");
106
        }
107
        let hash = self.store.put_change(change)?;
108
        if self.config.sign_changes && ours {
109
            self.sign_change(&hash)?;
110
        }
111
        Ok(hash)
112
    }
113
114
    /// Snapshot the working copy into the working-copy change (spec §12.3).
115
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
116
        let _lock = self.lock_snapshot()?;
117
        let (op_ref, op) = self.head_op()?;
118
        let (old_hash, old_wc) = self.working_copy()?;
119
        let base = self.load_state(&old_wc.deps)?;
120
        let base_snapshot = base.materialize();
121
122
        // Reuse the NodeIds the previous working-copy version assigned to new
123
        // paths, so an unchanged working copy hashes identically (spec §12.3).
124
        let reuse = added_node_paths(&old_wc, &base_snapshot);
125
        let built = self.build_against(&base, &base_snapshot, reuse)?;
126
127
        let mut wc = Change {
128
            change_id: old_wc.change_id,
129
            deps: vec![],
130
            author: self.author(),
131
            hlc: old_wc.hlc,
132
            message: old_wc.message.clone(),
133
            tree_ops: built.tree_ops,
134
            edits: built.edits,
135
            kind: ChangeKind::WorkingCopy,
136
        };
137
        let base_heads = base.maximal(&old_wc.deps);
138
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
139
        deps.extend(referenced_changes(&wc, &base));
140
        deps.extend(built.contributors.iter().copied());
141
        wc.deps = deps.into_iter().collect();
142
        wc.normalize();
143
        let resolves = built.resolves;
144
        let contributors = built.contributors;
145
        let statuses = built.statuses;
146
        // Empty snapshot: hash the candidate before storing; only record an op on change.
147
        let new_hash = wc.hash();
148
        let changed = new_hash != old_hash;
149
        if changed {
150
            self.store.put_change(&wc)?;
151
            let mut view = op.view.clone();
152
            view.workspaces.insert(self.workspace, new_hash);
153
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
154
        }
155
        // The prompt compares against this without recording anything.
156
        self.write_manifest(base_snapshot.is_conflicted(), !wc.tree_ops.is_empty() || !wc.edits.is_empty())?;
157
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
158
    }
159
160
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
161
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
162
        let snap = self.snapshot("oxy commit")?;
163
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
164
            return fail("nothing to commit: the working copy matches its base");
165
        }
166
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
167
            return fail(format!("{} still contains conflict markers", s.path));
168
        }
169
        if message.trim().is_empty() {
170
            return fail("a commit message is required");
171
        }
172
        let (op_ref, op) = self.head_op()?;
173
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
174
        // A resolution depends on every change that contributed to the
175
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
176
        // ordinary operations whose references already cover the contributors.
177
        deps.extend(snap.contributors.iter().copied());
178
        for d in extra_deps {
179
            if !snap.base.contains(d) {
180
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
181
            }
182
            deps.insert(*d);
183
        }
184
        let mut change = Change {
185
            change_id: snap.wc.change_id,
186
            deps: deps.into_iter().collect(),
187
            author: self.author(),
188
            hlc: Hlc::now(),
189
            message: message.trim_end().to_string(),
190
            tree_ops: snap.wc.tree_ops.clone(),
191
            edits: snap.wc.edits.clone(),
192
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
193
        };
194
        change.normalize();
195
        let hash = self.store_signed(&change)?;
196
197
        // New bookmark heads: maximal elements of old heads plus the new change.
198
        // The working copy continues on its own base plus the new change, which
199
        // is exactly what the files on disk contain; the bookmark may hold
200
        // other heads (and conflicts) the working copy has not checked out.
201
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
202
        let old_base = snap.base.maximal(&snap.wc.deps);
203
        let mut all: Vec<ChangeHash> = old_heads.clone();
204
        all.extend(old_base.iter().copied());
205
        all.push(hash);
206
        let mut st = self.load_state(&all)?;
207
        st.add(change)?;
208
        let mut with_new = old_heads.clone();
209
        with_new.push(hash);
210
        let new_heads = st.maximal(&with_new);
211
        // Automatic checkpoint once enough changes accumulated (spec §11).
212
        let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0);
213
        if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval {
214
            let cp_state = self.load_state_full(&new_heads)?;
215
            self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?;
216
        }
217
        let mut wc_base = old_base.clone();
218
        wc_base.push(hash);
219
        let wc_base = st.maximal(&wc_base);
220
221
        let wc = self.new_working_copy(wc_base, "");
222
        let wc_hash = self.store.put_change(&wc)?;
223
        let mut view = op.view.clone();
224
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
225
        view.workspaces.insert(self.workspace, wc_hash);
226
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
227
        self.write_manifest(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
228
        self.maybe_autopack()?;
229
        Ok(hash)
230
    }
231
}
232
233
/// Builds the tree ops and edits of a working-copy change against a base.
234
struct ChangeBuilder<'a> {
235
    base: &'a State,
236
    base_snapshot: &'a Snapshot,
237
    tree_ops: Vec<TreeOp>,
238
    edits: Vec<Edit>,
239
    statuses: Vec<PathStatus>,
240
    dir_nodes: HashMap<String, NodeId>,
241
    reuse: HashMap<String, (NodeId, NodeKind)>,
242
    /// Changes that contributed to conflicts this working copy resolves.
243
    contributors: BTreeSet<ChangeHash>,
244
    resolves: bool,
245
    attrs: crate::attributes::Attributes,
246
}
247
248
impl<'a> ChangeBuilder<'a> {
249
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
250
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
251
        dir_nodes.insert(String::new(), NodeId::ROOT);
252
        for (p, f) in &base_snapshot.files {
253
            if f.kind == NodeKind::Dir {
254
                dir_nodes.insert(p.clone(), f.node);
255
            }
256
        }
257
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false, attrs: crate::attributes::Attributes::default() }
258
    }
259
260
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
261
        self.attrs = attrs;
262
        self
263
    }
264
265
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
266
    /// only when it has no NUL bytes, else sniffed.
267
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
268
        if e.kind == NodeKind::Symlink {
269
            return true;
270
        }
271
        match self.attrs.lookup(path).binary {
272
            Some(true) => true,
273
            Some(false) => is_binary(&e.content),
274
            None => is_binary(&e.content),
275
        }
276
    }
277
278
    /// Text content as stored: `\r\n` normalized for `eol=` files.
279
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
280
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
281
    }
282
283
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
284
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
285
    }
286
287
    fn ensure_dir(&mut self, path: &str) -> NodeId {
288
        if let Some(n) = self.dir_nodes.get(path) {
289
            return *n;
290
        }
291
        let mut parent = NodeId::ROOT;
292
        let mut prefix = String::new();
293
        for comp in path.split('/') {
294
            if !prefix.is_empty() {
295
                prefix.push('/');
296
            }
297
            prefix.push_str(comp);
298
            parent = match self.dir_nodes.get(&prefix) {
299
                Some(n) => *n,
300
                None => {
301
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
302
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
303
                    self.dir_nodes.insert(prefix.clone(), node);
304
                    node
305
                }
306
            };
307
        }
308
        parent
309
    }
310
311
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
312
        let (dir, name) = split_path(path);
313
        let parent = self.ensure_dir(dir);
314
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
315
        let opaque = self.opaque_for(path, e);
316
        let nocompress = self.attrs.lookup(path).nocompress;
317
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
318
        if opaque {
319
            let blob = repo.store_blob_for(&e.content, nocompress)?;
320
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
321
        } else if !e.content.is_empty() {
322
            let content = self.stored_text(path, &e.content);
323
            self.edits.push(Edit::Insert {
324
                node,
325
                after: crate::objects::AtomRef::Start,
326
                before: crate::objects::AtomRef::End,
327
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
328
            });
329
        }
330
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
331
        Ok(())
332
    }
333
334
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
335
        let entry = &self.base_snapshot.tree.entries[path];
336
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
337
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
338
            self.resolves = true;
339
        }
340
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
341
    }
342
343
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
344
        if expected.kind != e.kind {
345
            self.remove_path(path, expected);
346
            self.statuses.pop();
347
            self.add_path(repo, path, e)?;
348
            self.statuses.pop();
349
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
350
            return Ok(());
351
        }
352
        let entry = &self.base_snapshot.tree.entries[path];
353
        let node = expected.node;
354
        let mode_atom = entry.mode_atom.map(op_ref);
355
        let mut status: Option<ChangeStatus> = None;
356
        let wants_opaque = self.opaque_for(path, e);
357
        let nocompress = self.attrs.lookup(path).nocompress;
358
        let disk_text = self.stored_text(path, &e.content);
359
        match &expected.content {
360
            FileContent::Dir => {}
361
            FileContent::Text { bytes, materialized } => {
362
                if materialized.is_conflicted() {
363
                    if bytes == &disk_text {
364
                        return Ok(());
365
                    }
366
                    if has_conflict_markers(&e.content) {
367
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
368
                        return Ok(());
369
                    }
370
                    // Resolution (spec §9.6): diff against the unconflicted lines
371
                    // with each region as a gap, then retire every region atom.
372
                    // Whatever the author kept from a region is re-inserted as
373
                    // new atoms, which leaves the graph totally ordered.
374
                    let regions = self.base.conflict_regions(&node);
375
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
376
                    if !regions.region_atoms.is_empty() {
377
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
378
                    }
379
                    self.edits.extend(edits);
380
                    self.contributors.extend(regions.contributors);
381
                    self.resolves = true;
382
                    if e.exec != expected.mode.exec {
383
                        if let Some(old) = mode_atom {
384
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
385
                        }
386
                    }
387
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
388
                    return Ok(());
389
                }
390
                if wants_opaque {
391
                    // Text → opaque (spec §7.5), by content or by attribute.
392
                    if let Some(old) = mode_atom {
393
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
394
                    }
395
                    let live: Vec<_> = repo_live_keys(self.base, &node);
396
                    if !live.is_empty() {
397
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
398
                    }
399
                    let blob = repo.store_blob_for(&e.content, nocompress)?;
400
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
401
                    status = Some(ChangeStatus::Modified);
402
                } else if bytes != &disk_text {
403
                    let old = self.base.live_lines(&node);
404
                    let edits = text_edits(node, &old, &disk_text);
405
                    if !edits.is_empty() {
406
                        self.edits.extend(edits);
407
                        status = Some(ChangeStatus::Modified);
408
                    }
409
                }
410
                let switched_to_opaque = wants_opaque;
411
                if !switched_to_opaque && e.exec != expected.mode.exec {
412
                    if let Some(old) = mode_atom {
413
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
414
                        status.get_or_insert(ChangeStatus::ModeChanged);
415
                    }
416
                }
417
            }
418
            FileContent::Opaque { blobs } => {
419
                let new_ref = Repo::blob_ref(&e.content);
420
                if blobs.len() > 1 {
421
                    if !blobs.contains(&new_ref) {
422
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
423
                    }
424
                    return Ok(());
425
                }
426
                let current = blobs.first().copied();
427
                if current != Some(new_ref) {
428
                    if e.kind == NodeKind::File && !wants_opaque {
429
                        // Opaque → text (spec §7.5).
430
                        if let Some(old) = mode_atom {
431
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
432
                        }
433
                        self.edits.push(Edit::Replace { node, old: current, new: None });
434
                        if !e.content.is_empty() {
435
                            self.edits.push(Edit::Insert {
436
                                node,
437
                                after: crate::objects::AtomRef::Start,
438
                                before: crate::objects::AtomRef::End,
439
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
440
                            });
441
                        }
442
                    } else {
443
                        let blob = repo.store_blob_for(&e.content, nocompress)?;
444
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
445
                    }
446
                    status = Some(ChangeStatus::Modified);
447
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
448
                    if let Some(old) = mode_atom {
449
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
450
                        status = Some(ChangeStatus::ModeChanged);
451
                    }
452
                }
453
            }
454
        }
455
        if let Some(s) = status {
456
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
457
        }
458
        Ok(())
459
    }
460
}
461
462
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
463
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
464
}
465
466
/// The changes a change's scope references (spec §8.1): introducers of every
467
/// atom, placement, and mode it touches, and of every node it operates on.
468
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
469
    let mut out = BTreeSet::new();
470
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
471
        if !r.change.is_zero() {
472
            out.insert(r.change);
473
        }
474
    };
475
    let mut nodes: Vec<NodeId> = Vec::new();
476
    for op in &change.tree_ops {
477
        match op {
478
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
479
            TreeOp::RemoveNode { node, placement } => {
480
                nodes.push(*node);
481
                opref(placement, &mut out);
482
            }
483
            TreeOp::Move { node, old, parent, .. } => {
484
                nodes.push(*node);
485
                nodes.push(*parent);
486
                opref(old, &mut out);
487
            }
488
            TreeOp::SetMode { node, old, .. } => {
489
                nodes.push(*node);
490
                opref(old, &mut out);
491
            }
492
        }
493
    }
494
    for e in &change.edits {
495
        nodes.push(e.node());
496
        match e {
497
            Edit::Insert { after, before, .. } => {
498
                for r in [after, before] {
499
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
500
                        if !change.is_zero() {
501
                            out.insert(*change);
502
                        }
503
                    }
504
                }
505
            }
506
            Edit::Delete { atoms, .. } => {
507
                for r in atoms {
508
                    if !r.change.is_zero() {
509
                        out.insert(r.change);
510
                    }
511
                }
512
            }
513
            Edit::Replace { .. } => {}
514
        }
515
    }
516
    for n in nodes {
517
        if let Some(h) = base.tree().introducer(&n) {
518
            out.insert(h);
519
        }
520
    }
521
    out
522
}
523
524
/// Paths of the nodes a change adds on top of `base_snapshot`.
525
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
526
    let by_node = base_snapshot.tree.by_node();
527
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
528
    for op in &change.tree_ops {
529
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
530
            new_names.insert(*node, (*parent, name.clone(), *kind));
531
        }
532
    }
533
    let mut out = HashMap::new();
534
    for (node, (_, _, kind)) in &new_names {
535
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
536
            out.insert(path, (*node, *kind));
537
        }
538
    }
539
    out
540
}
541
542
fn path_of_node(
543
    n: &NodeId,
544
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
545
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
546
) -> Option<String> {
547
    if let Some(e) = by_node.get(n) {
548
        return Some(e.path.clone());
549
    }
550
    let mut parts = Vec::new();
551
    let mut cur = *n;
552
    loop {
553
        if cur.is_root() {
554
            break;
555
        }
556
        if let Some(e) = by_node.get(&cur) {
557
            if !e.path.is_empty() {
558
                parts.push(e.path.clone());
559
            }
560
            break;
561
        }
562
        let (parent, name, _) = new_names.get(&cur)?;
563
        parts.push(name.clone());
564
        cur = *parent;
565
    }
566
    parts.reverse();
567
    Some(parts.join("/"))
568
}
569
570
/// Render a change's operations with line content, using `base` for deleted lines.
571
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
572
    let by_node = base_snapshot.tree.by_node();
573
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
574
    for op in &change.tree_ops {
575
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
576
            new_names.insert(*node, (*parent, name.clone(), *kind));
577
        }
578
    }
579
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
580
581
    let mut out = String::new();
582
    for op in &change.tree_ops {
583
        match op {
584
            TreeOp::AddNode { node, kind, mode, .. } => {
585
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
586
            }
587
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
588
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
589
            TreeOp::SetMode { node, mode, .. } => {
590
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
591
            }
592
        }
593
    }
594
    let mut last_node: Option<NodeId> = None;
595
    for e in &change.edits {
596
        let n = e.node();
597
        if last_node != Some(n) {
598
            out.push_str(&format!("--- {}\n", path_of(&n)));
599
            last_node = Some(n);
600
        }
601
        match e {
602
            Edit::Insert { lines, .. } => {
603
                for l in lines {
604
                    out.push('+');
605
                    out.push_str(&String::from_utf8_lossy(l));
606
                    if !l.ends_with(b"\n") {
607
                        out.push('\n');
608
                    }
609
                }
610
            }
611
            Edit::Delete { atoms, .. } => {
612
                for r in atoms {
613
                    for i in r.from..r.to {
614
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
615
                        out.push('-');
616
                        out.push_str(&String::from_utf8_lossy(content));
617
                        if !content.ends_with(b"\n") {
618
                            out.push('\n');
619
                        }
620
                    }
621
                }
622
            }
623
            Edit::Replace { old, new, .. } => {
624
                out.push_str(&format!(
625
                    "  opaque content {} -> {}\n",
626
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
627
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
628
                ));
629
            }
630
        }
631
    }
632
    out
633
}