OXYGENHUB
# 6d3eefbce2f0 oxygen / crates / oxy-core / src / repo / snapshot.rs
29.3 KiB · Rust · last changed Hooks: pre-commit, post-commit, pre-push, post-fetch, post-checkout with JSON payloads; semantic-deps hook behind hooks.semantic-deps (spec 0.6k) 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
        // pre-commit hook: may veto.
173
        self.run_hook(
174
            "pre-commit",
175
            &format!(
176
                "{{\"event\":\"pre-commit\",\"message\":{},\"paths\":{}}}",
177
                json_str(message),
178
                json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))))
179
            ),
180
        )?;
181
        let (op_ref, op) = self.head_op()?;
182
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
183
        // semantic-deps hook: may add explicit dependencies (spec §8.1).
184
        if self.config.semantic_deps {
185
            let touched: Vec<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
186
            let by_node = snap.base_snapshot.tree.by_node();
187
            let mut candidates: Vec<(ChangeId, ChangeHash, Vec<String>)> = Vec::new();
188
            let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
189
            for h in deps.iter().chain(snap.base.maximal(&snap.wc.deps).iter()) {
190
                if !seen.insert(*h) {
191
                    continue;
192
                }
193
                let c = self.store.get_change(h)?;
194
                let mut paths: BTreeSet<String> = BTreeSet::new();
195
                for n in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|e| e.node())) {
196
                    if let Some(e) = by_node.get(&n) {
197
                        paths.insert(e.path.clone());
198
                    }
199
                }
200
                candidates.push((c.change_id, *h, paths.into_iter().collect()));
201
            }
202
            for d in self.semantic_deps(&snap.wc.change_id, &touched, &candidates)? {
203
                if snap.base.contains(&d) {
204
                    deps.insert(d);
205
                }
206
            }
207
        }
208
        // A resolution depends on every change that contributed to the
209
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
210
        // ordinary operations whose references already cover the contributors.
211
        deps.extend(snap.contributors.iter().copied());
212
        for d in extra_deps {
213
            if !snap.base.contains(d) {
214
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
215
            }
216
            deps.insert(*d);
217
        }
218
        let mut change = Change {
219
            change_id: snap.wc.change_id,
220
            deps: deps.into_iter().collect(),
221
            author: self.author(),
222
            hlc: Hlc::now(),
223
            message: message.trim_end().to_string(),
224
            tree_ops: snap.wc.tree_ops.clone(),
225
            edits: snap.wc.edits.clone(),
226
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
227
        };
228
        change.normalize();
229
        let hash = self.store_signed(&change)?;
230
231
        // New bookmark heads: maximal elements of old heads plus the new change.
232
        // The working copy continues on its own base plus the new change, which
233
        // is exactly what the files on disk contain; the bookmark may hold
234
        // other heads (and conflicts) the working copy has not checked out.
235
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
236
        let old_base = snap.base.maximal(&snap.wc.deps);
237
        let mut all: Vec<ChangeHash> = old_heads.clone();
238
        all.extend(old_base.iter().copied());
239
        all.push(hash);
240
        let mut st = self.load_state(&all)?;
241
        st.add(change)?;
242
        let mut with_new = old_heads.clone();
243
        with_new.push(hash);
244
        let new_heads = st.maximal(&with_new);
245
        // Automatic checkpoint once enough changes accumulated (spec §11).
246
        let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0);
247
        if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval {
248
            let cp_state = self.load_state_full(&new_heads)?;
249
            self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?;
250
        }
251
        let mut wc_base = old_base.clone();
252
        wc_base.push(hash);
253
        let wc_base = st.maximal(&wc_base);
254
255
        let wc = self.new_working_copy(wc_base, "");
256
        let wc_hash = self.store.put_change(&wc)?;
257
        let mut view = op.view.clone();
258
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
259
        view.workspaces.insert(self.workspace, wc_hash);
260
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
261
        self.write_manifest(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
262
        self.maybe_autopack()?;
263
        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)));
264
        Ok(hash)
265
    }
