OXYGENHUB
# c8f92cc5f89e oxygen / crates / oxy-core / src / repo.rs
41.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};
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
    /// Move to the parent operation (spec §12.1).
269
    pub fn undo(&self) -> Result<Op> {
270
        let (_, op) = self.head_op()?;
271
        let Some(parent) = op.parents.first() else {
272
            return fail("nothing to undo");
273
        };
274
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
275
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
276
        Ok(parent_op)
277
    }
278
279
    // ------------------------------------------------------------ states
280
281
    /// Load the dependency closure of `heads` from the store.
282
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
283
        let mut state = State::new();
284
        let mut stack: Vec<ChangeHash> = heads.to_vec();
285
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
286
        while let Some(h) = stack.pop() {
287
            if !seen.insert(h) {
288
                continue;
289
            }
290
            let c = self.store.get_change(&h)?;
291
            stack.extend(c.deps.iter().copied());
292
            state.add_unchecked(c);
293
        }
294
        state.validate()?;
295
        Ok(state)
296
    }
297
298
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
299
        let view = self.view()?;
300
        let h = *view
301
            .workspaces
302
            .get(&self.workspace)
303
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
304
        Ok((h, self.store.get_change(&h)?))
305
    }
306
307
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
308
        Ok(self.view()?.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default())
309
    }
310
311
    // ------------------------------------------------------------ blobs
312
313
    fn blob_for(content: &[u8]) -> Blob {
314
        Blob {
315
            length: content.len() as u64,
316
            chunks: content.chunks(CHUNK_SIZE).map(|c| hash_object(ObjectType::Chunk, c)).collect(),
317
        }
318
    }
319
320
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
321
        Self::blob_for(content).hash()
322
    }
323
324
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
325
        for c in content.chunks(CHUNK_SIZE) {
326
            self.store.put_chunk(c)?;
327
        }
328
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
329
    }
330
331
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
332
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
333
        let mut out = Vec::with_capacity(blob.length as usize);
334
        for c in &blob.chunks {
335
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
336
        }
337
        Ok(out)
338
    }
339
340
    // ------------------------------------------------------------ working copy
341
342
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
343
        let mut wb = ignore::WalkBuilder::new(&self.root);
344
        wb.hidden(false)
345
            .git_ignore(false)
346
            .git_global(false)
347
            .git_exclude(false)
348
            .require_git(false)
349
            .follow_links(false)
350
            .add_custom_ignore_filename(".oxyignore");
351
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
352
        let mut out = BTreeMap::new();
353
        for entry in wb.build() {
354
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
355
            let path = entry.path();
356
            if path == self.root {
357
                continue;
358
            }
359
            let rel = path.strip_prefix(&self.root).unwrap();
360
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
361
            let Some(ft) = entry.file_type() else { continue };
362
            if ft.is_dir() {
363
                continue;
364
            }
365
            if ft.is_symlink() {
366
                let target = fs::read_link(path)?;
367
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
368
            } else if ft.is_file() {
369
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
370
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
371
            }
372
        }
373
        Ok(out)
374
    }
375
376
    /// Snapshot the working copy into the working-copy change (spec §12.3).
377
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
378
        let (op_ref, op) = self.head_op()?;
379
        let (old_hash, old_wc) = self.working_copy()?;
380
        let base = self.load_state(&old_wc.deps)?;
381
        let base_snapshot = base.materialize();
382
        let fs_entries = self.walk_working_copy()?;
383
384
        // Reuse the NodeIds the previous working-copy version assigned to new
385
        // paths, so an unchanged working copy hashes identically (spec §12.3).
386
        let reuse = added_node_paths(&old_wc, &base_snapshot);
387
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
388
        for (path, fs_entry) in &fs_entries {
389
            match base_snapshot.files.get(path) {
390
                None => b.add_path(self, path, fs_entry)?,
391
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
392
            }
393
        }
