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