OXYGENHUB
# 55e5a16a2b9c oxygen / crates / oxy-core / src / repo.rs
43.8 KiB · Rust · last changed Import the Oxygen specification and the first Rust implementation (root change) 1 month ago source blame history raw
1
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2
//! log, and the working-copy change (spec §12, §13.1).
3
//!
4
//! Not yet implemented from the spec: signatures and identities (the author
5
//! is `ObjectRef::ZERO`), packs, content-defined chunking (blobs use fixed
6
//! 256 KiB chunks), checkpoints, multiple workspaces, remotes, and resolving
7
//! conflicts by editing markers.
8
9
use crate::diff::{ranges, split_lines, text_edits};
10
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
11
use crate::ids::{ChangeId, NodeId, WorkspaceId};
12
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, TreeOp};
13
use crate::oplog::{Op, View, SNAPSHOT_SUFFIX};
14
use crate::state::{FileContent, Snapshot, State};
15
use crate::store::{Store, StoreError};
16
use crate::tree::OpKey;
17
use std::collections::{BTreeMap, BTreeSet, HashMap};
18
use std::fs;
19
use std::io::Write;
20
use std::path::{Path, PathBuf};
21
22
pub const OXY_DIR: &str = ".oxy";
23
pub const FORMAT_LINE: &str = "oxy repository format 1\n";
24
pub const CHUNK_SIZE: usize = 256 * 1024;
25
const BINARY_SNIFF: usize = 8 * 1024;
26
27
#[derive(Debug, thiserror::Error)]
28
pub enum RepoError {
29
    #[error("io: {0}")]
30
    Io(#[from] std::io::Error),
31
    #[error(transparent)]
32
    Store(#[from] StoreError),
33
    #[error(transparent)]
34
    Object(#[from] crate::objects::ObjectError),
35
    #[error(transparent)]
36
    State(#[from] crate::state::StateError),
37
    #[error("{0}")]
38
    Msg(String),
39
}
40
41
pub type Result<T> = std::result::Result<T, RepoError>;
42
43
fn fail<T>(s: impl Into<String>) -> Result<T> {
44
    Err(RepoError::Msg(s.into()))
45
}
46
47
/// Repository-local settings (`.oxy/config`, `key = value` lines).
48
#[derive(Clone, Debug, PartialEq, Eq)]
49
pub struct Config {
50
    pub author_name: String,
51
    pub author_email: String,
52
    pub bookmark: String,
53
}
54
55
impl Config {
56
    fn default_for_user() -> Config {
57
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
58
        Config {
59
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
60
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
61
            bookmark: "main".into(),
62
        }
63
    }
64
65
    fn load(path: &Path) -> Result<Config> {
66
        let mut c = Config::default_for_user();
67
        for line in fs::read_to_string(path)?.lines() {
68
            let line = line.trim();
69
            if line.is_empty() || line.starts_with('#') {
70
                continue;
71
            }
72
            let Some((k, v)) = line.split_once('=') else { continue };
73
            let v = v.trim().trim_matches('"').to_string();
74
            match k.trim() {
75
                "author.name" => c.author_name = v,
76
                "author.email" => c.author_email = v,
77
                "workspace.bookmark" => c.bookmark = v,
78
                _ => {}
79
            }
80
        }
81
        Ok(c)
82
    }
83
84
    fn save(&self, path: &Path) -> Result<()> {
85
        let text = format!(
86
            "author.name = \"{}\"\nauthor.email = \"{}\"\nworkspace.bookmark = \"{}\"\n",
87
            self.author_name, self.author_email, self.bookmark
88
        );
89
        write_atomic(path, text.as_bytes())
90
    }
91
}
92
93
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
94
    let tmp = path.with_extension("tmp");
95
    {
96
        let mut f = fs::File::create(&tmp)?;
97
        f.write_all(data)?;
98
        f.sync_all()?;
99
    }
100
    fs::rename(&tmp, path)?;
101
    Ok(())
102
}
103
104
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105
pub enum ChangeStatus {
106
    Added,
107
    Modified,
108
    Removed,
109
    ModeChanged,
110
    /// The file is conflicted in the base state and was edited in the working copy.
111
    ConflictEdited,
112
}
113
114
#[derive(Clone, Debug, PartialEq, Eq)]
115
pub struct PathStatus {
116
    pub path: String,
117
    pub status: ChangeStatus,
118
}
119
120
/// Result of snapshotting the working copy.
121
pub struct SnapshotResult {
122
    pub wc_hash: ChangeHash,
123
    pub wc: Change,
124
    /// The base state (closure of the working-copy change's dependencies).
125
    pub base: State,
126
    pub base_snapshot: Snapshot,
127
    pub statuses: Vec<PathStatus>,
128
    /// Whether the snapshot rewrote the working-copy change.
129
    pub changed: bool,
130
}
131
132
pub struct LogEntry {
133
    pub hash: ChangeHash,
134
    pub change: Change,
135
    pub is_working_copy: bool,
136
    pub is_head: bool,
137
}
138
139
struct FsEntry {
140
    kind: NodeKind,
141
    exec: bool,
142
    content: Vec<u8>,
143
}
144
145
pub struct Repo {
146
    root: PathBuf,
147
    oxy: PathBuf,
148
    store: Store,
149
    workspace: WorkspaceId,
150
    pub config: Config,
151
}
152
153
pub fn is_binary(content: &[u8]) -> bool {
154
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
155
}
156
157
impl Repo {
158
    // ------------------------------------------------------------ lifecycle
159
160
    pub fn init(root: &Path) -> Result<Repo> {
161
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
162
        let oxy = root.join(OXY_DIR);
163
        if oxy.exists() {
164
            return fail(format!("{} is already an oxy repository", root.display()));
165
        }
166
        fs::create_dir_all(oxy.join("ops"))?;
167
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
168
        let config = Config::default_for_user();
169
        config.save(&oxy.join("config"))?;
170
        let workspace = WorkspaceId::random();
171
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
172
        let store = Store::open(oxy.join("objects"))?;
173
        let repo = Repo { root, oxy, store, workspace, config };
174
175
        let wc = repo.new_working_copy(vec![], "");
176
        let wc_hash = repo.store.put_change(&wc)?;
177
        let mut view = View::default();
178
        view.bookmarks.insert(repo.config.bookmark.clone(), vec![]);
179
        view.workspaces.insert(workspace, wc_hash);
180
        repo.record_op(&[], "oxy init", view)?;
181
        Ok(repo)
182
    }
183
184
    /// Open the repository containing `start` (searching upwards).
185
    pub fn open(start: &Path) -> Result<Repo> {
186
        let start = start.canonicalize()?;
187
        let mut dir: Option<&Path> = Some(&start);
188
        while let Some(d) = dir {
189
            let oxy = d.join(OXY_DIR);
190
            if oxy.is_dir() {
191
                let format = fs::read_to_string(oxy.join("format"))?;
192
                if format != FORMAT_LINE {
193
                    return fail(format!("unsupported repository format: {}", format.trim()));
194
                }
195
                let config = Config::load(&oxy.join("config"))?;
196
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
197
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
198
                let store = Store::open(oxy.join("objects"))?;
199
                return Ok(Repo { root: d.to_path_buf(), oxy, store, workspace, config });
200
            }
201
            dir = d.parent();
202
        }
203
        fail(format!("no oxy repository found at or above {}", start.display()))
204
    }
205
206
    pub fn root(&self) -> &Path {
207
        &self.root
208
    }
209
    pub fn store(&self) -> &Store {
210
        &self.store
211
    }
212
    pub fn workspace(&self) -> WorkspaceId {
213
        self.workspace
214
    }
215
216
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
217
        let mut c = Change {
218
            change_id: ChangeId::random(),
219
            deps,
220
            author: ObjectRef::ZERO,
221
            hlc: Hlc::now(),
222
            message: message.into(),
223
            tree_ops: vec![],
224
            edits: vec![],
225
            kind: ChangeKind::WorkingCopy,
226
        };
227
        c.normalize();
228
        c
229
    }
230
231
    // ------------------------------------------------------------ operation log
232
233
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
234
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
235
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
236
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
237
        Ok((r, op))
238
    }
239
240
    pub fn view(&self) -> Result<View> {
241
        Ok(self.head_op()?.1.view)
242
    }
243
244
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
245
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
246
        let r = self.store.put(ObjectType::Op, &op.encode())?;
247
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
248
        Ok(r)
249
    }
250
251
    pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> {
252
        let mut out = Vec::new();
253
        let (mut r, mut op) = self.head_op()?;
254
        loop {
255
            let next = op.parents.first().copied();
256
            out.push((r, op));
257
            match next {
258
                Some(p) => {
259
                    r = p;
260
                    op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
261
                }
262
                None => break,
263
            }
264
        }
265
        Ok(out)
266
    }
267
268
    /// Undo the last user command (spec §12.1). Operations that only recorded
269
    /// a working-copy snapshot are skipped, since the files are still on disk
270
    /// and the next snapshot recreates them.
271
    pub fn undo(&self) -> Result<Op> {
272
        let (_, mut op) = self.head_op()?;
273
        while op.is_snapshot() {
274
            let Some(parent) = op.parents.first() else { break };
275
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
276
        }
277
        let Some(parent) = op.parents.first() else {
278
            return fail("nothing to undo");
279
        };
280
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
281
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
282
        Ok(parent_op)
283
    }
284
285
    /// The last operation that was not a snapshot.
286
    pub fn last_command(&self) -> Result<Op> {
287
        let (_, mut op) = self.head_op()?;
288
        while op.is_snapshot() {
289
            let Some(parent) = op.parents.first() else { break };
290
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
291
        }
292
        Ok(op)
293
    }
294
295
    // ------------------------------------------------------------ states
296
297
    /// Load the dependency closure of `heads` from the store.
298
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
299
        let mut state = State::new();
300
        let mut stack: Vec<ChangeHash> = heads.to_vec();
301
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
302
        while let Some(h) = stack.pop() {
303
            if !seen.insert(h) {
304
                continue;
305
            }
306
            let c = self.store.get_change(&h)?;
307
            stack.extend(c.deps.iter().copied());
308
            state.add_unchecked(c);
309
        }
310
        state.validate()?;
311
        Ok(state)
312
    }