394
        for (path, expected) in &base_snapshot.files {
395
            if fs_entries.contains_key(path) {
396
                continue;
397
            }
398
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
399
            if !still_needed {
400
                b.remove_path(path, expected);
401
            }
402
        }
403
404
        let mut wc = Change {
405
            change_id: old_wc.change_id,
406
            deps: vec![],
407
            author: ObjectRef::ZERO,
408
            hlc: old_wc.hlc,
409
            message: old_wc.message.clone(),
410
            tree_ops: b.tree_ops,
411
            edits: b.edits,
412
            kind: ChangeKind::WorkingCopy,
413
        };
414
        let base_heads = base.maximal(&old_wc.deps);
415
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
416
        deps.extend(referenced_changes(&wc, &base));
417
        wc.deps = deps.into_iter().collect();
418
        wc.normalize();
419
420
        let mut statuses = b.statuses;
421
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
422
        // Empty snapshot: hash the candidate before storing; only record an op on change.
423
        let new_hash = wc.hash();
424
        let changed = new_hash != old_hash;
425
        if changed {
426
            self.store.put_change(&wc)?;
427
            let mut view = op.view.clone();
428
            view.workspaces.insert(self.workspace, new_hash);
429
            self.record_op(&[op_ref], &format!("{command} (snapshot)"), view)?;
430
        }
431
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed })
432
    }
433
434
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
435
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
436
        let snap = self.snapshot("oxy commit")?;
437
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
438
            return fail("nothing to commit: the working copy matches its base");
439
        }
440
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
441
            return fail("a conflicted file was edited; resolving conflicts is not implemented yet");
442
        }
443
        if message.trim().is_empty() {
444
            return fail("a commit message is required");
445
        }
446
        let (op_ref, op) = self.head_op()?;
447
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
448
        for d in extra_deps {
449
            if !snap.base.contains(d) {
450
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
451
            }
452
            deps.insert(*d);
453
        }
454
        let mut change = Change {
455
            change_id: snap.wc.change_id,
456
            deps: deps.into_iter().collect(),
457
            author: ObjectRef::ZERO,
458
            hlc: Hlc::now(),
459
            message: message.trim_end().to_string(),
460
            tree_ops: snap.wc.tree_ops.clone(),
461
            edits: snap.wc.edits.clone(),
462
            kind: ChangeKind::Normal,
463
        };
464
        change.normalize();
465
        let hash = self.store.put_change(&change)?;
466
467
        // New bookmark heads: maximal elements of old heads plus the new change.
468
        let old_heads = op.view.bookmarks.get(&self.config.bookmark).cloned().unwrap_or_default();
469
        let mut with_new: Vec<ChangeHash> = old_heads.clone();
470
        with_new.push(hash);
471
        let mut st = self.load_state(&with_new)?;
472
        st.add(change)?;
473
        let new_heads = st.maximal(&with_new);
474
475
        let wc = self.new_working_copy(new_heads.clone(), "");
476
        let wc_hash = self.store.put_change(&wc)?;
477
        let mut view = op.view.clone();
478
        view.bookmarks.insert(self.config.bookmark.clone(), new_heads);
479
        view.workspaces.insert(self.workspace, wc_hash);
480
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
481
        Ok(hash)
482
    }
483
484
    /// Changes reachable from the bookmark and the working copy, newest first.
485
    pub fn log(&self) -> Result<Vec<LogEntry>> {
486
        let (wc_hash, _) = self.working_copy()?;
487
        let mut heads = self.bookmark_heads()?;
488
        heads.push(wc_hash);
489
        let state = self.load_state(&heads)?;
490
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
491
        let mut entries: Vec<LogEntry> = state
492
            .changes()
493
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
494
            .collect();
495
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
496
        Ok(entries)
497
    }
498
499
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
500
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
501
        if spec == "@" {
502
            return Ok(self.working_copy()?.0);
503
        }
504
        let entries = self.log()?;