266
}
267
268
/// Builds the tree ops and edits of a working-copy change against a base.
269
struct ChangeBuilder<'a> {
270
    base: &'a State,
271
    base_snapshot: &'a Snapshot,
272
    tree_ops: Vec<TreeOp>,
273
    edits: Vec<Edit>,
274
    statuses: Vec<PathStatus>,
275
    dir_nodes: HashMap<String, NodeId>,
276
    reuse: HashMap<String, (NodeId, NodeKind)>,
277
    /// Changes that contributed to conflicts this working copy resolves.
278
    contributors: BTreeSet<ChangeHash>,
279
    resolves: bool,
280
    attrs: crate::attributes::Attributes,
281
}
282
283
impl<'a> ChangeBuilder<'a> {
284
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
285
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
286
        dir_nodes.insert(String::new(), NodeId::ROOT);
287
        for (p, f) in &base_snapshot.files {
288
            if f.kind == NodeKind::Dir {
289
                dir_nodes.insert(p.clone(), f.node);
290
            }
291
        }
292
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false, attrs: crate::attributes::Attributes::default() }
293
    }
294
295
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
296
        self.attrs = attrs;
297
        self
298
    }
299
300
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
301
    /// only when it has no NUL bytes, else sniffed.
302
    fn opaque_for(&self, path: &str, e: &FsEntry) -> bool {
303
        if e.kind == NodeKind::Symlink {
304
            return true;
305
        }
306
        match self.attrs.lookup(path).binary {
307
            Some(true) => true,
308
            Some(false) => is_binary(&e.content),
309
            None => is_binary(&e.content),
310
        }
311
    }
312
313
    /// Text content as stored: `\r\n` normalized for `eol=` files.
314
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
315
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
316
    }
317
318
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
319
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
320
    }
321
322
    fn ensure_dir(&mut self, path: &str) -> NodeId {
323
        if let Some(n) = self.dir_nodes.get(path) {
324
            return *n;
325
        }
326
        let mut parent = NodeId::ROOT;
327
        let mut prefix = String::new();
328
        for comp in path.split('/') {
329
            if !prefix.is_empty() {
330
                prefix.push('/');
331
            }
332
            prefix.push_str(comp);
333
            parent = match self.dir_nodes.get(&prefix) {
334
                Some(n) => *n,
335
                None => {
336
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
337
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
338
                    self.dir_nodes.insert(prefix.clone(), node);
339
                    node
340
                }
341
            };
342
        }
343
        parent
344
    }
345
346
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
347
        let (dir, name) = split_path(path);
348
        let parent = self.ensure_dir(dir);
349
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
350
        let opaque = self.opaque_for(path, e);
351
        let nocompress = self.attrs.lookup(path).nocompress;
352
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
353
        if opaque {
354
            let blob = repo.store_blob_for(&e.content, nocompress)?;
355
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
356
        } else if !e.content.is_empty() {
357
            let content = self.stored_text(path, &e.content);
358
            self.edits.push(Edit::Insert {
359
                node,
360
                after: crate::objects::AtomRef::Start,
361
                before: crate::objects::AtomRef::End,
362
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
363
            });
364
        }
365
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
366
        Ok(())
367
    }
368
369
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
370
        let entry = &self.base_snapshot.tree.entries[path];
371
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
372
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
373
            self.resolves = true;
374
        }
375
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
376
    }
377
378
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
379
        if expected.kind != e.kind {
380
            self.remove_path(path, expected);
381
            self.statuses.pop();
382
            self.add_path(repo, path, e)?;
383
            self.statuses.pop();
384
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
385
            return Ok(());
386
        }
387
        let entry = &self.base_snapshot.tree.entries[path];
388
        let node = expected.node;
389
        let mode_atom = entry.mode_atom.map(op_ref);
390
        let mut status: Option<ChangeStatus> = None;
391
        let wants_opaque = self.opaque_for(path, e);
392
        let nocompress = self.attrs.lookup(path).nocompress;
393
        let disk_text = self.stored_text(path, &e.content);