313
314
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
315
        let view = self.view()?;
316
        let h = *view
317
            .workspaces
318
            .get(&self.workspace)
319
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
320
        Ok((h, self.store.get_change(&h)?))
321
    }
322
323
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
324
        Ok(self.view()?.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default())
325
    }
326
327
    // ------------------------------------------------------------ blobs
328
329
    fn blob_for(content: &[u8]) -> Blob {
330
        Blob {
331
            length: content.len() as u64,
332
            chunks: content.chunks(CHUNK_SIZE).map(|c| hash_object(ObjectType::Chunk, c)).collect(),
333
        }
334
    }
335
336
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
337
        Self::blob_for(content).hash()
338
    }
339
340
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
341
        for c in content.chunks(CHUNK_SIZE) {
342
            self.store.put_chunk(c)?;
343
        }
344
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
345
    }
346
347
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
348
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
349
        let mut out = Vec::with_capacity(blob.length as usize);
350
        for c in &blob.chunks {
351
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
352
        }
353
        Ok(out)
354
    }
355
356
    // ------------------------------------------------------------ working copy
357
358
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
359
        let mut wb = ignore::WalkBuilder::new(&self.root);
360
        wb.hidden(false)
361
            .git_ignore(false)
362
            .git_global(false)
363
            .git_exclude(false)
