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