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 |