364
            .require_git(false)
365
            .follow_links(false)
366
            .add_custom_ignore_filename(".oxyignore");
367
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
368
        let mut out = BTreeMap::new();
369
        for entry in wb.build() {
370
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
371
            let path = entry.path();
372
            if path == self.root {
373
                continue;
374
            }
375
            let rel = path.strip_prefix(&self.root).unwrap();
376
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
377
            let Some(ft) = entry.file_type() else { continue };
378
            if ft.is_dir() {
379
                continue;
380
            }
381
            if ft.is_symlink() {
382
                let target = fs::read_link(path)?;
383
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
384
            } else if ft.is_file() {
385
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
386
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
387
            }
388
        }
389
        Ok(out)
390
    }
391
392
    /// Snapshot the working copy into the working-copy change (spec §12.3).
393
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
394
        let (op_ref, op) = self.head_op()?;
395
        let (old_hash, old_wc) = self.working_copy()?;
396
        let base = self.load_state(&old_wc.deps)?;
397
        let base_snapshot = base.materialize();
398
        let fs_entries = self.walk_working_copy()?;
399
400
        // Reuse the NodeIds the previous working-copy version assigned to new
401
        // paths, so an unchanged working copy hashes identically (spec §12.3).
402
        let reuse = added_node_paths(&old_wc, &base_snapshot);
403
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
404
        for (path, fs_entry) in &fs_entries {
405
            match base_snapshot.files.get(path) {
406
                None => b.add_path(self, path, fs_entry)?,
407
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
408
            }
409
        }
410
        for (path, expected) in &base_snapshot.files {
411
            if fs_entries.contains_key(path) {
412
                continue;
413
            }
414
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
415
            if !still_needed {
416
                b.remove_path(path, expected);
417
            }
418
        }
419
420
        let mut wc = Change {
421
            change_id: old_wc.change_id,
422
            deps: vec![],
423
            author: ObjectRef::ZERO,
424
            hlc: old_wc.hlc,
425
            message: old_wc.message.clone(),
426
            tree_ops: b.tree_ops,
427
            edits: b.edits,
428
            kind: ChangeKind::WorkingCopy,
429
        };
430
        let base_heads = base.maximal(&old_wc.deps);
431
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
432
        deps.extend(referenced_changes(&wc, &base));
433
        wc.deps = deps.into_iter().collect();
434
        wc.normalize();