394
        match &expected.content {
395
            FileContent::Dir => {}
396
            FileContent::Text { bytes, materialized } => {
397
                if materialized.is_conflicted() {
398
                    if bytes == &disk_text {
399
                        return Ok(());
400
                    }
401
                    if has_conflict_markers(&e.content) {
402
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
403
                        return Ok(());
404
                    }
405
                    // Resolution (spec §9.6): diff against the unconflicted lines
406
                    // with each region as a gap, then retire every region atom.
407
                    // Whatever the author kept from a region is re-inserted as
408
                    // new atoms, which leaves the graph totally ordered.
409
                    let regions = self.base.conflict_regions(&node);
410
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
411
                    if !regions.region_atoms.is_empty() {
412
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
413
                    }
414
                    self.edits.extend(edits);
415
                    self.contributors.extend(regions.contributors);
416
                    self.resolves = true;
417
                    if e.exec != expected.mode.exec {
418
                        if let Some(old) = mode_atom {
419
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
420
                        }
421
                    }
422
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
423
                    return Ok(());
424
                }
425
                if wants_opaque {
426
                    // Text → opaque (spec §7.5), by content or by attribute.
427
                    if let Some(old) = mode_atom {
428
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
429
                    }
430
                    let live: Vec<_> = repo_live_keys(self.base, &node);
431
                    if !live.is_empty() {
432
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
433
                    }
434
                    let blob = repo.store_blob_for(&e.content, nocompress)?;
435
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
436
                    status = Some(ChangeStatus::Modified);
437
                } else if bytes != &disk_text {
438
                    let old = self.base.live_lines(&node);
439
                    let edits = text_edits(node, &old, &disk_text);
440
                    if !edits.is_empty() {
441
                        self.edits.extend(edits);
442
                        status = Some(ChangeStatus::Modified);
443
                    }
444
                }
445
                let switched_to_opaque = wants_opaque;
446
                if !switched_to_opaque && e.exec != expected.mode.exec {
447
                    if let Some(old) = mode_atom {
448
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
449
                        status.get_or_insert(ChangeStatus::ModeChanged);
450
                    }
451
                }
452
            }
453
            FileContent::Opaque { blobs } => {
454
                let new_ref = Repo::blob_ref(&e.content);
455
                if blobs.len() > 1 {
456
                    if !blobs.contains(&new_ref) {
457
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
458
                    }
459
                    return Ok(());
460
                }
461
                let current = blobs.first().copied();
462
                if current != Some(new_ref) {
463
                    if e.kind == NodeKind::File && !wants_opaque {
464
                        // Opaque → text (spec §7.5).
465
                        if let Some(old) = mode_atom {
466
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
467
                        }
468
                        self.edits.push(Edit::Replace { node, old: current, new: None });
469
                        if !e.content.is_empty() {
470
                            self.edits.push(Edit::Insert {
471
                                node,
472
                                after: crate::objects::AtomRef::Start,
473
                                before: crate::objects::AtomRef::End,
474
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
475
                            });
476
                        }
477
                    } else {
478
                        let blob = repo.store_blob_for(&e.content, nocompress)?;
479
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
480
                    }
481
                    status = Some(ChangeStatus::Modified);
482
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
483
                    if let Some(old) = mode_atom {
484
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
485
                        status = Some(ChangeStatus::ModeChanged);
486
                    }
487
                }
488
            }
489
        }
490
        if let Some(s) = status {
491
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
492
        }
493
        Ok(())
494
    }
495
}
496
497
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
498
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
499
}
500
501
/// The changes a change's scope references (spec §8.1): introducers of every
502
/// atom, placement, and mode it touches, and of every node it operates on.
503
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
504
    let mut out = BTreeSet::new();
505
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
506
        if !r.change.is_zero() {
507
            out.insert(r.change);
508
        }
509
    };
510
    let mut nodes: Vec<NodeId> = Vec::new();
511
    for op in &change.tree_ops {
512
        match op {
513
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
514
            TreeOp::RemoveNode { node, placement } => {
515
                nodes.push(*node);
516
                opref(placement, &mut out);
517
            }
518
            TreeOp::Move { node, old, parent, .. } => {
519
                nodes.push(*node);
520
                nodes.push(*parent);
521
                opref(old, &mut out);
522
            }
523
            TreeOp::SetMode { node, old, .. } => {
524
                nodes.push(*node);
525
                opref(old, &mut out);
526
            }
527
        }
528
    }