505
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
506
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
507
        } else {
508
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
509
        };
510
        match matches.len() {
511
            1 => Ok(matches[0].hash),
512
            0 => fail(format!("no change matches {spec:?}")),
513
            _ => {
514
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
515
                if ids.len() == 1 {
516
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
517
                } else {
518
                    fail(format!("{spec:?} is ambiguous"))
519
                }
520
            }
521
        }
522
    }
523
524
    /// Human-readable description of what a change does, with line content.
525
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
526
        let change = self.store.get_change(hash)?;
527
        let base = self.load_state(&change.deps)?;
528
        let base_snapshot = base.materialize();
529
        Ok(render_change(&change, &base, &base_snapshot))
530
    }
531
}
532
533
#[cfg(unix)]
534
fn is_executable(m: &fs::Metadata) -> bool {
535
    use std::os::unix::fs::PermissionsExt;
536
    m.permissions().mode() & 0o111 != 0
537
}
538
#[cfg(not(unix))]
539
fn is_executable(_m: &fs::Metadata) -> bool {
540
    false
541
}
542
543
fn op_ref(k: OpKey) -> OpRef {
544
    OpRef { change: k.0, index: k.1 }
545
}
546
547
fn split_path(path: &str) -> (&str, &str) {
548
    match path.rfind('/') {
549
        Some(i) => (&path[..i], &path[i + 1..]),
550
        None => ("", path),
551
    }
552
}
553
554
/// Builds the tree ops and edits of a working-copy change against a base.
555
struct ChangeBuilder<'a> {
556
    base: &'a State,
557
    base_snapshot: &'a Snapshot,
558
    tree_ops: Vec<TreeOp>,
559
    edits: Vec<Edit>,
560
    statuses: Vec<PathStatus>,
561
    dir_nodes: HashMap<String, NodeId>,
562
    reuse: HashMap<String, (NodeId, NodeKind)>,
563
}
564
565
impl<'a> ChangeBuilder<'a> {
566
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
567
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
568
        dir_nodes.insert(String::new(), NodeId::ROOT);
569
        for (p, f) in &base_snapshot.files {
570
            if f.kind == NodeKind::Dir {
571
                dir_nodes.insert(p.clone(), f.node);
572
            }
573
        }
574
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse }
575
    }
576
577
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
578
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
579
    }
580
581
    fn ensure_dir(&mut self, path: &str) -> NodeId {
582
        if let Some(n) = self.dir_nodes.get(path) {
583
            return *n;
584
        }
585
        let mut parent = NodeId::ROOT;
586
        let mut prefix = String::new();
587
        for comp in path.split('/') {
588
            if !prefix.is_empty() {
589
                prefix.push('/');
590
            }
591
            prefix.push_str(comp);
592
            parent = match self.dir_nodes.get(&prefix) {
593
                Some(n) => *n,
594
                None => {
595
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
596
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
597
                    self.dir_nodes.insert(prefix.clone(), node);
598
                    node
599
                }
600
            };
601
        }
602
        parent
603
    }
604
605
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
606
        let (dir, name) = split_path(path);
607
        let parent = self.ensure_dir(dir);
608
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
609
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
610
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
611
        if opaque {
612
            let blob = repo.store_blob(&e.content)?;
613
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
614
        } else if !e.content.is_empty() {
615
            self.edits.push(Edit::Insert {
616
                node,
617
                after: crate::objects::AtomRef::Start,
618
                before: crate::objects::AtomRef::End,
619
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
620
            });
621
        }
622
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
623
        Ok(())
624
    }
625
626
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
627
        let entry = &self.base_snapshot.tree.entries[path];
628
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
629
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
630
    }
631
632
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
633
        if expected.kind != e.kind {
634
            self.remove_path(path, expected);
635
            self.statuses.pop();
636
            self.add_path(repo, path, e)?;
637
            self.statuses.pop();
638
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
639
            return Ok(());
640
        }