435
436
        let mut statuses = b.statuses;
437
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
438
        // Empty snapshot: hash the candidate before storing; only record an op on change.
439
        let new_hash = wc.hash();
440
        let changed = new_hash != old_hash;
441
        if changed {
442
            self.store.put_change(&wc)?;
443
            let mut view = op.view.clone();
444
            view.workspaces.insert(self.workspace, new_hash);
445
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
446
        }
447
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed })
448
    }
449
450
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
451
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
452
        let snap = self.snapshot("oxy commit")?;
453
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
454
            return fail("nothing to commit: the working copy matches its base");
455
        }
456
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
457
            return fail("a conflicted file was edited; resolving conflicts is not implemented yet");
458
        }
459
        if message.trim().is_empty() {
460
            return fail("a commit message is required");
461
        }
462
        let (op_ref, op) = self.head_op()?;
463
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
464
        for d in extra_deps {
465
            if !snap.base.contains(d) {
466
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
467
            }
468
            deps.insert(*d);
469
        }
470
        let mut change = Change {
471
            change_id: snap.wc.change_id,
472
            deps: deps.into_iter().collect(),
473
            author: ObjectRef::ZERO,
474
            hlc: Hlc::now(),
475
            message: message.trim_end().to_string(),
476
            tree_ops: snap.wc.tree_ops.clone(),
477
            edits: snap.wc.edits.clone(),
478
            kind: ChangeKind::Normal,
479
        };
480
        change.normalize();
481
        let hash = self.store.put_change(&change)?;
482
483
        // New bookmark heads: maximal elements of old heads plus the new change.
484
        let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default();
485
        let mut with_new: Vec<ChangeHash> = old_heads.clone();
486
        with_new.push(hash);
487
        let mut st = self.load_state(&with_new)?;
488
        st.add(change)?;
489
        let new_heads = st.maximal(&with_new);
490
491
        let wc = self.new_working_copy(new_heads.clone(), "");
492
        let wc_hash = self.store.put_change(&wc)?;
493
        let mut view = op.view.clone();
494
        view.bookmarks.insert(self.config.bookmark.clone(), new_heads);
495
        view.workspaces.insert(self.workspace, wc_hash);
496
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
497
        Ok(hash)
498
    }
499
500
    /// Changes reachable from the bookmark and the working copy, newest first.
501
    pub fn log(&self) -> Result<Vec<LogEntry>> {
502
        let (wc_hash, _) = self.working_copy()?;
503
        let mut heads = self.bookmark_heads()?;
504
        heads.push(wc_hash);
505
        let state = self.load_state(&heads)?;
506
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
507
        let mut entries: Vec<LogEntry> = state
508
            .changes()
509
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
510
            .collect();
511
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
512
        Ok(entries)
513
    }
514
515
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
516
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
517
        if spec == "@" {
518
            return Ok(self.working_copy()?.0);
519
        }
520
        if spec.is_empty() || spec == "#" {
521
            return fail("empty change specifier");
522
        }
523
        let entries = self.log()?;
524
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
525
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
526
        } else {
527
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
528
        };
529
        match matches.len() {
530
            1 => Ok(matches[0].hash),
531
            0 => fail(format!("no change matches {spec:?}")),
532
            _ => {
533
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
534
                if ids.len() == 1 {
535
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
536
                } else {
537
                    fail(format!("{spec:?} is ambiguous"))
538
                }
539
            }
540
        }
541
    }
542
543
    /// Human-readable description of what a change does, with line content.
544
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
545
        let change = self.store.get_change(hash)?;
546
        let base = self.load_state(&change.deps)?;
547
        let base_snapshot = base.materialize();
548
        Ok(render_change(&change, &base, &base_snapshot))
549
    }
550
}
551
552
#[cfg(unix)]
553
fn is_executable(m: &fs::Metadata) -> bool {
554
    use std::os::unix::fs::PermissionsExt;
555
    m.permissions().mode() & 0o111 != 0
556
}
557
#[cfg(not(unix))]
558
fn is_executable(_m: &fs::Metadata) -> bool {
559
    false
560
}
561
562
fn op_ref(k: OpKey) -> OpRef {
563
    OpRef { change: k.0, index: k.1 }
564
}
565
566
fn split_path(path: &str) -> (&str, &str) {
567
    match path.rfind('/') {
568
        Some(i) => (&path[..i], &path[i + 1..]),
569
        None => ("", path),
570
    }
571
}
572
573
/// Builds the tree ops and edits of a working-copy change against a base.
574
struct ChangeBuilder<'a> {
575
    base: &'a State,
576
    base_snapshot: &'a Snapshot,
577
    tree_ops: Vec<TreeOp>,
578
    edits: Vec<Edit>,
