OXYGENHUB

Hold the working-copy lock through every working-copy write 6caacfff1d4f

A status run while a bookmark switch rewrote the working copy (on
metalgear, most likely the editor extension reacting to the files being
written) read the half-written files, recorded them as edits of the old
base, and its view then replaced the switch's in the merge of the two
operations. Status showed the switch's rewrite as the user's changes and
the switch back was refused.

Only snapshots took the workspace lock. Checkout, bookmark switch and
--carry, merge, commit, amend and rewrites, discard and pull now hold
it from reading the head until their operation is recorded; it is
re-entrant within a thread, waits up to a minute, and a live process's
lock is never taken over however old. A workspace changed by two
concurrent operations is now an error instead of the later one winning.
A test races a snapshot thread against twenty switches. Spec Draft 0.8r.

closes #36
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 21:19 UTC
Signature
✔ signed by Andy McCall
Hash
6caacfff1d4f62ca230a4dd2d34ffe47afb321e829461bfab48ffeadaff5e758
Depends on
132b884b9a8d 19555025a2ac 1c2bd9abdd4f 23b217d75848 4e88e774777a 614541c3cebd 61e3bce7ea5b 6f8aeb996558 75167672ebcd 7b9809e67c3d 8ab265dc4a51 982864068a59 c07e0e7d2d85 c6aa5bd9d1c8 ff0af1ee299d
Depended on by
d36ba2f79f95 767ac6cd0545 e59512f2f075 530a0fc650a2 7505194116e7 b30a880fa409 e226a6cc2f71 98a1620a3840 14edbaaf5291 e266b7d4065c f9466021286a 192fad1e46a2 d3cd7ef95da6 c5ce0367eddf 1a3b62032ed9 4fe7d6045860
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
38 atoms, 0 tree operations, 9 files

Browse the tree at this change

modified crates/oxy-core/src/repo/bookmarks.rs +4 −0
…
159 unchanged lines
160 160
161 161
    /// Make `name` this workspace's current bookmark and check it out.
162 162
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
163 +
        let _wc = self.lock_working_copy()?;
163 164
        let view = self.view()?;