641
        let entry = &self.base_snapshot.tree.entries[path];
642
        let node = expected.node;
643
        let mode_atom = entry.mode_atom.map(op_ref);
644
        let mut status: Option<ChangeStatus> = None;
645
        match &expected.content {
646
            FileContent::Dir => {}
647
            FileContent::Text { bytes, materialized } => {
648
                if materialized.is_conflicted() {
649
                    if bytes != &e.content {
650
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
651
                    }
652
                    return Ok(());
653
                }
654
                if bytes != &e.content {
655
                    if is_binary(&e.content) {
656
                        // Text → opaque (spec §7.5).
657
                        if let Some(old) = mode_atom {
658
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
659
                        }
660
                        let live: Vec<_> = repo_live_keys(self.base, &node);
661
                        if !live.is_empty() {
662
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
663
                        }
664
                        let blob = repo.store_blob(&e.content)?;
665
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
666
                        status = Some(ChangeStatus::Modified);
667
                    } else {
668
                        let old = self.base.live_lines(&node);
669
                        let edits = text_edits(node, &old, &e.content);
670
                        if !edits.is_empty() {
671
                            self.edits.extend(edits);
672
                            status = Some(ChangeStatus::Modified);
673
                        }
674
                    }
675
                }
676
                if status != Some(ChangeStatus::Modified) || !is_binary(&e.content) {
677
                    if e.exec != expected.mode.exec {
678
                        if let Some(old) = mode_atom {
679
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
680
                            status.get_or_insert(ChangeStatus::ModeChanged);
681
                        }
682
                    }
683
                }
684
            }
685
            FileContent::Opaque { blobs } => {
686
                let new_ref = Repo::blob_ref(&e.content);
687
                if blobs.len() > 1 {
688
                    if !blobs.contains(&new_ref) {
689
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
690
                    }
691
                    return Ok(());
692
                }
693
                let current = blobs.first().copied();
694
                if current != Some(new_ref) {
695
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
696
                        // Opaque → text (spec §7.5).
697
                        if let Some(old) = mode_atom {
698
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
699
                        }
700
                        self.edits.push(Edit::Replace { node, old: current, new: None });
701
                        if !e.content.is_empty() {
702
                            self.edits.push(Edit::Insert {
703
                                node,
704
                                after: crate::objects::AtomRef::Start,
705
                                before: crate::objects::AtomRef::End,
706
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
707
                            });
708
                        }
709
                    } else {
710
                        let blob = repo.store_blob(&e.content)?;
711
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
712
                    }
713
                    status = Some(ChangeStatus::Modified);
714
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
715
                    if let Some(old) = mode_atom {
716
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
717
                        status = Some(ChangeStatus::ModeChanged);
718
                    }
719
                }
720
            }
721
        }
722
        if let Some(s) = status {
723
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
724
        }
725
        Ok(())
726
    }
727
}
728
729
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
730
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
731
}
732
733
/// The changes a change's scope references (spec §8.1): introducers of every
734
/// atom, placement, and mode it touches, and of every node it operates on.
735
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
736
    let mut out = BTreeSet::new();
737
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
738
        if !r.change.is_zero() {
739
            out.insert(r.change);
740
        }
741
    };
742
    let mut nodes: Vec<NodeId> = Vec::new();
743
    for op in &change.tree_ops {
744
        match op {
745
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
746
            TreeOp::RemoveNode { node, placement } => {
747
                nodes.push(*node);
748
                opref(placement, &mut out);
749
            }
750
            TreeOp::Move { node, old, parent, .. } => {
751
                nodes.push(*node);
752
                nodes.push(*parent);
753
                opref(old, &mut out);
754
            }
755
            TreeOp::SetMode { node, old, .. } => {
756
                nodes.push(*node);
757
                opref(old, &mut out);
758
            }
759
        }
760
    }