579
    statuses: Vec<PathStatus>,
580
    dir_nodes: HashMap<String, NodeId>,
581
    reuse: HashMap<String, (NodeId, NodeKind)>,
582
}
583
584
impl<'a> ChangeBuilder<'a> {
585
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
586
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
587
        dir_nodes.insert(String::new(), NodeId::ROOT);
588
        for (p, f) in &base_snapshot.files {
589
            if f.kind == NodeKind::Dir {
590
                dir_nodes.insert(p.clone(), f.node);
591
            }
592
        }
593
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse }
594
    }
595
596
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
597
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
598
    }
599
600
    fn ensure_dir(&mut self, path: &str) -> NodeId {
601
        if let Some(n) = self.dir_nodes.get(path) {
602
            return *n;
603
        }
604
        let mut parent = NodeId::ROOT;
605
        let mut prefix = String::new();
606
        for comp in path.split('/') {
607
            if !prefix.is_empty() {
608
                prefix.push('/');
609
            }
610
            prefix.push_str(comp);
611
            parent = match self.dir_nodes.get(&prefix) {
612
                Some(n) => *n,
613
                None => {
614
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
615
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
616
                    self.dir_nodes.insert(prefix.clone(), node);
617
                    node
618
                }
619
            };
620
        }
621
        parent
622
    }
623
624
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
625
        let (dir, name) = split_path(path);
626
        let parent = self.ensure_dir(dir);
627
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
628
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
629
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
630
        if opaque {
631
            let blob = repo.store_blob(&e.content)?;
632
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
633
        } else if !e.content.is_empty() {
634
            self.edits.push(Edit::Insert {
635
                node,
636
                after: crate::objects::AtomRef::Start,
637
                before: crate::objects::AtomRef::End,
638
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
639
            });
640
        }
641
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
642
        Ok(())
643
    }
644
645
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
646
        let entry = &self.base_snapshot.tree.entries[path];
647
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
648
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
649
    }
650
651
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
652
        if expected.kind != e.kind {
653
            self.remove_path(path, expected);
654
            self.statuses.pop();
655
            self.add_path(repo, path, e)?;
656
            self.statuses.pop();
657
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
658
            return Ok(());
659
        }
660
        let entry = &self.base_snapshot.tree.entries[path];
661
        let node = expected.node;
662
        let mode_atom = entry.mode_atom.map(op_ref);
663
        let mut status: Option<ChangeStatus> = None;
664
        match &expected.content {
665
            FileContent::Dir => {}
666
            FileContent::Text { bytes, materialized } => {
667
                if materialized.is_conflicted() {
668
                    if bytes != &e.content {
669
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
670
                    }
671
                    return Ok(());
672
                }
673
                if bytes != &e.content {
674
                    if is_binary(&e.content) {
675
                        // Text → opaque (spec §7.5).
676
                        if let Some(old) = mode_atom {
677
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
678
                        }
679
                        let live: Vec<_> = repo_live_keys(self.base, &node);
680
                        if !live.is_empty() {
681
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
682
                        }
683
                        let blob = repo.store_blob(&e.content)?;
684
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
685
                        status = Some(ChangeStatus::Modified);
686
                    } else {
687
                        let old = self.base.live_lines(&node);
688
                        let edits = text_edits(node, &old, &e.content);
689
                        if !edits.is_empty() {
690
                            self.edits.extend(edits);
691
                            status = Some(ChangeStatus::Modified);
692
                        }
693
                    }
694
                }
695
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
696
                if !switched_to_opaque && e.exec != expected.mode.exec {
697
                    if let Some(old) = mode_atom {
698
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
699
                        status.get_or_insert(ChangeStatus::ModeChanged);
700
                    }
701
                }
702
            }
703
            FileContent::Opaque { blobs } => {
704
                let new_ref = Repo::blob_ref(&e.content);
705
                if blobs.len() > 1 {
706
                    if !blobs.contains(&new_ref) {
707
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
708
                    }
709
                    return Ok(());
710
                }
711
                let current = blobs.first().copied();
712
                if current != Some(new_ref) {
713
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
714
                        // Opaque → text (spec §7.5).
715
                        if let Some(old) = mode_atom {
716
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
717
                        }
718
                        self.edits.push(Edit::Replace { node, old: current, new: None });
719
                        if !e.content.is_empty() {
720
                            self.edits.push(Edit::Insert {
721
                                node,
722
                                after: crate::objects::AtomRef::Start,
723
                                before: crate::objects::AtomRef::End,
724
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
725
                            });
726
                        }
727
                    } else {
728
                        let blob = repo.store_blob(&e.content)?;
729
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
730
                    }
731
                    status = Some(ChangeStatus::Modified);
732
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
733
                    if let Some(old) = mode_atom {
734
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
735
                        status = Some(ChangeStatus::ModeChanged);
736
                    }
737
                }
738
            }