164 165
        let Some(st) = view.bookmarks.get(name) else {
165 166
            return fail(format!("no bookmark {name}"));
…
10 unchanged lines
176 177
    /// switch` does: see [`Repo::checkout_carry`]. Returns the checkout
177 178
    /// report and the carried paths.
178 179
    pub fn bookmark_switch_carry(&mut self, name: &str) -> Result<(CheckoutReport, Vec<String>)> {
180 +
        let _wc = self.lock_working_copy()?;
179 181
        let view = self.view()?;
180 182
        let Some(st) = view.bookmarks.get(name) else {
181 183
            return fail(format!("no bookmark {name}"));
…
20 unchanged lines
202 204
        target: &str,
203 205
        command: &str,
204 206
    ) -> Result<(CheckoutReport, Vec<String>)> {
207 +
        let _wc = self.lock_working_copy()?;
205 208
        let snap = self.snapshot(command)?;
206 209
        // Every path the work touches: changed ones, and base files it
207 210
        // removed or renamed away (a rename reports only its new path).
…
67 unchanged lines
275 278
    /// Union the heads of bookmark `name` into the current bookmark (spec
276 279
    /// §10.1). A working copy without changes follows.
277 280
    pub fn merge(&self, name: &str) -> Result<MergeReport> {
281 +
        let _wc = self.lock_working_copy()?;
278 282
        let (op_ref, op) = self.head_op()?;
279 283
        let current = self.config.bookmark.clone();
280 284
        if name == current {
…
52 unchanged lines
modified crates/oxy-core/src/repo/checkout.rs +1 −0
…
20 unchanged lines
21 21
        discard: bool,
22 22
        command: &str,
23 23
    ) -> Result<CheckoutReport> {
24 +
        let _wc = self.lock_working_copy()?;
24 25
        let snap = self.snapshot(command)?;
25 26
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
26 27
            let list: Vec<String> = snap
…
243 unchanged lines
modified crates/oxy-core/src/repo/history.rs +3 −0
…
34 unchanged lines
35 35
        new: Option<&Change>,
36 36
        command: &str,
37 37
    ) -> Result<Option<ChangeHash>> {
38 +
        let _wc = self.lock_working_copy()?;
38 39
        let new_hash = match new {
39 40
            Some(c) => Some(self.store_signed(c)?),
40 41
            None => None,
…
60 unchanged lines
101 102
        corr: &Correspondence,
102 103
        command: &str,
103 104
    ) -> Result<(Option<ChangeHash>, Vec<String>)> {
105 +
        let _wc = self.lock_working_copy()?;
104 106
        let (op_ref, op) = self.head_op()?;
105 107
        let bookmark = self.config.bookmark.clone();
106 108
        let heads = self.heads_of(&op.view, &bookmark)?;
…
210 unchanged lines
317 319
    /// rewritten with an identity correspondence, since the target's own
318 320
    /// atoms keep their indices.
319 321
    pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> {
322 +
        let _wc = self.lock_working_copy()?;
320 323
        let snap = self.snapshot("oxy amend")?;
321 324
        if snap
322 325
            .statuses
…
880 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +15 −4
…
103 unchanged lines
104 104
pub use subrepos::{SubrepoInfo, SubrepoSync};
105 105
pub use sync::{FetchReport, PullReport, PushReport, RefUpdated};
106 106
pub use tags::TagInfo;
107 -
pub use workspace::{FileLock, SnapshotLock, WorkspaceInfo};
107 +
pub use workspace::{FileLock, SnapshotLock, WorkingCopyLock, WorkspaceInfo};
108 108
109 109
/// The file name under which per-bookmark or per-tag data is kept: `/`
110 110
/// (and `%`) escaped, so `feature/x` is one file beside `feature` rather
…
538 unchanged lines
649 649
    /// Three-way merge of two views that both started from `base` (spec
650 650
    /// §12.1). A reference only one side changed takes that side's value.
651 651
    /// When both changed it differently: a bookmark keeps both sides'
652 -
    /// heads (it may then have several), a change beats a deletion, and
653 -
    /// otherwise this process's value wins for workspaces, remote
652 +
    /// heads (it may then have several), a change beats a deletion, a
653 +
    /// workspace changed by both is an error (only one of them matches the
654 +
    /// files on disk), and otherwise this process's value wins for remote
654 655
    /// references and the policy, the first value for a tag (tags never
655 656
    /// move).
656 657
    fn merge_views(&self, base: &View, ours: &View, theirs: &View) -> Result<View> {
…
35 unchanged lines
692 693
                Ok(self.store.put_state(&StateObject::new(heads))?)
693 694
            },
694 695
        )?;
696 +
        // Two operations that both replaced one workspace's working copy
697 +
        // cannot both be right: the files on disk match only one of them.
698 +
        // The working-copy lock keeps this from happening; should it happen
699 +
        // anyway, this operation is not recorded.
695 700
        let workspaces = pick(
696 701
            &base.workspaces,
697 702
            &ours.workspaces,
698 703
            &theirs.workspaces,
699 -
            |x, _| Ok(*x),
704 +
            |_, _| {
705 +
                fail(
706 +
                    "another oxy process changed this working copy at the same time; \
707 +
                     this command was not recorded: check `oxy status` and run it again",
708 +
                )
709 +
            },
700 710
        )?;
701 711
        let tags = pick(&base.tags, &ours.tags, &theirs.tags, |_, y| Ok(*y))?;
702 712
        let empty = BTreeMap::new();
…
53 unchanged lines
756 766
    /// a working-copy snapshot are skipped, since the files are still on disk
757 767
    /// and the next snapshot recreates them.
758 768
    pub fn undo(&self) -> Result<Op> {
769 +
        let _wc = self.lock_working_copy()?;
759 770
        let (read_head, mut op) = self.head_op()?;
760 771
        while op.is_snapshot() {
761 772
            let Some(parent) = op.parents.first() else {
…
875 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +3 −0
…
542 unchanged lines
543 543
        extra_deps: &[ChangeHash],
544 544
        selection: &Selection,
545 545
    ) -> Result<ChangeHash> {
546 +
        let _wc = self.lock_working_copy()?;
546 547
        let snap = self.snapshot_status("oxy commit")?;
547 548
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
548 549
            return fail("nothing to commit: the working copy matches its base");
…
510 unchanged lines
1059 1060
    /// edits are undone, additions removed, removals put back. Returns the
1060 1061
    /// number of paths touched.
1061 1062
    pub fn discard(&self, paths: &[String]) -> Result<usize> {
1063 +
        let _wc = self.lock_working_copy()?;
1062 1064
        let snap = self.snapshot_status("oxy discard")?;
1063 1065
        let under = |p: &str| {
1064 1066
            paths
…
79 unchanged lines
1144 1146
    /// disk, keeping the others: the files are rewritten from the base plus
1145 1147
    /// the remaining hunks.
1146 1148
    pub fn discard_hunks(&self, indices: &[usize]) -> Result<usize> {
1149 +
        let _wc = self.lock_working_copy()?;
1147 1150
        let (snap, hunks) = self.hunks()?;
1148 1151
        let keep: Vec<usize> = hunks
1149 1152
            .iter()
…
1160 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +1 −0
…
1246 unchanged lines
1247 1247
    /// Fetch, then take the union of the local bookmark's state with the
1248 1248
    /// remote's: no merge change, conflicts become reified (spec §9.4).
1249 1249
    pub fn pull(&self, remote_name: &str) -> Result<PullReport> {
1250 +
        let _wc = self.lock_working_copy()?;
1250 1251
        self.fetch(remote_name)?;
1251 1252
        let (op_ref, op) = self.head_op()?;
1252 1253
        let bookmark = self.config.bookmark.clone();
…
392 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +92 −6
…
2192 unchanged lines
2193 2193
        "a forgotten workspace is no longer in the view"
2194 2194
    );
2195 2195
2196 -
    // The snapshot lock: a fresh lock blocks a second, a stale one is taken over.
2196 +
    // The working-copy lock: re-entrant in a thread; a lock whose process
2197 +
    // is gone is taken over, and a live process's is not, however old.
2197 2198
    let lock = repo.lock_snapshot().unwrap();
2199 +
    let again = repo.lock_working_copy().unwrap();
2200 +
    drop(again);
2198 2201
    drop(lock);
2199 2202
    let lock_path = main_dir
2200 2203
        .join(".oxy/workspaces")
2201 2204
        .join(repo.workspace().to_hex())
2202 2205
        .join("lock");
2203 -
    fs::write(&lock_path, "1").unwrap();
2204 2206
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
2205 -
    fs::File::open(&lock_path)
2206 -
        .unwrap()
2207 -
        .set_modified(old)
2208 -
        .unwrap();
2207 +
    let age = |p: &Path| fs::File::open(p).unwrap().set_modified(old).unwrap();
2208 +
    fs::write(&lock_path, std::process::id().to_string()).unwrap();
2209 +
    age(&lock_path);
2210 +
    assert!(
2211 +
        !super::workspace::lock_is_stale(&lock_path),
2212 +
        "a live process keeps its lock"
2213 +
    );
2214 +
    fs::write(&lock_path, "4294967295").unwrap();
2215 +
    age(&lock_path);
2209 2216
    assert!(repo.lock_snapshot().is_ok(), "stale lock taken over");
2210 2217
}
2211 2218
…
3355 unchanged lines
5567 5574
    let c = Repo::clone(&root.join("a").to_string_lossy(), &root.join("c")).unwrap();
5568 5575
    assert_eq!(fs::read(root.join("c/big.txt")).unwrap(), edited.as_bytes());
5569 5576
    drop(c);
5577 +
}
5578 +
5579 +
#[test]
5580 +
fn operations_that_both_replace_a_working_copy_are_not_merged() {
5581 +
    hermetic();
5582 +
    let dir = tempfile::tempdir().unwrap();
5583 +
    let root = dir.path();
5584 +
    let repo = Repo::init(root).unwrap();
5585 +
    write(root, "a.txt", b"a\n");
5586 +
    let a = repo.commit("a", &[]).unwrap();
5587 +
    write(root, "b.txt", b"b\n");
5588 +
    repo.commit("b", &[]).unwrap();
5589 +
    let (x, op) = repo.head_op().unwrap();
5590 +
    let ws = *op.view.workspaces.keys().next().unwrap();
5591 +
    let mut one = op.view.clone();
5592 +
    one.workspaces.insert(ws, a);
5593 +
    let mut two = op.view.clone();
5594 +
    two.workspaces.insert(ws, ObjectRef([7; 32]));
5595 +
    let first = repo.record_op(&[x], "one", one).unwrap();
5596 +
    let e = repo.record_op(&[x], "two", two).unwrap_err();
5597 +
    assert!(
5598 +
        e.to_string()
5599 +
            .contains("changed this working copy at the same time"),
5600 +
        "{e}"
5601 +
    );
5602 +
    assert_eq!(
5603 +
        repo.head_op().unwrap().0,
5604 +
        first,
5605 +
        "the second is not recorded"
5606 +
    );
5607 +
}
5608 +
5609 +
/// Metalgear, 2026-09-29 (oxygen#36): a status run while a bookmark switch
5610 +
/// rewrote the working copy recorded the half-written files as edits, and
5611 +
/// its view replaced the switch's.
5612 +
#[test]
5613 +
fn a_snapshot_during_a_switch_never_records_the_switch_as_edits() {
5614 +
    hermetic();
5615 +
    let dir = tempfile::tempdir().unwrap();
5616 +
    let root = dir.path().to_path_buf();
5617 +
    let mut repo = Repo::init(&root).unwrap();
5618 +
    write(&root, "keep.txt", b"keep\n");
5619 +
    repo.commit("base", &[]).unwrap();
5620 +
    repo.bookmark_create("small", None).unwrap();
5621 +
    for i in 0..300 {
5622 +
        write(
5623 +
            &root,
5624 +
            &format!("many/f{i}.txt"),
5625 +
            format!("file {i}\n").as_bytes(),
5626 +
        );
5627 +
    }
5628 +
    repo.commit("many", &[]).unwrap();
5629 +
    let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
5630 +
    let watcher = {
5631 +
        let (root, stop) = (root.clone(), stop.clone());
5632 +
        std::thread::spawn(move || {
5633 +
            let r = Repo::open(&root).unwrap();
5634 +
            let mut ran = 0;
5635 +
            while !stop.load(std::sync::atomic::Ordering::Relaxed) {
5636 +
                if r.snapshot_status("oxy status").is_ok() {
5637 +
                    ran += 1;
5638 +
                }
5639 +
            }
5640 +
            ran
5641 +
        })
5642 +
    };
5643 +
    for i in 0..20 {
5644 +
        let to = if i % 2 == 0 { "small" } else { "main" };
5645 +
        repo.bookmark_switch(to, false).unwrap();
5646 +
    }
5647 +
    stop.store(true, std::sync::atomic::Ordering::Relaxed);
5648 +
    assert!(watcher.join().unwrap() > 0, "the watcher snapshotted");
5649 +
    // Back on main, clean, with every file where main has it.
5650 +
    let repo = Repo::open(&root).unwrap();
5651 +
    let s = repo.snapshot_status("oxy status").unwrap();
5652 +
    assert!(s.statuses.is_empty(), "{:?}", statuses(&s));
5653 +
    assert!(s.wc.edits.is_empty() && s.wc.tree_ops.is_empty());
5654 +
    assert_eq!(s.wc.deps, repo.bookmark_heads().unwrap());
5655 +
    assert!(root.join("many/f299.txt").exists());
5570 5656
}
modified crates/oxy-core/src/repo/workspace.rs +105 −17
…
14 unchanged lines
15 15
    pub current: bool,
16 16
}
17 17
18 -
/// A short-lived lock file, taken by creating it exclusively and
19 -
/// released on drop: a workspace's snapshot lock, the operation head's
20 -
/// lock. A stale one (older than a minute, its process gone) is taken
21 -
/// over.
18 +
/// A lock file, taken by creating it exclusively and released on drop: a
19 +
/// workspace's working-copy lock, the operation head's lock. A stale one,
20 +
/// whose process is gone (or, where that cannot be told, older than a
21 +
/// minute), is taken over.
22 22
pub struct FileLock {
23 23
    path: PathBuf,
24 24
}
25 25
26 -
/// Held while a workspace is snapshotted.
27 -
pub type SnapshotLock = FileLock;
26 +
/// A workspace's working-copy lock (spec §12.1), held by everything that
27 +
/// reads or writes the working copy and records its working-copy change:
28 +
/// snapshot, checkout, bookmark switch, commit, discard, pull. It is
29 +
/// re-entrant within a thread, so a checkout that snapshots first holds it
30 +
/// once.
31 +
pub struct WorkingCopyLock {
32 +
    path: PathBuf,
33 +
    _file: Option<FileLock>,
34 +
}
35 +
36 +
/// The working-copy lock; its old name.
37 +
pub type SnapshotLock = WorkingCopyLock;
28 38
39 +
thread_local! {
40 +
    /// Working-copy locks this thread holds, with their depth.
41 +
    static HELD: std::cell::RefCell<HashMap<PathBuf, usize>> = Default::default();
42 +
}
43 +
44 +
impl Drop for WorkingCopyLock {
45 +
    fn drop(&mut self) {
46 +
        HELD.with(|h| {
47 +
            let mut h = h.borrow_mut();
48 +
            if let Some(n) = h.get_mut(&self.path) {
49 +
                *n -= 1;
50 +
                if *n == 0 {
51 +
                    h.remove(&self.path);
52 +
                }
53 +
            }
54 +
        });
55 +
    }
56 +
}
57 +
58 +
/// Whether the process that wrote a lock file is gone. Where that cannot
59 +
/// be told (no `/proc`), a lock older than a minute counts as stale.
60 +
pub(crate) fn lock_is_stale(path: &Path) -> bool {
61 +
    let old = fs::metadata(path)
62 +
        .and_then(|m| m.modified())
63 +
        .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false))
64 +
        .unwrap_or(true);
65 +
    let proc_fs = Path::new("/proc/self").exists();
66 +
    match fs::read_to_string(path)
67 +
        .ok()
68 +
        .and_then(|s| s.trim().parse::<u32>().ok())
69 +
    {
70 +
        Some(pid) if proc_fs => !Path::new("/proc").join(pid.to_string()).exists(),
71 +
        // Being written this instant, or by something that is not oxy.
72 +
        _ => old,
73 +
    }
74 +
}
75 +
29 76
impl Drop for FileLock {
30 77
    fn drop(&mut self) {
31 78
        let _ = fs::remove_file(&self.path);
…
3 unchanged lines
35 82
impl FileLock {
36 83
    /// Wait up to about five seconds for `path`; `busy` is the error after that.
37 84
    pub(crate) fn acquire(path: PathBuf, busy: &str) -> Result<FileLock> {
38 -
        for _ in 0..500 {
85 +
        Self::acquire_within(path, busy, std::time::Duration::from_secs(5))
86 +
    }
87 +
88 +
    /// Wait up to `wait` for `path`; `busy` is the error after that.
89 +
    pub(crate) fn acquire_within(
90 +
        path: PathBuf,
91 +
        busy: &str,
92 +
        wait: std::time::Duration,
93 +
    ) -> Result<FileLock> {
94 +
        let until = std::time::Instant::now() + wait;
95 +
        loop {
39 96
            match fs::OpenOptions::new()
40 97
                .write(true)
41 98
                .create_new(true)
…
4 unchanged lines
46 103
                    return Ok(FileLock { path });
47 104
                }
48 105
                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
49 -
                    let stale = fs::metadata(&path)
50 -
                        .and_then(|m| m.modified())
51 -
                        .map(|t| t.elapsed().map(|d| d.as_secs() > 60).unwrap_or(false))
52 -
                        .unwrap_or(true);
53 -
                    if stale {
106 +
                    if lock_is_stale(&path) {
54 107
                        let _ = fs::remove_file(&path);
55 108
                        continue;
56 109
                    }
110 +
                    if std::time::Instant::now() >= until {
111 +
                        return fail(busy);
112 +
                    }
57 113
                    std::thread::sleep(std::time::Duration::from_millis(10));
58 114
                }
59 115
                Err(e) => return Err(e.into()),
60 116
            }
61 117
        }
62 -
        fail(busy)
63 118
    }
64 119
}
65 120
…
124 unchanged lines
190 245
191 246
    /// Take this workspace's snapshot lock (spec §12.1).
192 247
    pub(crate) fn lock_snapshot(&self) -> Result<SnapshotLock> {
248 +
        self.lock_working_copy()
249 +
    }
250 +
251 +
    /// Take this workspace's working-copy lock (spec §12.1), waiting up to
252 +
    /// a minute for another process's checkout or snapshot to finish.
253 +
    pub fn lock_working_copy(&self) -> Result<WorkingCopyLock> {
193 254
        let dir = self.workspace_dir(&self.workspace);
255 +
        if self.bare {
256 +
            // No working copy to guard.
257 +
            return Ok(WorkingCopyLock {
258 +
                path: dir,
259 +
                _file: None,
260 +
            });
261 +
        }
194 262
        fs::create_dir_all(&dir)?;
195 -
        FileLock::acquire(
196 -
            dir.join("lock"),
197 -
            "another oxy process is snapshotting this workspace",
198 -
        )
263 +
        let path = dir.join("lock");
264 +
        let held = HELD.with(|h| {
265 +
            let mut h = h.borrow_mut();
266 +
            match h.get_mut(&path) {
267 +
                Some(n) => {
268 +
                    *n += 1;
269 +
                    true
270 +
                }
271 +
                None => false,
272 +
            }
273 +
        });
274 +
        if held {
275 +
            return Ok(WorkingCopyLock { path, _file: None });
276 +
        }
277 +
        let file = FileLock::acquire_within(
278 +
            path.clone(),
279 +
            "another oxy process is using this working copy (a checkout or snapshot); try again when it finishes",
280 +
            std::time::Duration::from_secs(60),
281 +
        )?;
282 +
        HELD.with(|h| h.borrow_mut().insert(path.clone(), 1));
283 +
        Ok(WorkingCopyLock {
284 +
            path,
285 +
            _file: Some(file),
286 +
        })
199 287
    }
200 288
}
modified docs/spec.md +4 −4
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8q |
5 +
| **Status** | Draft 0.8r |
6 6
| **Format version** | 1 (changes: 2) |
7 7
| **Date** | 2026-09-29 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
467 unchanged lines
476 476
}
477 477
```
478 478
479 -
`view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, and workspaces, remote references and the policy take the later process's value. `oxy undo` refuses when the head moved while it ran.
479 +
`view` is the complete state of all references and workspaces after the operation. The operation log is a DAG. The head (`.oxy/ops/head`) moves by compare-and-swap under a short lock (`.oxy/ops/lock`), held only while the pointer is written: a process that finds the head is no longer the parent it built on records a merge operation with both as parents, whose view is the three-way merge of the two views against that parent. A reference changed on one side takes that side's value; changed differently on both, a bookmark keeps both sides' heads (it then has several), a change beats a deletion, a tag keeps its first value, remote references and the policy take the later process's value, and a workspace changed by both is an error: the later operation is not recorded, since the files on disk match only one of the two working-copy changes. `oxy undo` refuses when the head moved while it ran.
480 480
481 481
`oxy undo` moves the current operation to its parent's view. `oxy redo` and `oxy op log` expose the DAG. Operations are retained for `core.op-retention` (default 90 days) and then eligible for garbage collection.
482 482
483 -
Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write, and a workspace's snapshot lock covers one snapshot of that workspace.
483 +
Because all mutation goes through the operation log and object writes are idempotent, no long-held repository lock is needed: the operation head's lock covers one pointer write. Each workspace has a **working-copy lock** (`.oxy/workspaces/<id>/lock`), held by every command that reads or writes the working copy and replaces its working-copy change (snapshot, checkout, bookmark switch, merge, commit, amend and rewrites, discard, pull), from reading the operation head until its operation is recorded. A snapshot therefore never sees a half-written checkout, and never records one as edits. The lock is re-entrant within a process thread; a waiting command gives up after a minute and says why; a lock whose process is gone is taken over, and where that cannot be told, one older than a minute.
484 484
485 485
### 12.2 Workspaces
486 486
…
487 unchanged lines
974 974
975 975
---
976 976
977 -
*Changelog.* Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
977 +
*Changelog.* Draft 0.8r (2026-09-29): the working-copy lock covers every command that writes the working copy; a workspace changed by two concurrent operations is an error (§12.1). Draft 0.8q (2026-09-29): change format version 2 stores large inserts as text blobs, so a change does not grow with the files it adds (§6.1, §7.4); no object may exceed 1 GiB (§13.2). Draft 0.8p (2026-09-29): Git import derives new NodeIds from the commit, making imports deterministic (§23). Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
978 978
979 979
Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
980 980
…
61 unchanged lines