761
    for e in &change.edits {
762
        nodes.push(e.node());
763
        match e {
764
            Edit::Insert { after, before, .. } => {
765
                for r in [after, before] {
766
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
767
                        if !change.is_zero() {
768
                            out.insert(*change);
769
                        }
770
                    }
771
                }
772
            }
773
            Edit::Delete { atoms, .. } => {
774
                for r in atoms {
775
                    if !r.change.is_zero() {
776
                        out.insert(r.change);
777
                    }
778
                }
779
            }
780
            Edit::Replace { .. } => {}
781
        }
782
    }
783
    for n in nodes {
784
        if let Some(h) = base.tree().introducer(&n) {
785
            out.insert(h);
786
        }
787
    }
788
    out
789
}
790
791
/// Paths of the nodes a change adds on top of `base_snapshot`.
792
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
793
    let by_node = base_snapshot.tree.by_node();
794
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
795
    for op in &change.tree_ops {
796
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
797
            new_names.insert(*node, (*parent, name.clone(), *kind));
798
        }
799
    }
800
    let mut out = HashMap::new();
801
    for (node, (_, _, kind)) in &new_names {
802
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
803
            out.insert(path, (*node, *kind));
804
        }
805
    }
806
    out
807
}
808
809
fn path_of_node(
810
    n: &NodeId,
811
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
812
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
813
) -> Option<String> {
814
    if let Some(e) = by_node.get(n) {
815
        return Some(e.path.clone());
816
    }
817
    let mut parts = Vec::new();
818
    let mut cur = *n;
819
    loop {
820
        if cur.is_root() {
821
            break;
822
        }
823
        if let Some(e) = by_node.get(&cur) {
824
            if !e.path.is_empty() {
825
                parts.push(e.path.clone());
826
            }
827
            break;
828
        }
829
        let (parent, name, _) = new_names.get(&cur)?;
830
        parts.push(name.clone());
831
        cur = *parent;
832
    }
833
    parts.reverse();
834
    Some(parts.join("/"))
835
}
836
837
/// Render a change's operations with line content, using `base` for deleted lines.
838
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
839
    let by_node = base_snapshot.tree.by_node();
840
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
841
    for op in &change.tree_ops {
842
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
843
            new_names.insert(*node, (*parent, name.clone(), *kind));
844
        }
845
    }
846
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
847
848
    let mut out = String::new();
849
    for op in &change.tree_ops {
850
        match op {
851
            TreeOp::AddNode { node, kind, mode, .. } => {
852
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
853
            }
854
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
855
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
856
            TreeOp::SetMode { node, mode, .. } => {
857
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
858
            }
859
        }
860
    }
861
    let mut last_node: Option<NodeId> = None;
862
    for e in &change.edits {
863
        let n = e.node();
864
        if last_node != Some(n) {
865
            out.push_str(&format!("--- {}\n", path_of(&n)));
866
            last_node = Some(n);
867
        }
868
        match e {
869
            Edit::Insert { lines, .. } => {
870
                for l in lines {
871
                    out.push('+');
872
                    out.push_str(&String::from_utf8_lossy(l));
873
                    if !l.ends_with(b"\n") {
874
                        out.push('\n');
875
                    }
876
                }
877
            }
878
            Edit::Delete { atoms, .. } => {
879
                for r in atoms {
880
                    for i in r.from..r.to {
881
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
882
                        out.push('-');
883
                        out.push_str(&String::from_utf8_lossy(content));
884
                        if !content.ends_with(b"\n") {
885
                            out.push('\n');
886
                        }
887
                    }
888
                }
889
            }
890
            Edit::Replace { old, new, .. } => {
891
                out.push_str(&format!(
892
                    "  opaque content {} -> {}\n",
893
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
894
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
895
                ));
896
            }
897
        }
898
    }
899
    out