529
    for e in &change.edits {
530
        nodes.push(e.node());
531
        match e {
532
            Edit::Insert { after, before, .. } => {
533
                for r in [after, before] {
534
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
535
                        if !change.is_zero() {
536
                            out.insert(*change);
537
                        }
538
                    }
539
                }
540
            }
541
            Edit::Delete { atoms, .. } => {
542
                for r in atoms {
543
                    if !r.change.is_zero() {
544
                        out.insert(r.change);
545
                    }
546
                }
547
            }
548
            Edit::Replace { .. } => {}
549
        }
550
    }
551
    for n in nodes {
552
        if let Some(h) = base.tree().introducer(&n) {
553
            out.insert(h);
554
        }
555
    }
556
    out
557
}
558
559
/// Paths of the nodes a change adds on top of `base_snapshot`.
560
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
561
    let by_node = base_snapshot.tree.by_node();
562
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
563
    for op in &change.tree_ops {
564
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
565
            new_names.insert(*node, (*parent, name.clone(), *kind));
566
        }
567
    }
568
    let mut out = HashMap::new();
569
    for (node, (_, _, kind)) in &new_names {
570
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
571
            out.insert(path, (*node, *kind));
572
        }
573
    }
574
    out
575
}
576
577
fn path_of_node(
578
    n: &NodeId,
579
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
580
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
581
) -> Option<String> {
582
    if let Some(e) = by_node.get(n) {
583
        return Some(e.path.clone());
584
    }
585
    let mut parts = Vec::new();
586
    let mut cur = *n;
587
    loop {
588
        if cur.is_root() {
589
            break;
590
        }
591
        if let Some(e) = by_node.get(&cur) {
592
            if !e.path.is_empty() {
593
                parts.push(e.path.clone());
594
            }
595
            break;
596
        }
597
        let (parent, name, _) = new_names.get(&cur)?;
598
        parts.push(name.clone());
599
        cur = *parent;
600
    }
601
    parts.reverse();
602
    Some(parts.join("/"))
603
}
604
605
/// Render a change's operations with line content, using `base` for deleted lines.
606
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
607
    let by_node = base_snapshot.tree.by_node();
608
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
609
    for op in &change.tree_ops {
610
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
611
            new_names.insert(*node, (*parent, name.clone(), *kind));
612
        }
613
    }
614
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
615
616
    let mut out = String::new();
617
    for op in &change.tree_ops {
618
        match op {
619
            TreeOp::AddNode { node, kind, mode, .. } => {
620
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
621
            }
622
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
623
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
624
            TreeOp::SetMode { node, mode, .. } => {
625
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
626
            }
627
        }
628
    }
629
    let mut last_node: Option<NodeId> = None;
630
    for e in &change.edits {
631
        let n = e.node();
632
        if last_node != Some(n) {
633
            out.push_str(&format!("--- {}\n", path_of(&n)));
634
            last_node = Some(n);
635
        }
636
        match e {
637
            Edit::Insert { lines, .. } => {
638
                for l in lines {
639
                    out.push('+');
640
                    out.push_str(&String::from_utf8_lossy(l));
641
                    if !l.ends_with(b"\n") {
642
                        out.push('\n');
643
                    }
644
                }
645
            }
646
            Edit::Delete { atoms, .. } => {
647
                for r in atoms {
648
                    for i in r.from..r.to {
649
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
650
                        out.push('-');
651
                        out.push_str(&String::from_utf8_lossy(content));
652
                        if !content.ends_with(b"\n") {
653
                            out.push('\n');
654
                        }
655
                    }
656
                }
657
            }
658
            Edit::Replace { old, new, .. } => {
659
                out.push_str(&format!(
660
                    "  opaque content {} -> {}\n",
661
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
662
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
663
                ));
664
            }
665
        }
666
    }
667
    out
668
}