OXYGENHUB

Failed file writes name the file; the guide shows the feature flow 314db9b4f3f8

From metalgear's dogfooding summary. Durable writes and renames now say
which file failed and in which step (write, rename, sync), keeping the
error kind, so a failure deep in a push reads as "write
.oxy/attest/feature/x: No such file or directory" instead of a bare io
error. The guide's bookmark section gains the develop/feature flow the oxy
way: names with /, switch --carry and --discard, commit -F, merge updating
the working copy, push -b and push --delete, and describe for build
stamps.

closes #26
closes #27
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 11:12 UTC
Signature
✔ signed by Andy McCall
Hash
314db9b4f3f82a20bf6953280246f60cac2c22b3a9770ad1437337e407cb8834
Depends on
4dcb8c0916b7 958fb27dda7c 982864068a59 c3069537d7c5 ee06a6c557b7 fbda43e7d8ed
Depended on by
14edbaaf5291 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
10 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified crates/oxy-core/src/durable.rs +38 −4
…
37 unchanged lines
38 38
        if !is_injected(&e) {
39 39
            let _ = fs::remove_file(&tmp);
40 40
        }
41 -
        return Err(e);
41 +
        return Err(at("write", path, e));
42 42
    }
43 -
    sync_dir(dir)
43 +
    sync_dir(dir).map_err(|e| at("sync the directory of", path, e))
44 44
}
45 45
46 +
/// `e`, naming what failed and on which file, so a failure deep in a
47 +
/// command says where it happened (an injected test crash stays as is).
48 +
/// The kind is kept for callers that test it.
49 +
fn at(what: &str, path: &Path, e: io::Error) -> io::Error {
50 +
    if is_injected(&e) {
51 +
        return e;
52 +
    }
53 +
    io::Error::new(e.kind(), format!("{what} {}: {e}", path.display()))
54 +
}
55 +
46 56
/// Rename `from` to `to` and make the rename durable.
47 57
pub fn rename(from: &Path, to: &Path) -> io::Result<()> {
48 58
    step("rename")?;
49 -
    fs::rename(from, to)?;
50 -
    sync_dir(to.parent().unwrap_or(Path::new(".")))
59 +
    fs::rename(from, to).map_err(|e| at(&format!("rename {} to", from.display()), to, e))?;
60 +
    sync_dir(to.parent().unwrap_or(Path::new("."))).map_err(|e| at("sync the directory of", to, e))
51 61
}
52 62
53 63
/// Flush a directory's entries (new, renamed or removed files) to disk.
…
71 unchanged lines
125 135
            }
126 136
            None => Ok(()),
127 137
        })
138 +
    }
139 +
}
140 +
141 +
#[cfg(test)]
142 +
mod tests {
143 +
    use super::*;
144 +
145 +
    #[test]
146 +
    fn a_failed_write_names_the_file_and_keeps_its_kind() {
147 +
        let dir = tempfile::tempdir().unwrap();
148 +
        let path = dir.path().join("attest").join("feature").join("x");
149 +
        let e = write_atomic(&path, b"state").unwrap_err();
150 +
        assert_eq!(e.kind(), io::ErrorKind::NotFound);
151 +
        let msg = e.to_string();
152 +
        assert!(
153 +
            msg.starts_with("write ") && msg.contains("attest/feature/x"),
154 +
            "{msg}"
155 +
        );
156 +
        write_atomic(&dir.path().join("ok"), b"state").unwrap();
157 +
        let e = rename(&dir.path().join("missing"), &dir.path().join("to")).unwrap_err();
158 +
        assert!(
159 +
            e.to_string().contains("missing") && e.to_string().contains("to"),
160 +
            "{e}"
161 +
        );
128 162
    }
129 163
}
modified crates/oxy-core/src/repo/tests.rs +14 −2
…
1527 unchanged lines
1528 1528
    write(&g, "run.sh", b"#!/bin/sh\necho discarded\n");
1529 1529
    git(&["commit", "-q", "-am", "discarded edit"]);
1530 1530
    git(&["checkout", "-q", "main"]);
1531 -
    git(&["merge", "-q", "-s", "ours", "-m", "merge discarded, keeping ours", "discarded"]);
1531 +
    git(&[
1532 +
        "merge",
1533 +
        "-q",
1534 +
        "-s",
1535 +
        "ours",
1536 +
        "-m",
1537 +
        "merge discarded, keeping ours",
1538 +
        "discarded",
1539 +
    ]);
1532 1540
    let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap();
1533 1541
1534 1542
    let dest = root.join("oxy");
…
1 unchanged lines
1536 1544
    let rep = repo.import_git(&g, "main").unwrap();
1537 1545
    assert_eq!(rep.commits, expected_count);
1538 1546
    assert_eq!(rep.merges, 3);
1539 -
    assert_eq!(fs::read(dest.join("run.sh")).unwrap(), b"#!/bin/sh\n", "the ours merge kept main's run.sh");
1547 +
    assert_eq!(
1548 +
        fs::read(dest.join("run.sh")).unwrap(),
1549 +
        b"#!/bin/sh\n",
1550 +
        "the ours merge kept main's run.sh"
1551 +
    );
1540 1552
    let v = repo.verify_git_import(&g, "main").unwrap();
1541 1553
    assert_eq!(v.commits, expected_count, "every commit's tree reproduced");
1542 1554
    assert!(v.mismatches.is_empty(), "{:?}", v.mismatches);
…
3925 unchanged lines
modified docs/guide.md +27 −0
…
102 unchanged lines
103 103
Each workspace remembers its own bookmark, so two directories can sit on
104 104
two bookmarks of one repository.
105 105
106 +
### A develop / feature flow
107 +
108 +
Names with `/` work everywhere, pushing included:
109 +
110 +
```
111 +
oxy bookmark create feature/42-login          # at the working copy's base
112 +
oxy bookmark switch feature/42-login --carry  # started already? take the edits along
113 +
oxy commit -F msg.txt                         # a long message from a file
114 +
oxy push -b feature/42-login
115 +
oxy bookmark switch develop
116 +
oxy merge feature/42-login                    # the working copy follows when it was clean
117 +
oxy push                                      # develop, with the feature in it
118 +
oxy bookmark delete feature/42-login          # oxy push --delete feature/42-login on the remote
119 +
```
120 +
121 +
`bookmark switch` stops when the working copy has uncommitted edits, so
122 +
nothing is lost by accident. There are two ways past it:
123 +
- `--carry` takes the edits along. It is refused only when the target
124 +
  bookmark has other versions of the files they touch.
125 +
- `--discard` drops the edits.
126 +
127 +
`oxy describe` names what the working copy holds, for build stamps:
128 +
- a tag, when a tag holds exactly this state;
129 +
- otherwise the nearest tag with the distance from it and the change;
130 +
- or, with no tag behind it, the bookmark and the change;
131 +
- with `-dirty` added when there are uncommitted edits.
132 +
106 133
## 4. Fix up history
107 134
108 135
Oxygen keeps its own record of every operation, so all of these are safe to
…
348 unchanged lines