900
}
901
902
#[cfg(test)]
903
mod tests {
904
    use super::*;
905
906
    fn write(root: &Path, rel: &str, content: &[u8]) {
907
        let p = root.join(rel);
908
        fs::create_dir_all(p.parent().unwrap()).unwrap();
909
        fs::write(p, content).unwrap();
910
    }
911
912
    fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
913
        s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
914
    }
915
916
    #[test]
917
    fn init_snapshot_commit_log_undo() {
918
        let dir = tempfile::tempdir().unwrap();
919
        let root = dir.path();
920
        let repo = Repo::init(root).unwrap();
921
        assert!(Repo::init(root).is_err());
922
        let ops = repo.op_log().unwrap();
923
        assert_eq!(ops.len(), 1);
924
        assert_eq!(ops[0].1.command, "oxy init");
925
926
        // Empty working copy: snapshot records no operation (spec §12.3).
927
        let s = repo.snapshot("oxy status").unwrap();
928
        assert!(!s.changed);
929
        assert_eq!(repo.op_log().unwrap().len(), 1);
930
931
        write(root, "src/main.rs", b"fn main() {}\n");
932
        write(root, "README.md", b"# Hello\n");
933
        write(root, ".oxyignore", b"/target\n");
934
        write(root, "target/junk.o", b"\0\0\0");
935
        let s = repo.snapshot("oxy status").unwrap();
936
        assert!(s.changed);
937
        assert_eq!(
938
            statuses(&s),
939
            vec![
940
                (".oxyignore".into(), ChangeStatus::Added),
941
                ("README.md".into(), ChangeStatus::Added),
942
                ("src/main.rs".into(), ChangeStatus::Added),
943
            ]
944
        );
945
        assert_eq!(repo.op_log().unwrap().len(), 2);
946
        // Snapshotting again with nothing changed records no op.
947
        let s2 = repo.snapshot("oxy status").unwrap();
948
        assert!(!s2.changed);
949
        assert_eq!(s2.wc_hash, s.wc_hash);
950
        assert_eq!(repo.op_log().unwrap().len(), 2);
951
952
        let h1 = repo.commit("Initial import", &[]).unwrap();
953
        let c1 = repo.store().get_change(&h1).unwrap();
954
        assert_eq!(c1.kind, ChangeKind::Normal);
955
        assert!(c1.deps.is_empty(), "first change has nothing to depend on");
956
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
957
        let (wc_hash, wc) = repo.working_copy().unwrap();
958
        assert_eq!(wc.deps, vec![h1]);
959
        assert!(wc.edits.is_empty());
960
        assert!(repo.commit("nothing", &[]).is_err());
961
962
        // Modify one file, remove another, add a binary file.
963
        write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
964
        fs::remove_file(root.join("README.md")).unwrap();
965
        write(root, "logo.png", b"\x89PNG\0\0\x01");
966
        let s = repo.snapshot("oxy status").unwrap();
967
        assert_eq!(
968
            statuses(&s),
969
            vec![
970
                ("README.md".into(), ChangeStatus::Removed),
971
                ("logo.png".into(), ChangeStatus::Added),
972
                ("src/main.rs".into(), ChangeStatus::Modified),
973
            ]
974
        );
975
        assert_ne!(s.wc_hash, wc_hash);
976
        // The rewritten working copy keeps its ChangeId and depends on h1.
977
        assert_eq!(s.wc.change_id, wc.change_id);
978
        assert!(s.wc.deps.contains(&h1));
979
980
        let h2 = repo.commit("Second", &[]).unwrap();
981
        let c2 = repo.store().get_change(&h2).unwrap();
982
        assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
983
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
984
985
        // The materialized head state matches the working copy.
986
        let st = repo.load_state(&[h2]).unwrap();
987
        let snap = st.materialize();
988
        assert!(!snap.is_conflicted());
989
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
990
        assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
991
        assert!(snap.files["logo.png"].mode.opaque);
992
        if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
993
            assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
994
        } else {
995
            panic!("logo should be opaque");
996
        }