739
        }
740
        if let Some(s) = status {
741
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
742
        }
743
        Ok(())
744
    }
745
}
746
747
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
748
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
749
}
750
751
/// The changes a change's scope references (spec §8.1): introducers of every
752
/// atom, placement, and mode it touches, and of every node it operates on.
753
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
754
    let mut out = BTreeSet::new();
755
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
756
        if !r.change.is_zero() {
757
            out.insert(r.change);
758
        }
759
    };
760
    let mut nodes: Vec<NodeId> = Vec::new();
761
    for op in &change.tree_ops {
762
        match op {
763
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
764
            TreeOp::RemoveNode { node, placement } => {
765
                nodes.push(*node);
766
                opref(placement, &mut out);
767
            }
768
            TreeOp::Move { node, old, parent, .. } => {
769
                nodes.push(*node);
770
                nodes.push(*parent);
771
                opref(old, &mut out);
772
            }
773
            TreeOp::SetMode { node, old, .. } => {
774
                nodes.push(*node);
775
                opref(old, &mut out);
776
            }
777
        }
778
    }
779
    for e in &change.edits {
780
        nodes.push(e.node());
781
        match e {
782
            Edit::Insert { after, before, .. } => {
783
                for r in [after, before] {
784
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
785
                        if !change.is_zero() {
786
                            out.insert(*change);
787
                        }
788
                    }
789
                }
790
            }
791
            Edit::Delete { atoms, .. } => {
792
                for r in atoms {
793
                    if !r.change.is_zero() {
794
                        out.insert(r.change);
795
                    }
796
                }
797
            }
798
            Edit::Replace { .. } => {}
799
        }
800
    }
801
    for n in nodes {
802
        if let Some(h) = base.tree().introducer(&n) {
803
            out.insert(h);
804
        }
805
    }
806
    out
807
}
808
809
/// Paths of the nodes a change adds on top of `base_snapshot`.
810
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
811
    let by_node = base_snapshot.tree.by_node();
812
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
813
    for op in &change.tree_ops {
814
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
815
            new_names.insert(*node, (*parent, name.clone(), *kind));
816
        }
817
    }
818
    let mut out = HashMap::new();
819
    for (node, (_, _, kind)) in &new_names {
820
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
821
            out.insert(path, (*node, *kind));
822
        }
823
    }
824
    out
825
}
826
827
fn path_of_node(
828
    n: &NodeId,
829
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
830
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
831
) -> Option<String> {
832
    if let Some(e) = by_node.get(n) {
833
        return Some(e.path.clone());
834
    }
835
    let mut parts = Vec::new();
836
    let mut cur = *n;
837
    loop {
838
        if cur.is_root() {
839
            break;
840
        }
841
        if let Some(e) = by_node.get(&cur) {
842
            if !e.path.is_empty() {
843
                parts.push(e.path.clone());
844
            }
845
            break;
846
        }
847
        let (parent, name, _) = new_names.get(&cur)?;
848
        parts.push(name.clone());
849
        cur = *parent;
850
    }
851
    parts.reverse();
852
    Some(parts.join("/"))
853
}
854
855
/// Render a change's operations with line content, using `base` for deleted lines.
856
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
857
    let by_node = base_snapshot.tree.by_node();
858
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
859
    for op in &change.tree_ops {
860
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
861
            new_names.insert(*node, (*parent, name.clone(), *kind));
862
        }
863
    }
864
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
865
866
    let mut out = String::new();
867
    for op in &change.tree_ops {
868
        match op {
869
            TreeOp::AddNode { node, kind, mode, .. } => {
870
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
871
            }
872
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
873
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
874
            TreeOp::SetMode { node, mode, .. } => {
875
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
876
            }
877
        }
878
    }
879
    let mut last_node: Option<NodeId> = None;
880
    for e in &change.edits {
881
        let n = e.node();
882
        if last_node != Some(n) {
883
            out.push_str(&format!("--- {}\n", path_of(&n)));
884
            last_node = Some(n);
885
        }
886
        match e {
887
            Edit::Insert { lines, .. } => {
888
                for l in lines {
889
                    out.push('+');
890
                    out.push_str(&String::from_utf8_lossy(l));
891
                    if !l.ends_with(b"\n") {
892
                        out.push('\n');
893
                    }
894
                }
895
            }
896
            Edit::Delete { atoms, .. } => {
897
                for r in atoms {
898
                    for i in r.from..r.to {
899
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
900
                        out.push('-');
901
                        out.push_str(&String::from_utf8_lossy(content));
902
                        if !content.ends_with(b"\n") {
903
                            out.push('\n');
904
                        }
905
                    }
906
                }
907
            }
908
            Edit::Replace { old, new, .. } => {
909
                out.push_str(&format!(
910
                    "  opaque content {} -> {}\n",
911
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
912
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
913
                ));
914
            }
915
        }
916
    }