997
998
        // Log: newest first, working copy included.
999
        let log = repo.log().unwrap();
1000
        assert_eq!(log.len(), 3);
1001
        assert!(log[0].is_working_copy);
1002
        assert_eq!(log[1].hash, h2);
1003
        assert!(log[1].is_head);
1004
        assert_eq!(log[2].hash, h1);
1005
        let d = repo.describe_change(&h2).unwrap();
1006
        assert!(d.contains("D README.md"), "{d}");
1007
        assert!(d.contains("+    println!(\"hi\");"), "{d}");
1008
        assert!(d.contains("-fn main() {}"), "{d}");
1009
1010
        // Resolution by prefix.
1011
        assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
1012
        assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
1013
        assert!(repo.resolve_change("zzzz").is_err());
1014
1015
        // Undo the commit: bookmark and working copy go back.
1016
        repo.undo().unwrap();
1017
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1018
        let s = repo.snapshot("oxy status").unwrap();
1019
        assert!(!s.changed, "files on disk still match the pre-commit working copy");
1020
        assert_eq!(s.statuses.len(), 3);
1021
    }
1022
1023
    #[test]
1024
    fn independent_changes_commute_and_make_multiple_heads() {
1025
        let dir = tempfile::tempdir().unwrap();
1026
        let root = dir.path();
1027
        let repo = Repo::init(root).unwrap();
1028
        write(root, "a.txt", b"a\n");
1029
        let h1 = repo.commit("a", &[]).unwrap();
1030
        write(root, "b.txt", b"b\n");
1031
        let h2 = repo.commit("b", &[]).unwrap();
1032
        let c2 = repo.store().get_change(&h2).unwrap();
1033
        assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
1034
        let mut heads = repo.bookmark_heads().unwrap();
1035
        heads.sort();
1036
        let mut want = vec![h1, h2];
1037
        want.sort();
1038
        assert_eq!(heads, want);
1039
        // Editing a.txt depends on h1 only.
1040
        write(root, "a.txt", b"a\na2\n");
1041
        let h3 = repo.commit("a2", &[]).unwrap();
1042
        assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1043
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1044
        assert_eq!(heads, [h2, h3].into_iter().collect());
1045
        // Explicit dependency.
1046
        write(root, "c.txt", b"c\n");
1047
        let h4 = repo.commit("c", &[h2]).unwrap();
1048
        assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
1049
    }
1050
1051
    #[test]
1052
    fn mode_change_and_text_binary_switch() {
1053
        let dir = tempfile::tempdir().unwrap();
1054
        let root = dir.path();
1055
        let repo = Repo::init(root).unwrap();
1056
        write(root, "run.sh", b"#!/bin/sh\n");
1057
        repo.commit("add script", &[]).unwrap();
1058
        #[cfg(unix)]
1059
        {
1060
            use std::os::unix::fs::PermissionsExt;
1061
            fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1062
            let s = repo.snapshot("status").unwrap();
1063
            assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
1064
            let h = repo.commit("exec", &[]).unwrap();
1065
            let snap = repo.load_state(&[h]).unwrap().materialize();
1066
            assert!(snap.files["run.sh"].mode.exec);
1067
        }
1068
        // Text → binary → text.
1069
        write(root, "run.sh", b"\0binary");
1070
        let h = repo.commit("binary", &[]).unwrap();
1071
        let snap = repo.load_state(&[h]).unwrap().materialize();
1072
        assert!(snap.files["run.sh"].mode.opaque);
1073
        write(root, "run.sh", b"text again\n");
1074
        let h = repo.commit("text", &[]).unwrap();
1075
        let snap = repo.load_state(&[h]).unwrap().materialize();
1076
        assert!(!snap.files["run.sh"].mode.opaque);
1077
        assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
1078
        assert!(!snap.is_conflicted());
1079
    }
1080
}