917
    out
918
}
919
920
#[cfg(test)]
921
mod tests {
922
    use super::*;
923
924
    fn write(root: &Path, rel: &str, content: &[u8]) {
925
        let p = root.join(rel);
926
        fs::create_dir_all(p.parent().unwrap()).unwrap();
927
        fs::write(p, content).unwrap();
928
    }
929
930
    fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
931
        s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
932
    }
933
934
    #[test]
935
    fn init_snapshot_commit_log_undo() {
936
        let dir = tempfile::tempdir().unwrap();
937
        let root = dir.path();
938
        let repo = Repo::init(root).unwrap();
939
        assert!(Repo::init(root).is_err());
940
        let ops = repo.op_log().unwrap();
941
        assert_eq!(ops.len(), 1);
942
        assert_eq!(ops[0].1.command, "oxy init");
943
944
        // Empty working copy: snapshot records no operation (spec §12.3).
945
        let s = repo.snapshot("oxy status").unwrap();
946
        assert!(!s.changed);
947
        assert_eq!(repo.op_log().unwrap().len(), 1);
948
949
        write(root, "src/main.rs", b"fn main() {}\n");
950
        write(root, "README.md", b"# Hello\n");
951
        write(root, ".oxyignore", b"/target\n");
952
        write(root, "target/junk.o", b"\0\0\0");
953
        let s = repo.snapshot("oxy status").unwrap();
954
        assert!(s.changed);
955
        assert_eq!(
956
            statuses(&s),
957
            vec![
958
                (".oxyignore".into(), ChangeStatus::Added),
959
                ("README.md".into(), ChangeStatus::Added),
960
                ("src/main.rs".into(), ChangeStatus::Added),
961
            ]
962
        );
963
        assert_eq!(repo.op_log().unwrap().len(), 2);
964
        // Snapshotting again with nothing changed records no op.
965
        let s2 = repo.snapshot("oxy status").unwrap();
966
        assert!(!s2.changed);
967
        assert_eq!(s2.wc_hash, s.wc_hash);
968
        assert_eq!(repo.op_log().unwrap().len(), 2);
969
970
        let h1 = repo.commit("Initial import", &[]).unwrap();
971
        let c1 = repo.store().get_change(&h1).unwrap();
972
        assert_eq!(c1.kind, ChangeKind::Normal);
973
        assert!(c1.deps.is_empty(), "first change has nothing to depend on");
974
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
975
        let (wc_hash, wc) = repo.working_copy().unwrap();
976
        assert_eq!(wc.deps, vec![h1]);
977
        assert!(wc.edits.is_empty());
978
        assert!(repo.commit("nothing", &[]).is_err());
979
980
        // Modify one file, remove another, add a binary file.
981
        write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
982
        fs::remove_file(root.join("README.md")).unwrap();
983
        write(root, "logo.png", b"\x89PNG\0\0\x01");
984
        let s = repo.snapshot("oxy status").unwrap();
985
        assert_eq!(
986
            statuses(&s),
987
            vec![
988
                ("README.md".into(), ChangeStatus::Removed),
989
                ("logo.png".into(), ChangeStatus::Added),
990
                ("src/main.rs".into(), ChangeStatus::Modified),
991
            ]
992
        );
993
        assert_ne!(s.wc_hash, wc_hash);
994
        // The rewritten working copy keeps its ChangeId and depends on h1.
995
        assert_eq!(s.wc.change_id, wc.change_id);
996
        assert!(s.wc.deps.contains(&h1));
997
998
        let h2 = repo.commit("Second", &[]).unwrap();
999
        let c2 = repo.store().get_change(&h2).unwrap();
1000
        assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
1001
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
1002
1003
        // The materialized head state matches the working copy.
1004
        let st = repo.load_state(&[h2]).unwrap();
1005
        let snap = st.materialize();
1006
        assert!(!snap.is_conflicted());
1007
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
1008
        assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
1009
        assert!(snap.files["logo.png"].mode.opaque);
1010
        if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
1011
            assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
1012
        } else {
1013
            panic!("logo should be opaque");
1014
        }
1015
1016
        // Log: newest first, working copy included.
1017
        let log = repo.log().unwrap();
1018
        assert_eq!(log.len(), 3);
1019
        assert!(log[0].is_working_copy);
1020
        assert_eq!(log[1].hash, h2);
1021
        assert!(log[1].is_head);
1022
        assert_eq!(log[2].hash, h1);
1023
        let d = repo.describe_change(&h2).unwrap();
1024
        assert!(d.contains("D README.md"), "{d}");
1025
        assert!(d.contains("+    println!(\"hi\");"), "{d}");
1026
        assert!(d.contains("-fn main() {}"), "{d}");
1027
1028
        // Resolution by prefix.
1029
        assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
1030
        assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
1031
        assert!(repo.resolve_change("zzzz").is_err());
1032
1033
        // Undo the commit: bookmark and working copy go back.
1034
        repo.undo().unwrap();
1035
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1036
        let s = repo.snapshot("oxy status").unwrap();
1037
        assert!(!s.changed, "files on disk still match the pre-commit working copy");
1038
        assert_eq!(s.statuses.len(), 3);
1039
    }
1040
1041
    #[test]
1042
    fn undo_skips_snapshot_operations() {
1043
        let dir = tempfile::tempdir().unwrap();
1044
        let root = dir.path();
1045
        let repo = Repo::init(root).unwrap();
1046
        write(root, "a.txt", b"a\n");
1047
        let h1 = repo.commit("a", &[]).unwrap();
1048
        // A later command snapshots new edits on top of the commit.
1049
        write(root, "b.txt", b"b\n");
1050
        let s = repo.snapshot("oxy status").unwrap();
1051
        assert!(s.changed);
1052
        assert!(repo.head_op().unwrap().1.is_snapshot());
1053
        assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\"");
1054
        // Undo must undo the commit, not the snapshot.
1055
        repo.undo().unwrap();
1056
        assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
1057
        let s = repo.snapshot("oxy status").unwrap();
1058
        let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
1059
        assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy");
1060
        assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
1061
        // Only snapshots remain above `init`, so there is nothing left to undo.
1062
        assert_eq!(repo.last_command().unwrap().command, "oxy init");
1063
        assert!(repo.undo().is_err());
1064
    }
1065
1066
    #[test]
1067
    fn independent_changes_commute_and_make_multiple_heads() {
1068
        let dir = tempfile::tempdir().unwrap();
1069
        let root = dir.path();
1070
        let repo = Repo::init(root).unwrap();
1071
        write(root, "a.txt", b"a\n");
1072
        let h1 = repo.commit("a", &[]).unwrap();
1073
        write(root, "b.txt", b"b\n");
1074
        let h2 = repo.commit("b", &[]).unwrap();
1075
        let c2 = repo.store().get_change(&h2).unwrap();
1076
        assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
1077
        let mut heads = repo.bookmark_heads().unwrap();
1078
        heads.sort();
1079
        let mut want = vec![h1, h2];
1080
        want.sort();
1081
        assert_eq!(heads, want);
1082
        // Editing a.txt depends on h1 only.
1083
        write(root, "a.txt", b"a\na2\n");
1084
        let h3 = repo.commit("a2", &[]).unwrap();
1085
        assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1086
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1087
        assert_eq!(heads, [h2, h3].into_iter().collect());
1088
        // Explicit dependency.
1089
        write(root, "c.txt", b"c\n");
1090
        let h4 = repo.commit("c", &[h2]).unwrap();
1091
        assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
1092
    }
1093
1094
    #[test]
1095
    fn mode_change_and_text_binary_switch() {
1096
        let dir = tempfile::tempdir().unwrap();
1097
        let root = dir.path();
1098
        let repo = Repo::init(root).unwrap();
1099
        write(root, "run.sh", b"#!/bin/sh\n");
1100
        repo.commit("add script", &[]).unwrap();
1101
        #[cfg(unix)]
1102
        {
1103
            use std::os::unix::fs::PermissionsExt;
1104
            fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1105
            let s = repo.snapshot("status").unwrap();
1106
            assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
1107
            let h = repo.commit("exec", &[]).unwrap();
1108
            let snap = repo.load_state(&[h]).unwrap().materialize();
1109
            assert!(snap.files["run.sh"].mode.exec);
1110
        }
1111
        // Text → binary → text.
1112
        write(root, "run.sh", b"\0binary");
1113
        let h = repo.commit("binary", &[]).unwrap();
1114
        let snap = repo.load_state(&[h]).unwrap().materialize();
1115
        assert!(snap.files["run.sh"].mode.opaque);
1116
        write(root, "run.sh", b"text again\n");
1117
        let h = repo.commit("text", &[]).unwrap();
1118
        let snap = repo.load_state(&[h]).unwrap().materialize();
1119
        assert!(!snap.files["run.sh"].mode.opaque);
1120
        assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
1121
        assert!(!snap.is_conflicted());
1122
    }
1123
}