OXYGENHUB

Unbound hosted repositories adopt the first pusher's id 75167672ebcd

A bare repository created with init_bare_unbound reports an all-zero id.
Clients refuse to clone or fetch from one and send Oxy-Repo-Id on every
request; the server binds the repository to that id under the push lock,
so an existing repository moved to a hub keeps its identity (protocol §2).
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 17:15 UTC
Signature
✔ signed by Andy McCall
Hash
75167672ebcdfc798181db598f9e72a4987236c9c376e5db1f8fde30ec031fc7
Depends on
02f1b14af1e2 1196b29b5fa9 132b884b9a8d 135212de5370 1983686fb7a7 20247a03a596 23b217d75848 252e8cb23fbc 2863456a0f6d 28fc23d7706a 2b5ee371060e 2b872d074ddb 30ea9c1e70fa 4dee277c327a 4e88e774777a 50a046e439e9 5b1114a04905 5b9c6ad45f6e 614541c3cebd 6706bdcfb63e 694805cca585 6d3eefbce2f0 6e723b81f5b9 728da0f92161 7421309f5372 750251b21a96 79ab8528ff62 79d03f826ecd 7b9809e67c3d 83e0677b07fe 86e682c242c6 8b678b298fee 8f10a07a1dbe 94084087a876 957fee30826e 96f7610fa666 982864068a59 9ac1fbe211b4 a269bee11bb2 b3c5a59a875a b7f102fcbb17 b837d2b55853 ba160f60219e c07e0e7d2d85 c3069537d7c5 c6aa5bd9d1c8 c792033af344 cdb5beba7f05 d02e545dc265 d8c086ca3eb2 e02bf0cd0533 ee06a6c557b7 f9c98deef326 fce44ae9ad29 ff0af1ee299d
Depended on by
232aabb5452d 284fe47d98b9 aded4110f0bd 2f0b4b0651ea c397a8c36a9f 6f4226bfb87f 0588de69a84d 64dc5197f9df b5e98094eb8f 642fa213de80 80b069facc69 4afe288b7946 9bd98cfb4f7e 544d0541a7e5 4476cdbb0443 570e35310f31 e30c081a5064 f3811eb20bea 7bd805ffb418 d3b270909c78 b63fc7a77fc6 ee130dd9eef2 1ec6fdbc6bcb ef7d4ea6f33b d36ba2f79f95 028545dfa7a4 3652104ae7d3 c2e1d09047db 0299076f09a2 733ea16a9701 767ac6cd0545 4b9d06778581 68dcf21fe055 f53cbe7a204e f0a517d28237 3a6effc74835 c6e69e4b8d8f 74b4a3c62ea4 092457020692 a39754d02c58 e5d9b0513f3e b582948f1912 68a924846379 aa4939e07a09 9b723f69b032 b689779e9e74 530a0fc650a2 9d580f2132f3 d6e0d5b8b339 cb8b8082fa73 ac03130a2b95 e8e6e0cac26a 79b5a90ffd43 7505194116e7 19303b4a06fb 170bb436e1d5 f6dccc4533c8 6eae06b6ec04 42ccf504aa50 54e2e6309aa3 bf3a67819b59 d069701a4997 dd21fd82687d 614f55e14eee 5cde7882d767 ff1a64f81c1c 648189388f1a 58dfe0e134eb 2c9ddac3c387 47ce8b8e2d4b 7ba155f20aa0 9ae4d15a0e4b 9aa399044d9d 9f9e72b93e4e a46f3e4f50b1 b35df36b7dc5 631fc8970bd0 5b4c51307b14 360aa74a6277 236f35c0baba 0389c3e82f6c e96a430c78f3 33472ccc08cc b30a880fa409 1ec3f008e96a 77445673174b 3604179679eb c07406bcc237 98a1620a3840 2bf22305bc48 14edbaaf5291 ff1f62fee654 d4dbab97e948 a17fc622ca43 e266b7d4065c 073a2115995f f9466021286a ce14f0d68b15 4cce912a0953 36e51aa806c6 1966f5db15ee 76740263089c 06d16dc2bc13 05059d434aca 992467d31d26 d0990c2bff74 f9575918477f 40a40bb7b798 dbf06d342e1d e83354cdac22 2306a4d24779 d0d52107b074 4d6c9c1b358c 0f93f49ce059 da652d6392d8 a87c7b9d563a 2c75672fb2fe 519c7c061bcb 598009a75f6c 01a47cd7f0c8 192fad1e46a2 8b276e0106d1 69816d688300 0554aaa7ddef eaf3e9068180 d3cd7ef95da6 c5ce0367eddf 57eaac9107df da868d5d2646 86e1824f2d0b 7d9dab131937 1a3b62032ed9 4fe7d6045860 6caacfff1d4f 6f8aeb996558 a9f686edf60b e293c90dca62 dfb23a3a8cfa 958fb27dda7c fc1a466a1e66 83d61e803ccd f7510b63cceb 8ab265dc4a51 e7ac6c2e84e4 6a81dbab471d 1c2bd9abdd4f be451356e3ef 61e3bce7ea5b 0912a0337a95 8c30d814f2cc f8c695764c95 cc7a00565161 e8d38e47e9d7 60a1223c894f 0955469ac0e1 7df9007b163c afaba5f1306d 696bbf29d3eb 0bdc3f91a39f bc489b6c7f69 c863c7a7a2d1 62794ecc7f74 d42369abf180 3e5772ccf9da 19555025a2ac e7ddd552b619 07eb99be126e 4dcb8c0916b7 3526d339519f eae595568832 84b9e9ac0ab4 be0161a88dd7 76f752503e7e 119bbf50cb86 7aa2dc9369e3 05e71ed6cdf1
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
1686 atoms, 0 tree operations, 49 files

Browse the tree at this change

modified crates/oxy-core/src/attest.rs +104 −26
…
1 unchanged lines
2 2
//! of changes. Only the `ref` and `seal` kinds exist so far.
3 3
4 4
use crate::cbor::{self, Value};
5 -
use crate::hash::{KeyId, hash_object, ChangeHash, ObjectRef, ObjectType};
6 -
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION};
5 +
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
6 +
use crate::objects::{
7 +
    array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError, FORMAT_VERSION,
8 +
};
7 9
8 10
pub const MAX_SEAL_HASHES: usize = 4096;
9 11
10 12
#[derive(Clone, Debug, PartialEq, Eq)]
11 13
pub enum AttestBody {
12 14
    /// A bookmark moved from `old_state` to `new_state` (spec §10.2).
13 -
    Ref { name: String, old_state: ObjectRef, new_state: ObjectRef, serial: u64 },
15 +
    Ref {
16 +
        name: String,
17 +
        old_state: ObjectRef,
18 +
        new_state: ObjectRef,
19 +
        serial: u64,
20 +
    },
14 21
    /// One signature covering a batch of changes (spec §14.4).
15 22
    Seal { hashes: Vec<ChangeHash> },
16 23
    /// An immutable named state (spec §10.1).
17 -
    Tag { name: String, state: ObjectRef, message: String },
24 +
    Tag {
25 +
        name: String,
26 +
        state: ObjectRef,
27 +
        message: String,
28 +
    },
18 29
    /// The signing key's identity was replaced by `new` (spec §14.2).
19 30
    Rotate { new: KeyId },
20 31
    /// `key` must no longer be trusted (spec §14.2).
…
22 unchanged lines
43 54
    pub fn seal(repo: [u8; 16], mut hashes: Vec<ChangeHash>) -> Attest {
44 55
        hashes.sort();
45 56
        hashes.dedup();
46 -
        Attest { repo, hlc: Hlc::now(), body: AttestBody::Seal { hashes } }
57 +
        Attest {
58 +
            repo,
59 +
            hlc: Hlc::now(),
60 +
            body: AttestBody::Seal { hashes },
61 +
        }
47 62
    }
48 63
49 64
    pub fn to_value(&self) -> Value {
50 65
        let body = match &self.body {
51 -
            AttestBody::Ref { name, old_state, new_state, serial } => Value::map([
66 +
            AttestBody::Ref {
67 +
                name,
68 +
                old_state,
69 +
                new_state,
70 +
                serial,
71 +
            } => Value::map([
52 72
                (0, Value::Text(name.clone())),
53 73
                (1, Value::Bytes(old_state.0.to_vec())),
54 74
                (2, Value::Bytes(new_state.0.to_vec())),
55 75
                (3, Value::Uint(*serial)),
56 76
            ]),
57 -
            AttestBody::Seal { hashes } => {
58 -
                Value::map([(0, Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()))])
59 -
            }
60 -
            AttestBody::Tag { name, state, message } => {
61 -
                Value::map([(0, Value::Text(name.clone())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(message.clone()))])
62 -
            }
77 +
            AttestBody::Seal { hashes } => Value::map([(
78 +
                0,
79 +
                Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
80 +
            )]),
81 +
            AttestBody::Tag {
82 +
                name,
83 +
                state,
84 +
                message,
85 +
            } => Value::map([
86 +
                (0, Value::Text(name.clone())),
87 +
                (1, Value::Bytes(state.0.to_vec())),
88 +
                (2, Value::Text(message.clone())),
89 +
            ]),
63 90
            AttestBody::Rotate { new } => Value::map([(0, Value::Bytes(new.0.to_vec()))]),
64 -
            AttestBody::Revoke { key, reason } => Value::map([(0, Value::Bytes(key.0.to_vec())), (1, Value::Text(reason.clone()))]),
91 +
            AttestBody::Revoke { key, reason } => Value::map([
92 +
                (0, Value::Bytes(key.0.to_vec())),
93 +
                (1, Value::Text(reason.clone())),
94 +
            ]),
65 95
        };
66 96
        Value::map([
67 97
            (0, Value::Uint(FORMAT_VERSION)),
…
30 unchanged lines
98 128
                serial: uint(field(b, 3, "serial")?, "serial")?,
99 129
            },
100 130
            "seal" => {
101 -
                let hashes = array(field(b, 0, "hashes")?, "hashes")?.iter().map(|h| oref(h, "hash")).collect::<Result<Vec<_>, _>>()?;
102 -
                if hashes.is_empty() || hashes.len() > MAX_SEAL_HASHES || !hashes.windows(2).all(|w| w[0] < w[1]) {
103 -
                    return Err(ObjectError::Schema("seal: hashes must be 1..4096, sorted and unique".into()));
131 +
                let hashes = array(field(b, 0, "hashes")?, "hashes")?
132 +
                    .iter()
133 +
                    .map(|h| oref(h, "hash"))
134 +
                    .collect::<Result<Vec<_>, _>>()?;
135 +
                if hashes.is_empty()
136 +
                    || hashes.len() > MAX_SEAL_HASHES
137 +
                    || !hashes.windows(2).all(|w| w[0] < w[1])
138 +
                {
139 +
                    return Err(ObjectError::Schema(
140 +
                        "seal: hashes must be 1..4096, sorted and unique".into(),
141 +
                    ));
104 142
                }
105 143
                AttestBody::Seal { hashes }
106 144
            }
…
2 unchanged lines
109 147
                state: oref(field(b, 1, "state")?, "state")?,
110 148
                message: text(field(b, 2, "message")?, "message")?.to_owned(),
111 149
            },
112 -
            "rotate" => AttestBody::Rotate { new: oref(field(b, 0, "new")?, "new")? },
113 -
            "revoke" => AttestBody::Revoke { key: oref(field(b, 0, "key")?, "key")?, reason: text(field(b, 1, "reason")?, "reason")?.to_owned() },
114 -
            other => return Err(ObjectError::Schema(format!("unknown attestation kind {other:?}"))),
150 +
            "rotate" => AttestBody::Rotate {
151 +
                new: oref(field(b, 0, "new")?, "new")?,
152 +
            },
153 +
            "revoke" => AttestBody::Revoke {
154 +
                key: oref(field(b, 0, "key")?, "key")?,
155 +
                reason: text(field(b, 1, "reason")?, "reason")?.to_owned(),
156 +
            },
157 +
            other => {
158 +
                return Err(ObjectError::Schema(format!(
159 +
                    "unknown attestation kind {other:?}"
160 +
                )))
161 +
            }
115 162
        };
116 163
        Ok(Attest { repo, hlc, body })
117 164
    }
…
7 unchanged lines
125 172
    fn round_trips() {
126 173
        let r = Attest {
127 174
            repo: [1; 16],
128 -
            hlc: Hlc { wall_ms: 3, logical: 0 },
129 -
            body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 7 },
175 +
            hlc: Hlc {
176 +
                wall_ms: 3,
177 +
                logical: 0,
178 +
            },
179 +
            body: AttestBody::Ref {
180 +
                name: "main".into(),
181 +
                old_state: ObjectRef([1; 32]),
182 +
                new_state: ObjectRef([2; 32]),
183 +
                serial: 7,
184 +
            },
130 185
        };
131 186
        assert_eq!(Attest::decode(&r.encode()).unwrap(), r);
132 187
        assert_eq!(r.kind(), "ref");
133 -
        let s = Attest::seal([1; 16], vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])]);
188 +
        let s = Attest::seal(
189 +
            [1; 16],
190 +
            vec![ObjectRef([9; 32]), ObjectRef([3; 32]), ObjectRef([9; 32])],
191 +
        );
134 192
        let d = Attest::decode(&s.encode()).unwrap();
135 -
        assert!(matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])]));
193 +
        assert!(
194 +
            matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])])
195 +
        );
136 196
        assert_ne!(s.hash(), r.hash());
137 -
        let t = Attest { repo: [1; 16], hlc: Hlc::default(), body: AttestBody::Tag { name: "v1".into(), state: ObjectRef([5; 32]), message: "release".into() } };
197 +
        let t = Attest {
198 +
            repo: [1; 16],
199 +
            hlc: Hlc::default(),
200 +
            body: AttestBody::Tag {
201 +
                name: "v1".into(),
202 +
                state: ObjectRef([5; 32]),
203 +
                message: "release".into(),
204 +
            },
205 +
        };
138 206
        assert_eq!(Attest::decode(&t.encode()).unwrap(), t);
139 207
        assert_eq!(t.kind(), "tag");
140 208
    }
141 209
142 210
    #[test]
143 211
    fn rejects_bad_seals() {
144 -
        let empty = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![] } };
212 +
        let empty = Attest {
213 +
            repo: [0; 16],
214 +
            hlc: Hlc::default(),
215 +
            body: AttestBody::Seal { hashes: vec![] },
216 +
        };
145 217
        assert!(Attest::decode(&empty.encode()).is_err());
146 -
        let unsorted = Attest { repo: [0; 16], hlc: Hlc::default(), body: AttestBody::Seal { hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])] } };
218 +
        let unsorted = Attest {
219 +
            repo: [0; 16],
220 +
            hlc: Hlc::default(),
221 +
            body: AttestBody::Seal {
222 +
                hashes: vec![ObjectRef([2; 32]), ObjectRef([1; 32])],
223 +
            },
224 +
        };
147 225
        assert!(Attest::decode(&unsorted.encode()).is_err());
148 226
    }
149 227
}
modified crates/oxy-core/src/attributes.rs +20 −4
…
42 unchanged lines
43 43
                continue;
44 44
            }
45 45
            let mut parts = line.split_whitespace();
46 -
            let Some(pattern) = parts.next() else { continue };
46 +
            let Some(pattern) = parts.next() else {
47 +
                continue;
48 +
            };
47 49
            let attrs: Vec<(String, Option<String>)> = parts
48 50
                .map(|a| match a.split_once('=') {
49 51
                    Some((k, v)) => (k.to_string(), Some(v.to_string())),
…
9 unchanged lines
59 61
                (false, false) => format!("{dir}/**/{core}"),
60 62
            };
61 63
            if let Ok(g) = Glob::new(&full) {
62 -
                self.rules.push(Rule { matcher: g.compile_matcher(), attrs });
64 +
                self.rules.push(Rule {
65 +
                    matcher: g.compile_matcher(),
66 +
                    attrs,
67 +
                });
63 68
            }
64 69
        }
65 70
    }
…
11 unchanged lines
77 82
        let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect();
78 83
        entries.sort_by_key(|(p, _)| p.matches('/').count());
79 84
        for (p, c) in entries {
80 -
            let dir = p.strip_suffix(".oxyattributes").unwrap_or("").trim_end_matches('/');
85 +
            let dir = p
86 +
                .strip_suffix(".oxyattributes")
87 +
                .unwrap_or("")
88 +
                .trim_end_matches('/');
81 89
            out.add_file(dir, &String::from_utf8_lossy(c));
82 90
        }
83 91
        out
…
68 unchanged lines
152 160
            ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]),
153 161
            ("README.md", &b"not attributes"[..]),
154 162
        ]);
155 -
        assert_eq!(a.lookup("a/b/logo.png"), Attrs { binary: Some(true), eol: None, nocompress: true, lockable: false });
163 +
        assert_eq!(
164 +
            a.lookup("a/b/logo.png"),
165 +
            Attrs {
166 +
                binary: Some(true),
167 +
                eol: None,
168 +
                nocompress: true,
169 +
                lockable: false
170 +
            }
171 +
        );
156 172
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
157 173
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
158 174
        assert!(a.lookup("models/x/y.bin").lockable);
…
14 unchanged lines
modified crates/oxy-core/src/cbor.rs +109 −25
…
38 unchanged lines
39 39
        Value::Map(pairs.into_iter().collect())
40 40
    }
41 41
    pub fn as_uint(&self) -> Option<u64> {
42 -
        if let Value::Uint(n) = self { Some(*n) } else { None }
42 +
        if let Value::Uint(n) = self {
43 +
            Some(*n)
44 +
        } else {
45 +
            None
46 +
        }
43 47
    }
44 48
    pub fn as_bytes(&self) -> Option<&[u8]> {
45 -
        if let Value::Bytes(b) = self { Some(b) } else { None }
49 +
        if let Value::Bytes(b) = self {
50 +
            Some(b)
51 +
        } else {
52 +
            None
53 +
        }
46 54
    }
47 55
    pub fn as_text(&self) -> Option<&str> {
48 -
        if let Value::Text(t) = self { Some(t) } else { None }
56 +
        if let Value::Text(t) = self {
57 +
            Some(t)
58 +
        } else {
59 +
            None
60 +
        }
49 61
    }
50 62
    pub fn as_array(&self) -> Option<&[Value]> {
51 -
        if let Value::Array(a) = self { Some(a) } else { None }
63 +
        if let Value::Array(a) = self {
64 +
            Some(a)
65 +
        } else {
66 +
            None
67 +
        }
52 68
    }
53 69
    pub fn as_map(&self) -> Option<&BTreeMap<u64, Value>> {
54 -
        if let Value::Map(m) = self { Some(m) } else { None }
70 +
        if let Value::Map(m) = self {
71 +
            Some(m)
72 +
        } else {
73 +
            None
74 +
        }
55 75
    }
56 76
    pub fn as_bool(&self) -> Option<bool> {
57 -
        if let Value::Bool(b) = self { Some(*b) } else { None }
77 +
        if let Value::Bool(b) = self {
78 +
            Some(*b)
79 +
        } else {
80 +
            None
81 +
        }
58 82
    }
59 83
    pub fn is_null(&self) -> bool {
60 84
        matches!(self, Value::Null)
…
56 unchanged lines
117 141
}
118 142
119 143
pub fn decode(bytes: &[u8]) -> Result<Value, CborError> {
120 -
    let mut d = Decoder { b: bytes, pos: 0, depth: 0 };
144 +
    let mut d = Decoder {
145 +
        b: bytes,
146 +
        pos: 0,
147 +
        depth: 0,
148 +
    };
121 149
    let v = d.value()?;
122 150
    if d.pos != bytes.len() {
123 151
        return Err(CborError::TrailingBytes);
…
34 unchanged lines
158 186
        let major = ib >> 5;
159 187
        let ai = ib & 0x1f;
160 188
        if major == 7 {
161 -
            return Err(CborError::Unsupported("simple value or float where a length or integer was expected"));
189 +
            return Err(CborError::Unsupported(
190 +
                "simple value or float where a length or integer was expected",
191 +
            ));
162 192
        }
163 193
        let n = match ai {
164 194
            0..=23 => ai as u64,
…
43 unchanged lines
208 238
                0xf4 => Value::Bool(false),
209 239
                0xf5 => Value::Bool(true),
210 240
                0xf6 => Value::Null,
211 -
                _ => return Err(CborError::Unsupported("simple value, float, undefined, or break")),
241 +
                _ => {
242 +
                    return Err(CborError::Unsupported(
243 +
                        "simple value, float, undefined, or break",
244 +
                    ))
245 +
                }
212 246
            }
213 247
        } else {
214 248
            let (major, n) = self.head()?;
…
64 unchanged lines
279 313
280 314
    #[test]
281 315
    fn rejects_non_shortest_integers() {
282 -
        assert_eq!(decode(&[0x18, 5]), Err(CborError::NonCanonical("argument fits in the initial byte")));
283 -
        assert_eq!(decode(&[0x19, 0, 5]), Err(CborError::NonCanonical("argument fits in one byte")));
284 -
        assert_eq!(decode(&[0x1a, 0, 0, 0, 5]), Err(CborError::NonCanonical("argument fits in two bytes")));
285 -
        assert_eq!(decode(&[0x1b, 0, 0, 0, 0, 0, 0, 0, 5]), Err(CborError::NonCanonical("argument fits in four bytes")));
316 +
        assert_eq!(
317 +
            decode(&[0x18, 5]),
318 +
            Err(CborError::NonCanonical("argument fits in the initial byte"))
319 +
        );
320 +
        assert_eq!(
321 +
            decode(&[0x19, 0, 5]),
322 +
            Err(CborError::NonCanonical("argument fits in one byte"))
323 +
        );
324 +
        assert_eq!(
325 +
            decode(&[0x1a, 0, 0, 0, 5]),
326 +
            Err(CborError::NonCanonical("argument fits in two bytes"))
327 +
        );
328 +
        assert_eq!(
329 +
            decode(&[0x1b, 0, 0, 0, 0, 0, 0, 0, 5]),
330 +
            Err(CborError::NonCanonical("argument fits in four bytes"))
331 +
        );
286 332
    }
287 333
288 334
    #[test]
289 335
    fn rejects_unsupported_items() {
290 336
        assert!(matches!(decode(&[0x20]), Err(CborError::Unsupported(_)))); // -1
291 -
        assert!(matches!(decode(&[0xc0, 0x00]), Err(CborError::Unsupported(_)))); // tag
292 -
        assert!(matches!(decode(&[0xf9, 0, 0]), Err(CborError::Unsupported(_)))); // float16
293 -
        assert!(matches!(decode(&[0x9f, 0xff]), Err(CborError::Unsupported(_)))); // indefinite array
337 +
        assert!(matches!(
338 +
            decode(&[0xc0, 0x00]),
339 +
            Err(CborError::Unsupported(_))
340 +
        )); // tag
341 +
        assert!(matches!(
342 +
            decode(&[0xf9, 0, 0]),
343 +
            Err(CborError::Unsupported(_))
344 +
        )); // float16
345 +
        assert!(matches!(
346 +
            decode(&[0x9f, 0xff]),
347 +
            Err(CborError::Unsupported(_))
348 +
        )); // indefinite array
294 349
        assert!(matches!(decode(&[0xf7]), Err(CborError::Unsupported(_)))); // undefined
295 350
    }
296 351
297 352
    #[test]
298 353
    fn maps_are_sorted_and_unique() {
299 -
        let v = Value::map([(2, Value::Uint(1)), (0, Value::Null), (1, Value::Bool(true))]);
354 +
        let v = Value::map([
355 +
            (2, Value::Uint(1)),
356 +
            (0, Value::Null),
357 +
            (1, Value::Bool(true)),
358 +
        ]);
300 359
        let b = rt(v);
301 360
        assert_eq!(b, [0xa3, 0x00, 0xf6, 0x01, 0xf5, 0x02, 0x01]);
302 -
        assert_eq!(decode(&[0xa2, 0x01, 0x00, 0x00, 0x00]), Err(CborError::BadMapKey));
303 -
        assert_eq!(decode(&[0xa2, 0x01, 0x00, 0x01, 0x00]), Err(CborError::BadMapKey));
304 -
        assert_eq!(decode(&[0xa1, 0x61, 0x61, 0x00]), Err(CborError::BadMapKey)); // text key
361 +
        assert_eq!(
362 +
            decode(&[0xa2, 0x01, 0x00, 0x00, 0x00]),
363 +
            Err(CborError::BadMapKey)
364 +
        );
365 +
        assert_eq!(
366 +
            decode(&[0xa2, 0x01, 0x00, 0x01, 0x00]),
367 +
            Err(CborError::BadMapKey)
368 +
        );
369 +
        assert_eq!(decode(&[0xa1, 0x61, 0x61, 0x00]), Err(CborError::BadMapKey));
370 +
        // text key
305 371
    }
306 372
307 373
    #[test]
308 374
    fn strings_arrays_and_trailing() {
309 375
        rt(Value::Bytes(vec![1, 2, 3]));
310 376
        rt(Value::Text("héllo".into()));
311 -
        rt(Value::Array(vec![Value::Uint(1), Value::Text("x".into()), Value::Array(vec![])]));
377 +
        rt(Value::Array(vec![
378 +
            Value::Uint(1),
379 +
            Value::Text("x".into()),
380 +
            Value::Array(vec![]),
381 +
        ]));
312 382
        assert_eq!(decode(&[0x00, 0x00]), Err(CborError::TrailingBytes));
313 383
        assert_eq!(decode(&[0x62, 0xff, 0xfe]), Err(CborError::InvalidUtf8));
314 384
        assert_eq!(decode(&[0x43, 1, 2]), Err(CborError::UnexpectedEnd));
…
1 unchanged lines
316 386
317 387
    #[test]
318 388
    fn huge_length_does_not_allocate() {
319 -
        assert_eq!(decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), Err(CborError::LengthOverflow));
389 +
        assert_eq!(
390 +
            decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
391 +
            Err(CborError::LengthOverflow)
392 +
        );
320 393
        assert!(decode(&[0x9b, 0x00, 0x00, 0x00, 0x01, 0, 0, 0, 0]).is_err());
321 394
    }
322 395
…
20 unchanged lines
343 416
            }
344 417
            4 => {
345 418
                let n = (xorshift(seed) % 12) as usize;
346 -
                Value::Text((0..n).map(|_| char::from_u32((xorshift(seed) % 0x24f) as u32 + 1).unwrap_or('x')).collect())
419 +
                Value::Text(
420 +
                    (0..n)
421 +
                        .map(|_| char::from_u32((xorshift(seed) % 0x24f) as u32 + 1).unwrap_or('x'))
422 +
                        .collect(),
423 +
                )
347 424
            }
348 425
            5 => {
349 426
                let n = (xorshift(seed) % 5) as usize;
…
32 unchanged lines
382 459
            let n = (xorshift(&mut seed) % 64) as usize;
383 460
            let bytes: Vec<u8> = (0..n).map(|_| (xorshift(&mut seed) & 0xff) as u8).collect();
384 461
            if let Ok(v) = decode(&bytes) {
385 -
                assert_eq!(encode(&v), bytes, "accepted input must be canonical: {bytes:02x?}");
462 +
                assert_eq!(
463 +
                    encode(&v),
464 +
                    bytes,
465 +
                    "accepted input must be canonical: {bytes:02x?}"
466 +
                );
386 467
            }
387 468
        }
388 469
        for _ in 0..3000 {
…
25 unchanged lines
414 495
        deep.push(0x00);
415 496
        assert!(matches!(decode(&deep), Err(CborError::Unsupported(_))));
416 497
        // Lengths beyond the input are refused without allocating them.
417 -
        assert!(matches!(decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), Err(CborError::LengthOverflow) | Err(CborError::UnexpectedEnd)));
498 +
        assert!(matches!(
499 +
            decode(&[0x5b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
500 +
            Err(CborError::LengthOverflow) | Err(CborError::UnexpectedEnd)
501 +
        ));
418 502
    }
419 503
}
modified crates/oxy-core/src/chunk.rs +21 −5
…
20 unchanged lines
21 21
    static GEAR: OnceLock<[u64; 256]> = OnceLock::new();
22 22
    GEAR.get_or_init(|| {
23 23
        let mut out = [0u8; 256 * 8];
24 -
        blake3::Hasher::new().update(GEAR_SEED).finalize_xof().fill(&mut out);
24 +
        blake3::Hasher::new()
25 +
            .update(GEAR_SEED)
26 +
            .finalize_xof()
27 +
            .fill(&mut out);
25 28
        let mut g = [0u64; 256];
26 29
        for (i, v) in g.iter_mut().enumerate() {
27 30
            *v = u64::from_le_bytes(out[i * 8..i * 8 + 8].try_into().unwrap());
…
78 unchanged lines
106 109
        assert_eq!(MASK_S & (1 << 63), 1 << 63);
107 110
        // Pin the first gear entry so the table cannot drift silently.
108 111
        let mut out = [0u8; 8];
109 -
        blake3::Hasher::new().update(GEAR_SEED).finalize_xof().fill(&mut out);
112 +
        blake3::Hasher::new()
113 +
            .update(GEAR_SEED)
114 +
            .finalize_xof()
115 +
            .fill(&mut out);
110 116
        assert_eq!(gear()[0], u64::from_le_bytes(out));
111 117
        assert_ne!(gear()[1], gear()[2]);
112 118
    }
…
30 unchanged lines
143 149
        let d = random(2 * 1024 * 1024, 3);
144 150
        let mut e = d.clone();
145 151
        e.splice(1_000_000..1_000_000, random(100, 4));
146 -
        let a: std::collections::HashSet<[u8; 32]> = chunks(&d).iter().map(|c| *blake3::hash(c).as_bytes()).collect();
147 -
        let b: Vec<[u8; 32]> = chunks(&e).iter().map(|c| *blake3::hash(c).as_bytes()).collect();
152 +
        let a: std::collections::HashSet<[u8; 32]> = chunks(&d)
153 +
            .iter()
154 +
            .map(|c| *blake3::hash(c).as_bytes())
155 +
            .collect();
156 +
        let b: Vec<[u8; 32]> = chunks(&e)
157 +
            .iter()
158 +
            .map(|c| *blake3::hash(c).as_bytes())
159 +
            .collect();
148 160
        let shared = b.iter().filter(|h| a.contains(*h)).count();
149 -
        assert!(shared * 10 >= b.len() * 8, "only {shared} of {} chunks shared", b.len());
161 +
        assert!(
162 +
            shared * 10 >= b.len() * 8,
163 +
            "only {shared} of {} chunks shared",
164 +
            b.len()
165 +
        );
150 166
    }
151 167
152 168
    #[test]
…
10 unchanged lines
modified crates/oxy-core/src/diff.rs +123 −19
…
30 unchanged lines
31 31
    for &(c, i) in keys {
32 32
        match out.last_mut() {
33 33
            Some(r) if r.change == c && r.to == i => r.to = i + 1,
34 -
            _ => out.push(AtomRange { change: c, from: i, to: i + 1 }),
34 +
            _ => out.push(AtomRange {
35 +
                change: c,
36 +
                from: i,
37 +
                to: i + 1,
38 +
            }),
35 39
        }
36 40
    }
37 41
    out
…
6 unchanged lines
44 48
    let ops = capture_diff_slices(Algorithm::Myers, &old_lines, &new_lines);
45 49
46 50
    let anchor = |i: usize| -> (AtomRef, AtomRef) {
47 -
        let after = if i == 0 { AtomRef::Start } else { AtomRef::Atom { change: old[i - 1].0 .0, index: old[i - 1].0 .1 } };
48 -
        let before = if i >= old.len() { AtomRef::End } else { AtomRef::Atom { change: old[i].0 .0, index: old[i].0 .1 } };
51 +
        let after = if i == 0 {
52 +
            AtomRef::Start
53 +
        } else {
54 +
            AtomRef::Atom {
55 +
                change: old[i - 1].0 .0,
56 +
                index: old[i - 1].0 .1,
57 +
            }
58 +
        };
59 +
        let before = if i >= old.len() {
60 +
            AtomRef::End
61 +
        } else {
62 +
            AtomRef::Atom {
63 +
                change: old[i].0 .0,
64 +
                index: old[i].0 .1,
65 +
            }
66 +
        };
49 67
        (after, before)
50 68
    };
51 69
…
2 unchanged lines
54 72
    for op in ops {
55 73
        match op {
56 74
            DiffOp::Equal { .. } => {}
57 -
            DiffOp::Delete { old_index, old_len, .. } => {
75 +
            DiffOp::Delete {
76 +
                old_index, old_len, ..
77 +
            } => {
58 78
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
59 79
            }
60 -
            DiffOp::Insert { old_index, new_index, new_len } => {
80 +
            DiffOp::Insert {
81 +
                old_index,
82 +
                new_index,
83 +
                new_len,
84 +
            } => {
61 85
                let (after, before) = anchor(old_index);
62 86
                inserts.push(Edit::Insert {
63 87
                    node,
64 88
                    after,
65 89
                    before,
66 -
                    lines: new_lines[new_index..new_index + new_len].iter().map(|l| l.to_vec()).collect(),
90 +
                    lines: new_lines[new_index..new_index + new_len]
91 +
                        .iter()
92 +
                        .map(|l| l.to_vec())
93 +
                        .collect(),
67 94
                });
68 95
            }
69 -
            DiffOp::Replace { old_index, old_len, new_index, new_len } => {
96 +
            DiffOp::Replace {
97 +
                old_index,
98 +
                old_len,
99 +
                new_index,
100 +
                new_len,
101 +
            } => {
70 102
                deleted.extend(old[old_index..old_index + old_len].iter().map(|(k, _)| *k));
71 103
                let (after, before) = anchor(old_index);
72 104
                inserts.push(Edit::Insert {
73 105
                    node,
74 106
                    after,
75 107
                    before,
76 -
                    lines: new_lines[new_index..new_index + new_len].iter().map(|l| l.to_vec()).collect(),
108 +
                    lines: new_lines[new_index..new_index + new_len]
109 +
                        .iter()
110 +
                        .map(|l| l.to_vec())
111 +
                        .collect(),
77 112
                });
78 113
            }
79 114
        }
80 115
    }
81 116
    let mut edits = Vec::new();
82 117
    if !deleted.is_empty() {
83 -
        edits.push(Edit::Delete { node, atoms: ranges(&deleted) });
118 +
        edits.push(Edit::Delete {
119 +
            node,
120 +
            atoms: ranges(&deleted),
121 +
        });
84 122
    }
85 123
    edits.extend(inserts);
86 124
    edits
…
22 unchanged lines
109 147
        assert_eq!(
110 148
            r,
111 149
            vec![
112 -
                AtomRange { change: h(1), from: 0, to: 2 },
113 -
                AtomRange { change: h(1), from: 3, to: 4 },
114 -
                AtomRange { change: h(2), from: 0, to: 1 }
150 +
                AtomRange {
151 +
                    change: h(1),
152 +
                    from: 0,
153 +
                    to: 2
154 +
                },
155 +
                AtomRange {
156 +
                    change: h(1),
157 +
                    from: 3,
158 +
                    to: 4
159 +
                },
160 +
                AtomRange {
161 +
                    change: h(2),
162 +
                    from: 0,
163 +
                    to: 1
164 +
                }
115 165
            ]
116 166
        );
117 167
    }
…
1 unchanged lines
119 169
    #[test]
120 170
    fn edits_for_typical_changes() {
121 171
        let node = NodeId::random();
122 -
        let old: Vec<(AtomKey, Vec<u8>)> = ["a\n", "b\n", "c\n"].iter().enumerate().map(|(i, l)| ((h(1), i as u32), l.as_bytes().to_vec())).collect();
172 +
        let old: Vec<(AtomKey, Vec<u8>)> = ["a\n", "b\n", "c\n"]
173 +
            .iter()
174 +
            .enumerate()
175 +
            .map(|(i, l)| ((h(1), i as u32), l.as_bytes().to_vec()))
176 +
            .collect();
123 177
        // No change.
124 178
        assert!(text_edits(node, &old, b"a\nb\nc\n").is_empty());
125 179
        // Replace middle line.
…
1 unchanged lines
127 181
        assert_eq!(
128 182
            e,
129 183
            vec![
130 -
                Edit::Delete { node, atoms: vec![AtomRange { change: h(1), from: 1, to: 2 }] },
131 -
                Edit::Insert { node, after: AtomRef::Atom { change: h(1), index: 0 }, before: AtomRef::Atom { change: h(1), index: 1 }, lines: vec![b"B\n".to_vec()] },
184 +
                Edit::Delete {
185 +
                    node,
186 +
                    atoms: vec![AtomRange {
187 +
                        change: h(1),
188 +
                        from: 1,
189 +
                        to: 2
190 +
                    }]
191 +
                },
192 +
                Edit::Insert {
193 +
                    node,
194 +
                    after: AtomRef::Atom {
195 +
                        change: h(1),
196 +
                        index: 0
197 +
                    },
198 +
                    before: AtomRef::Atom {
199 +
                        change: h(1),
200 +
                        index: 1
201 +
                    },
202 +
                    lines: vec![b"B\n".to_vec()]
203 +
                },
132 204
            ]
133 205
        );
134 206
        // Prepend and append.
135 207
        let e = text_edits(node, &old, b"top\na\nb\nc\nend\n");
136 208
        assert_eq!(e.len(), 2);
137 -
        assert!(matches!(&e[0], Edit::Insert { after: AtomRef::Start, before: AtomRef::Atom { index: 0, .. }, .. }));
138 -
        assert!(matches!(&e[1], Edit::Insert { after: AtomRef::Atom { index: 2, .. }, before: AtomRef::End, .. }));
209 +
        assert!(matches!(
210 +
            &e[0],
211 +
            Edit::Insert {
212 +
                after: AtomRef::Start,
213 +
                before: AtomRef::Atom { index: 0, .. },
214 +
                ..
215 +
            }
216 +
        ));
217 +
        assert!(matches!(
218 +
            &e[1],
219 +
            Edit::Insert {
220 +
                after: AtomRef::Atom { index: 2, .. },
221 +
                before: AtomRef::End,
222 +
                ..
223 +
            }
224 +
        ));
139 225
        // Delete everything.
140 226
        let e = text_edits(node, &old, b"");
141 -
        assert_eq!(e, vec![Edit::Delete { node, atoms: vec![AtomRange { change: h(1), from: 0, to: 3 }] }]);
227 +
        assert_eq!(
228 +
            e,
229 +
            vec![Edit::Delete {
230 +
                node,
231 +
                atoms: vec![AtomRange {
232 +
                    change: h(1),
233 +
                    from: 0,
234 +
                    to: 3
235 +
                }]
236 +
            }]
237 +
        );
142 238
        // From empty.
143 239
        let e = text_edits(node, &[], b"x\n");
144 -
        assert_eq!(e, vec![Edit::Insert { node, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }]);
240 +
        assert_eq!(
241 +
            e,
242 +
            vec![Edit::Insert {
243 +
                node,
244 +
                after: AtomRef::Start,
245 +
                before: AtomRef::End,
246 +
                lines: vec![b"x\n".to_vec()]
247 +
            }]
248 +
        );
145 249
    }
146 250
}
modified crates/oxy-core/src/graph.rs +396 −65
…
58 unchanged lines
59 59
    Conflict(Vec<Vec<AtomKey>>),
60 60
    /// Delete/insert conflict: `inserted` was anchored to `deleted` by a change
61 61
    /// that commutes with the deletion.
62 -
    Zombie { deleted: Vec<AtomKey>, inserted: Vec<AtomKey> },
62 +
    Zombie {
63 +
        deleted: Vec<AtomKey>,
64 +
        inserted: Vec<AtomKey>,
65 +
    },
63 66
}
64 67
65 68
#[derive(Clone, Debug, PartialEq, Eq, Default)]
…
3 unchanged lines
69 72
70 73
impl Materialized {
71 74
    pub fn is_conflicted(&self) -> bool {
72 -
        self.segments.iter().any(|s| !matches!(s, Segment::Lines(_)))
75 +
        self.segments
76 +
            .iter()
77 +
            .any(|s| !matches!(s, Segment::Lines(_)))
73 78
    }
74 79
    pub fn conflict_count(&self) -> usize {
75 -
        self.segments.iter().filter(|s| !matches!(s, Segment::Lines(_))).count()
80 +
        self.segments
81 +
            .iter()
82 +
            .filter(|s| !matches!(s, Segment::Lines(_)))
83 +
            .count()
76 84
    }
77 85
    /// Every live atom in materialized order, conflict sides concatenated.
78 86
    pub fn live_atoms(&self) -> Vec<AtomKey> {
…
11 unchanged lines
90 98
91 99
impl FileGraph {
92 100
    pub fn new() -> Self {
93 -
        let mut g = FileGraph { atoms: HashMap::new(), succ: HashMap::new(), pred: HashMap::new() };
101 +
        let mut g = FileGraph {
102 +
            atoms: HashMap::new(),
103 +
            succ: HashMap::new(),
104 +
            pred: HashMap::new(),
105 +
        };
94 106
        g.edge(Vertex::Start, Vertex::End);
95 107
        g
96 108
    }
…
6 unchanged lines
103 115
    }
104 116
105 117
    /// Apply an `Insert` by `change` whose first new atom has index `first_index`.
106 -
    pub fn insert(&mut self, change: ChangeHash, first_index: u32, after: Vertex, before: Vertex, lines: &[Vec<u8>]) {
118 +
    pub fn insert(
119 +
        &mut self,
120 +
        change: ChangeHash,
121 +
        first_index: u32,
122 +
        after: Vertex,
123 +
        before: Vertex,
124 +
        lines: &[Vec<u8>],
125 +
    ) {
107 126
        let mut prev = after;
108 127
        for (i, line) in lines.iter().enumerate() {
109 128
            let key = (change, first_index + i as u32);
…
8 unchanged lines
118 137
    /// Apply a `Delete` of `range` by change `by`.
119 138
    pub fn delete(&mut self, by: ChangeHash, range: &AtomRange) {
120 139
        for i in range.from..range.to {
121 -
            self.atoms.entry((range.change, i)).or_default().deleted_by.insert(by);
140 +
            self.atoms
141 +
                .entry((range.change, i))
142 +
                .or_default()
143 +
                .deleted_by
144 +
                .insert(by);
122 145
        }
123 146
    }
124 147
125 148
    pub fn is_live(&self, k: &AtomKey) -> bool {
126 -
        self.atoms.get(k).is_some_and(|a| a.content.is_some() && a.deleted_by.is_empty())
149 +
        self.atoms
150 +
            .get(k)
151 +
            .is_some_and(|a| a.content.is_some() && a.deleted_by.is_empty())
127 152
    }
128 153
129 154
    pub fn content(&self, k: &AtomKey) -> Option<&[u8]> {
…
1 unchanged lines
131 156
    }
132 157
133 158
    pub fn deleters(&self, k: &AtomKey) -> impl Iterator<Item = &ChangeHash> {
134 -
        self.atoms.get(k).into_iter().flat_map(|a| a.deleted_by.iter())
159 +
        self.atoms
160 +
            .get(k)
161 +
            .into_iter()
162 +
            .flat_map(|a| a.deleted_by.iter())
135 163
    }
136 164
137 165
    /// Number of atoms introduced by `change` in this graph (for index bookkeeping).
…
8 unchanged lines
146 174
        // deletion, or the deleter saw the insertion, there is no conflict.
147 175
        self.succ.get(&Vertex::Atom(*k)).is_some_and(|ss| {
148 176
            ss.iter().any(|s| match s {
149 -
                Vertex::Atom(y) if self.is_live(y) => !info.deleted_by.iter().any(|d| deps.depends_on(d, &y.0) || deps.depends_on(&y.0, d)),
177 +
                Vertex::Atom(y) if self.is_live(y) => !info
178 +
                    .deleted_by
179 +
                    .iter()
180 +
                    .any(|d| deps.depends_on(d, &y.0) || deps.depends_on(&y.0, d)),
150 181
                _ => false,
151 182
            })
152 183
        })
153 184
    }
154 185
155 186
    /// Successors of `v` in the graph with non-kept vertices contracted away.
156 -
    fn reduced_succ(&self, v: Vertex, kept: &HashSet<Vertex>, memo: &mut HashMap<Vertex, BTreeSet<Vertex>>) -> BTreeSet<Vertex> {
187 +
    fn reduced_succ(
188 +
        &self,
189 +
        v: Vertex,
190 +
        kept: &HashSet<Vertex>,
191 +
        memo: &mut HashMap<Vertex, BTreeSet<Vertex>>,
192 +
    ) -> BTreeSet<Vertex> {
157 193
        let mut out = BTreeSet::new();
158 -
        let mut stack: Vec<Vertex> = self.succ.get(&v).map(|s| s.iter().copied().collect()).unwrap_or_default();
194 +
        let mut stack: Vec<Vertex> = self
195 +
            .succ
196 +
            .get(&v)
197 +
            .map(|s| s.iter().copied().collect())
198 +
            .unwrap_or_default();
159 199
        let mut seen = HashSet::new();
160 200
        while let Some(s) = stack.pop() {
161 201
            if kept.contains(&s) {
…
42 unchanged lines
204 244
        }
205 245
206 246
        // 3. Kahn's algorithm with side labelling for conflict regions.
207 -
        let mut ready: BTreeSet<Vertex> = indeg.iter().filter(|(_, &d)| d == 0).map(|(v, _)| *v).collect();
247 +
        let mut ready: BTreeSet<Vertex> = indeg
248 +
            .iter()
249 +
            .filter(|(_, &d)| d == 0)
250 +
            .map(|(v, _)| *v)
251 +
            .collect();
208 252
        let mut side: HashMap<Vertex, u32> = HashMap::new();
209 253
        let mut joins: HashSet<Vertex> = HashSet::new();
210 254
        let mut next_side = 0u32;
…
2 unchanged lines
213 257
        let mut segments: Vec<Segment> = Vec::new();
214 258
215 259
        let flush_plain = |plain: &mut Vec<Vertex>, segments: &mut Vec<Segment>| {
216 -
            let atoms: Vec<AtomKey> = plain.iter().filter_map(|v| if let Vertex::Atom(k) = v { Some(*k) } else { None }).collect();
260 +
            let atoms: Vec<AtomKey> = plain
261 +
                .iter()
262 +
                .filter_map(|v| {
263 +
                    if let Vertex::Atom(k) = v {
264 +
                        Some(*k)
265 +
                    } else {
266 +
                        None
267 +
                    }
268 +
                })
269 +
                .collect();
217 270
            plain.clear();
218 271
            if !atoms.is_empty() {
219 272
                segments.extend(split_zombies(atoms, &ghosts));
…
108 unchanged lines
328 381
                        push_atoms(&mut out, s);
329 382
                    }
330 383
                    newline_if_needed(&mut out);
331 -
                    out.extend_from_slice(format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes());
384 +
                    out.extend_from_slice(
385 +
                        format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes(),
386 +
                    );
332 387
                }
333 388
                Segment::Zombie { deleted, inserted } => {
334 389
                    n += 1;
…
9 unchanged lines
344 399
                    );
345 400
                    push_atoms(&mut out, deleted);
346 401
                    newline_if_needed(&mut out);
347 -
                    out.extend_from_slice(format!("======= oxy conflict {n} of {total}, side 2 of 2: change {inserter}\n").as_bytes());
402 +
                    out.extend_from_slice(
403 +
                        format!(
404 +
                            "======= oxy conflict {n} of {total}, side 2 of 2: change {inserter}\n"
405 +
                        )
406 +
                        .as_bytes(),
407 +
                    );
348 408
                    push_atoms(&mut out, inserted);
349 409
                    newline_if_needed(&mut out);
350 -
                    out.extend_from_slice(format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes());
410 +
                    out.extend_from_slice(
411 +
                        format!(">>>>>>> oxy conflict {n} of {total} end\n").as_bytes(),
412 +
                    );
351 413
                }
352 414
            }
353 415
        }
…
6 unchanged lines
360 422
    match v {
361 423
        Vertex::Start => Value::Uint(0),
362 424
        Vertex::End => Value::Uint(1),
363 -
        Vertex::Atom((c, i)) => Value::Array(vec![Value::Bytes(c.0.to_vec()), Value::Uint(*i as u64)]),
425 +
        Vertex::Atom((c, i)) => {
426 +
            Value::Array(vec![Value::Bytes(c.0.to_vec()), Value::Uint(*i as u64)])
427 +
        }
364 428
    }
365 429
}
366 430
…
3 unchanged lines
370 434
    match v {
371 435
        Value::Uint(0) => Ok(Vertex::Start),
372 436
        Value::Uint(1) => Ok(Vertex::End),
373 -
        Value::Array(a) if a.len() == 2 => Ok(Vertex::Atom((crate::objects::oref(&a[0], "vertex")?, crate::objects::u32_(&a[1], "vertex")?))),
374 -
        _ => Err(ObjectError::Schema("vertex: expected 0, 1, or [hash, index]".into())),
437 +
        Value::Array(a) if a.len() == 2 => Ok(Vertex::Atom((
438 +
            crate::objects::oref(&a[0], "vertex")?,
439 +
            crate::objects::u32_(&a[1], "vertex")?,
440 +
        ))),
441 +
        _ => Err(ObjectError::Schema(
442 +
            "vertex: expected 0, 1, or [hash, index]".into(),
443 +
        )),
375 444
    }
376 445
}
377 446
…
3 unchanged lines
381 450
        use crate::cbor::Value;
382 451
        let mut atoms: Vec<(&AtomKey, &AtomInfo)> = self.atoms.iter().collect();
383 452
        atoms.sort_by_key(|(k, _)| **k);
384 -
        let mut edges: Vec<(Vertex, Vertex)> = self.succ.iter().flat_map(|(a, bs)| bs.iter().map(move |b| (*a, *b))).collect();
453 +
        let mut edges: Vec<(Vertex, Vertex)> = self
454 +
            .succ
455 +
            .iter()
456 +
            .flat_map(|(a, bs)| bs.iter().map(move |b| (*a, *b)))
457 +
            .collect();
385 458
        edges.sort();
386 459
        Value::map([
387 460
            (
…
5 unchanged lines
393 466
                            Value::Array(vec![
394 467
                                Value::Bytes(c.0.to_vec()),
395 468
                                Value::Uint(*i as u64),
396 -
                                info.content.as_ref().map(|b| Value::Bytes(b.clone())).unwrap_or(Value::Null),
397 -
                                Value::Array(info.deleted_by.iter().map(|d| Value::Bytes(d.0.to_vec())).collect()),
469 +
                                info.content
470 +
                                    .as_ref()
471 +
                                    .map(|b| Value::Bytes(b.clone()))
472 +
                                    .unwrap_or(Value::Null),
473 +
                                Value::Array(
474 +
                                    info.deleted_by
475 +
                                        .iter()
476 +
                                        .map(|d| Value::Bytes(d.0.to_vec()))
477 +
                                        .collect(),
478 +
                                ),
398 479
                            ])
399 480
                        })
400 481
                        .collect(),
401 482
                ),
402 483
            ),
403 -
            (1, Value::Array(edges.iter().map(|(a, b)| Value::Array(vec![vertex_value(a), vertex_value(b)])).collect())),
484 +
            (
485 +
                1,
486 +
                Value::Array(
487 +
                    edges
488 +
                        .iter()
489 +
                        .map(|(a, b)| Value::Array(vec![vertex_value(a), vertex_value(b)]))
490 +
                        .collect(),
491 +
                ),
492 +
            ),
404 493
        ])
405 494
    }
406 495
407 496
    pub fn from_value(v: &crate::cbor::Value) -> Result<FileGraph, crate::objects::ObjectError> {
408 497
        use crate::objects::{array, bytes, field, map, oref, u32_, ObjectError};
409 498
        let m = map(v, "file graph")?;
410 -
        let mut g = FileGraph { atoms: HashMap::new(), succ: HashMap::new(), pred: HashMap::new() };
499 +
        let mut g = FileGraph {
500 +
            atoms: HashMap::new(),
501 +
            succ: HashMap::new(),
502 +
            pred: HashMap::new(),
503 +
        };
411 504
        for a in array(field(m, 0, "atoms")?, "atoms")? {
412 505
            let e = array(a, "atom")?;
413 506
            if e.len() != 4 {
414 507
                return Err(ObjectError::Schema("atom: expected 4 fields".into()));
415 508
            }
416 509
            let key = (oref(&e[0], "atom change")?, u32_(&e[1], "atom index")?);
417 -
            let content = if e[2].is_null() { None } else { Some(bytes(&e[2], "atom content")?.to_vec()) };
418 -
            let deleted_by = array(&e[3], "deleters")?.iter().map(|d| oref(d, "deleter")).collect::<Result<BTreeSet<_>, _>>()?;
419 -
            g.atoms.insert(key, AtomInfo { content, deleted_by });
510 +
            let content = if e[2].is_null() {
511 +
                None
512 +
            } else {
513 +
                Some(bytes(&e[2], "atom content")?.to_vec())
514 +
            };
515 +
            let deleted_by = array(&e[3], "deleters")?
516 +
                .iter()
517 +
                .map(|d| oref(d, "deleter"))
518 +
                .collect::<Result<BTreeSet<_>, _>>()?;
519 +
            g.atoms.insert(
520 +
                key,
521 +
                AtomInfo {
522 +
                    content,
523 +
                    deleted_by,
524 +
                },
525 +
            );
420 526
        }
421 527
        for e in array(field(m, 1, "edges")?, "edges")? {
422 528
            let pair = array(e, "edge")?;
…
83 unchanged lines
506 612
    #[test]
507 613
    fn sequential_inserts_and_deletes() {
508 614
        let mut g = FileGraph::new();
509 -
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n", "c\n"]));
615 +
        g.insert(
616 +
            h(1),
617 +
            0,
618 +
            Vertex::Start,
619 +
            Vertex::End,
620 +
            &lines(&["a\n", "b\n", "c\n"]),
621 +
        );
510 622
        let m = g.materialize(&NoDeps);
511 623
        assert!(!m.is_conflicted());
512 624
        assert_eq!(text(&g, &m), "a\nb\nc\n");
513 625
514 626
        // Change 2 depends on 1, inserts between b and c and deletes a.
515 -
        g.insert(h(2), 0, Vertex::Atom((h(1), 1)), Vertex::Atom((h(1), 2)), &lines(&["x\n"]));
516 -
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
627 +
        g.insert(
628 +
            h(2),
629 +
            0,
630 +
            Vertex::Atom((h(1), 1)),
631 +
            Vertex::Atom((h(1), 2)),
632 +
            &lines(&["x\n"]),
633 +
        );
634 +
        g.delete(
635 +
            h(2),
636 +
            &AtomRange {
637 +
                change: h(1),
638 +
                from: 0,
639 +
                to: 1,
640 +
            },
641 +
        );
517 642
        let deps = Deps([(h(2), vec![h(1)])].into_iter().collect());
518 643
        let m = g.materialize(&deps);
519 644
        assert!(!m.is_conflicted());
…
5 unchanged lines
525 650
    fn concurrent_inserts_at_same_anchor_conflict() {
526 651
        let mut g = FileGraph::new();
527 652
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
528 -
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["from-2\n"]));
529 -
        g.insert(h(3), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["from-3\n", "more-3\n"]));
530 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
653 +
        g.insert(
654 +
            h(2),
655 +
            0,
656 +
            Vertex::Atom((h(1), 0)),
657 +
            Vertex::Atom((h(1), 1)),
658 +
            &lines(&["from-2\n"]),
659 +
        );
660 +
        g.insert(
661 +
            h(3),
662 +
            0,
663 +
            Vertex::Atom((h(1), 0)),
664 +
            Vertex::Atom((h(1), 1)),
665 +
            &lines(&["from-3\n", "more-3\n"]),
666 +
        );
667 +
        let deps = Deps(
668 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
669 +
                .into_iter()
670 +
                .collect(),
671 +
        );
531 672
        let m = g.materialize(&deps);
532 673
        assert!(m.is_conflicted());
533 674
        assert_eq!(m.conflict_count(), 1);
…
12 unchanged lines
546 687
    #[test]
547 688
    fn concurrent_inserts_at_different_anchors_do_not_conflict() {
548 689
        let mut g = FileGraph::new();
549 -
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n", "c\n"]));
550 -
        g.insert(h(2), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["top\n"]));
551 -
        g.insert(h(3), 0, Vertex::Atom((h(1), 2)), Vertex::End, &lines(&["bottom\n"]));
552 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
690 +
        g.insert(
691 +
            h(1),
692 +
            0,
693 +
            Vertex::Start,
694 +
            Vertex::End,
695 +
            &lines(&["a\n", "b\n", "c\n"]),
696 +
        );
697 +
        g.insert(
698 +
            h(2),
699 +
            0,
700 +
            Vertex::Start,
701 +
            Vertex::Atom((h(1), 0)),
702 +
            &lines(&["top\n"]),
703 +
        );
704 +
        g.insert(
705 +
            h(3),
706 +
            0,
707 +
            Vertex::Atom((h(1), 2)),
708 +
            Vertex::End,
709 +
            &lines(&["bottom\n"]),
710 +
        );
711 +
        let deps = Deps(
712 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
713 +
                .into_iter()
714 +
                .collect(),
715 +
        );
553 716
        let m = g.materialize(&deps);
554 717
        assert!(!m.is_conflicted());
555 718
        assert_eq!(text(&g, &m), "top\na\nb\nc\nbottom\n");
…
3 unchanged lines
559 722
    fn concurrent_deletes_of_the_same_line_agree() {
560 723
        let mut g = FileGraph::new();
561 724
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
562 -
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
563 -
        g.delete(h(3), &AtomRange { change: h(1), from: 0, to: 1 });
564 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
725 +
        g.delete(
726 +
            h(2),
727 +
            &AtomRange {
728 +
                change: h(1),
729 +
                from: 0,
730 +
                to: 1,
731 +
            },
732 +
        );
733 +
        g.delete(
734 +
            h(3),
735 +
            &AtomRange {
736 +
                change: h(1),
737 +
                from: 0,
738 +
                to: 1,
739 +
            },
740 +
        );
741 +
        let deps = Deps(
742 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
743 +
                .into_iter()
744 +
                .collect(),
745 +
        );
565 746
        let m = g.materialize(&deps);
566 747
        assert!(!m.is_conflicted());
567 748
        assert_eq!(text(&g, &m), "b\n");
…
4 unchanged lines
572 753
        let mut g = FileGraph::new();
573 754
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
574 755
        // 2 deletes a; 3 (concurrent) inserts after a.
575 -
        g.delete(h(2), &AtomRange { change: h(1), from: 0, to: 1 });
576 -
        g.insert(h(3), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["after-a\n"]));
577 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
756 +
        g.delete(
757 +
            h(2),
758 +
            &AtomRange {
759 +
                change: h(1),
760 +
                from: 0,
761 +
                to: 1,
762 +
            },
763 +
        );
764 +
        g.insert(
765 +
            h(3),
766 +
            0,
767 +
            Vertex::Atom((h(1), 0)),
768 +
            Vertex::Atom((h(1), 1)),
769 +
            &lines(&["after-a\n"]),
770 +
        );
771 +
        let deps = Deps(
772 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
773 +
                .into_iter()
774 +
                .collect(),
775 +
        );
578 776
        let m = g.materialize(&deps);
579 777
        assert!(m.is_conflicted());
580 778
        assert_eq!(
581 779
            m.segments,
582 780
            vec![
583 -
                Segment::Zombie { deleted: vec![(h(1), 0)], inserted: vec![(h(3), 0)] },
781 +
                Segment::Zombie {
782 +
                    deleted: vec![(h(1), 0)],
783 +
                    inserted: vec![(h(3), 0)]
784 +
                },
584 785
                Segment::Lines(vec![(h(1), 1)]),
585 786
            ]
586 787
        );
…
2 unchanged lines
589 790
        assert!(t.contains("after-a\n>>>>>>>"), "{t}");
590 791
591 792
        // If the deleter saw the insert (depends on 3), there is no conflict.
592 -
        let deps = Deps([(h(2), vec![h(1), h(3)]), (h(3), vec![h(1)])].into_iter().collect());
793 +
        let deps = Deps(
794 +
            [(h(2), vec![h(1), h(3)]), (h(3), vec![h(1)])]
795 +
                .into_iter()
796 +
                .collect(),
797 +
        );
593 798
        let m = g.materialize(&deps);
594 799
        assert!(!m.is_conflicted());
595 800
        assert_eq!(text(&g, &m), "after-a\nb\n");
…
5 unchanged lines
601 806
        // after the dead b (a rewritten change may do this): no conflict.
602 807
        let mut g = FileGraph::new();
603 808
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
604 -
        g.delete(h(2), &AtomRange { change: h(1), from: 1, to: 2 });
605 -
        g.insert(h(3), 0, Vertex::Atom((h(1), 1)), Vertex::End, &lines(&["c\n"]));
606 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(2)])].into_iter().collect());
809 +
        g.delete(
810 +
            h(2),
811 +
            &AtomRange {
812 +
                change: h(1),
813 +
                from: 1,
814 +
                to: 2,
815 +
            },
816 +
        );
817 +
        g.insert(
818 +
            h(3),
819 +
            0,
820 +
            Vertex::Atom((h(1), 1)),
821 +
            Vertex::End,
822 +
            &lines(&["c\n"]),
823 +
        );
824 +
        let deps = Deps(
825 +
            [(h(2), vec![h(1)]), (h(3), vec![h(2)])]
826 +
                .into_iter()
827 +
                .collect(),
828 +
        );
607 829
        let m = g.materialize(&deps);
608 830
        assert!(!m.is_conflicted(), "{:?}", m.segments);
609 831
        assert_eq!(text(&g, &m), "a\nc\n");
…
1 unchanged lines
611 833
        // anchoring after it, is likewise fine.
612 834
        let mut g = FileGraph::new();
613 835
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["x\n"]));
614 -
        g.delete(h(1), &AtomRange { change: h(1), from: 0, to: 1 });
615 -
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::End, &lines(&["y\n"]));
836 +
        g.delete(
837 +
            h(1),
838 +
            &AtomRange {
839 +
                change: h(1),
840 +
                from: 0,
841 +
                to: 1,
842 +
            },
843 +
        );
844 +
        g.insert(
845 +
            h(2),
846 +
            0,
847 +
            Vertex::Atom((h(1), 0)),
848 +
            Vertex::End,
849 +
            &lines(&["y\n"]),
850 +
        );
616 851
        let deps = Deps([(h(2), vec![h(1)])].into_iter().collect());
617 852
        let m = g.materialize(&deps);
618 853
        assert!(!m.is_conflicted());
…
4 unchanged lines
623 858
    fn resolution_collapses_a_conflict() {
624 859
        let mut g = FileGraph::new();
625 860
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n"]));
626 -
        g.insert(h(2), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["x\n"]));
627 -
        g.insert(h(3), 0, Vertex::Start, Vertex::Atom((h(1), 0)), &lines(&["y\n"]));
628 -
        let mut deps: HashMap<_, _> = [(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect();
861 +
        g.insert(
862 +
            h(2),
863 +
            0,
864 +
            Vertex::Start,
865 +
            Vertex::Atom((h(1), 0)),
866 +
            &lines(&["x\n"]),
867 +
        );
868 +
        g.insert(
869 +
            h(3),
870 +
            0,
871 +
            Vertex::Start,
872 +
            Vertex::Atom((h(1), 0)),
873 +
            &lines(&["y\n"]),
874 +
        );
875 +
        let mut deps: HashMap<_, _> = [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
876 +
            .into_iter()
877 +
            .collect();
629 878
        assert!(g.materialize(&Deps(deps.clone())).is_conflicted());
630 879
        // Resolution keeps y, drops x.
631 -
        g.delete(h(4), &AtomRange { change: h(2), from: 0, to: 1 });
880 +
        g.delete(
881 +
            h(4),
882 +
            &AtomRange {
883 +
                change: h(2),
884 +
                from: 0,
885 +
                to: 1,
886 +
            },
887 +
        );
632 888
        deps.insert(h(4), vec![h(2), h(3)]);
633 889
        let m = g.materialize(&Deps(deps));
634 890
        assert!(!m.is_conflicted());
…
15 unchanged lines
650 906
    fn serialization_round_trip() {
651 907
        let mut g = FileGraph::new();
652 908
        g.insert(h(1), 0, Vertex::Start, Vertex::End, &lines(&["a\n", "b\n"]));
653 -
        g.insert(h(2), 0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), &lines(&["x\n"]));
654 -
        g.delete(h(3), &AtomRange { change: h(1), from: 1, to: 2 });
909 +
        g.insert(
910 +
            h(2),
911 +
            0,
912 +
            Vertex::Atom((h(1), 0)),
913 +
            Vertex::Atom((h(1), 1)),
914 +
            &lines(&["x\n"]),
915 +
        );
916 +
        g.delete(
917 +
            h(3),
918 +
            &AtomRange {
919 +
                change: h(1),
920 +
                from: 1,
921 +
                to: 2,
922 +
            },
923 +
        );
655 924
        let v = g.to_value();
656 925
        let bytes = crate::cbor::encode(&v);
657 926
        let back = FileGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap();
658 -
        let deps = Deps([(h(2), vec![h(1)]), (h(3), vec![h(1)])].into_iter().collect());
927 +
        let deps = Deps(
928 +
            [(h(2), vec![h(1)]), (h(3), vec![h(1)])]
929 +
                .into_iter()
930 +
                .collect(),
931 +
        );
659 932
        assert_eq!(back.materialize(&deps), g.materialize(&deps));
660 933
        assert_eq!(text(&back, &back.materialize(&deps)), "a\nx\n");
661 -
        assert_eq!(crate::cbor::encode(&back.to_value()), bytes, "deterministic");
934 +
        assert_eq!(
935 +
            crate::cbor::encode(&back.to_value()),
936 +
            bytes,
937 +
            "deterministic"
938 +
        );
662 939
    }
663 940
664 941
    /// Spec §7.3: the graph is a function of the set of changes, not their order.
665 942
    #[test]
666 943
    fn application_order_is_irrelevant() {
667 944
        use rand::seq::SliceRandom;
668 -
        type Op = (ChangeHash, Vec<(u32, Vertex, Vertex, Vec<Vec<u8>>)>, Vec<AtomRange>);
945 +
        type Op = (
946 +
            ChangeHash,
947 +
            Vec<(u32, Vertex, Vertex, Vec<Vec<u8>>)>,
948 +
            Vec<AtomRange>,
949 +
        );
669 950
        let ops: Vec<Op> = vec![
670 -
            (h(1), vec![(0, Vertex::Start, Vertex::End, lines(&["a\n", "b\n", "c\n"]))], vec![]),
671 -
            (h(2), vec![(0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), lines(&["p\n"]))], vec![]),
672 -
            (h(3), vec![(0, Vertex::Atom((h(1), 0)), Vertex::Atom((h(1), 1)), lines(&["q\n", "r\n"]))], vec![AtomRange { change: h(1), from: 2, to: 3 }]),
673 -
            (h(4), vec![(0, Vertex::Atom((h(3), 1)), Vertex::Atom((h(1), 1)), lines(&["s\n"]))], vec![AtomRange { change: h(1), from: 1, to: 2 }]),
674 -
            (h(5), vec![(0, Vertex::Atom((h(1), 2)), Vertex::End, lines(&["z\n"]))], vec![]),
951 +
            (
952 +
                h(1),
953 +
                vec![(0, Vertex::Start, Vertex::End, lines(&["a\n", "b\n", "c\n"]))],
954 +
                vec![],
955 +
            ),
956 +
            (
957 +
                h(2),
958 +
                vec![(
959 +
                    0,
960 +
                    Vertex::Atom((h(1), 0)),
961 +
                    Vertex::Atom((h(1), 1)),
962 +
                    lines(&["p\n"]),
963 +
                )],
964 +
                vec![],
965 +
            ),
966 +
            (
967 +
                h(3),
968 +
                vec![(
969 +
                    0,
970 +
                    Vertex::Atom((h(1), 0)),
971 +
                    Vertex::Atom((h(1), 1)),
972 +
                    lines(&["q\n", "r\n"]),
973 +
                )],
974 +
                vec![AtomRange {
975 +
                    change: h(1),
976 +
                    from: 2,
977 +
                    to: 3,
978 +
                }],
979 +
            ),
980 +
            (
981 +
                h(4),
982 +
                vec![(
983 +
                    0,
984 +
                    Vertex::Atom((h(3), 1)),
985 +
                    Vertex::Atom((h(1), 1)),
986 +
                    lines(&["s\n"]),
987 +
                )],
988 +
                vec![AtomRange {
989 +
                    change: h(1),
990 +
                    from: 1,
991 +
                    to: 2,
992 +
                }],
993 +
            ),
994 +
            (
995 +
                h(5),
996 +
                vec![(0, Vertex::Atom((h(1), 2)), Vertex::End, lines(&["z\n"]))],
997 +
                vec![],
998 +
            ),
675 999
        ];
676 1000
        let deps = Deps(
677 -
            [(h(2), vec![h(1)]), (h(3), vec![h(1)]), (h(4), vec![h(3), h(1)]), (h(5), vec![h(1)])].into_iter().collect(),
1001 +
            [
1002 +
                (h(2), vec![h(1)]),
1003 +
                (h(3), vec![h(1)]),
1004 +
                (h(4), vec![h(3), h(1)]),
1005 +
                (h(5), vec![h(1)]),
1006 +
            ]
1007 +
            .into_iter()
1008 +
            .collect(),
678 1009
        );
679 1010
        let build = |order: &[usize]| {
680 1011
            let mut g = FileGraph::new();
…
22 unchanged lines
modified crates/oxy-core/src/identity.rs +153 −34
…
5 unchanged lines
6 6
7 7
use crate::cbor::{self, Value};
8 8
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
9 -
use crate::objects::{array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION};
9 +
use crate::objects::{
10 +
    array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION,
11 +
};
10 12
use ed25519_dalek::ed25519::signature::Signer as _;
11 -
use ed25519_dalek::{Signature as EdSignature, SigningKey as EdSigningKey, VerifyingKey as EdVerifyingKey};
12 -
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, Seed, Signature as MlSignature, SigningKey as MlSigningKey, VerifyingKey as MlVerifyingKey};
13 +
use ed25519_dalek::{
14 +
    Signature as EdSignature, SigningKey as EdSigningKey, VerifyingKey as EdVerifyingKey,
15 +
};
16 +
use ml_dsa::{
17 +
    EncodedSignature, EncodedVerifyingKey, MlDsa65, Seed, Signature as MlSignature,
18 +
    SigningKey as MlSigningKey, VerifyingKey as MlVerifyingKey,
19 +
};
13 20
use rand::RngCore;
14 21
15 22
pub const ED25519_PK_LEN: usize = 32;
…
25 unchanged lines
41 48
            (4, Value::Text(self.email.clone())),
42 49
            (5, Value::Uint(self.created)),
43 50
            (6, self.expires.map(Value::Uint).unwrap_or(Value::Null)),
44 -
            (7, self.previous.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)),
45 -
            (8, Value::Array(self.delegates.iter().map(|d| Value::Bytes(d.0.to_vec())).collect())),
51 +
            (
52 +
                7,
53 +
                self.previous
54 +
                    .map(|p| Value::Bytes(p.0.to_vec()))
55 +
                    .unwrap_or(Value::Null),
56 +
            ),
57 +
            (
58 +
                8,
59 +
                Value::Array(
60 +
                    self.delegates
61 +
                        .iter()
62 +
                        .map(|d| Value::Bytes(d.0.to_vec()))
63 +
                        .collect(),
64 +
                ),
65 +
            ),
46 66
        ])
47 67
    }
48 68
    pub fn encode(&self) -> Vec<u8> {
…
4 unchanged lines
53 73
    }
54 74
    pub fn decode(b: &[u8]) -> Result<Identity, ObjectError> {
55 75
        let v = cbor::decode(b)?;
56 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("identity: expected map".into()))?;
76 +
        let m = v
77 +
            .as_map()
78 +
            .ok_or_else(|| ObjectError::Schema("identity: expected map".into()))?;
57 79
        check_version(m)?;
58 80
        let ed = bytes(field(m, 1, "ed25519_pk")?, "ed25519_pk")?;
59 81
        let ml = bytes(field(m, 2, "mldsa65_pk")?, "mldsa65_pk")?;
60 82
        if ed.len() != ED25519_PK_LEN || ml.len() != MLDSA65_PK_LEN {
61 -
            return Err(ObjectError::Schema("identity: wrong public key length".into()));
83 +
            return Err(ObjectError::Schema(
84 +
                "identity: wrong public key length".into(),
85 +
            ));
62 86
        }
63 -
        let opt_u = |v: &Value| -> Result<Option<u64>, ObjectError> { if v.is_null() { Ok(None) } else { uint(v, "expires").map(Some) } };
64 -
        let opt_r = |v: &Value| -> Result<Option<KeyId>, ObjectError> { if v.is_null() { Ok(None) } else { oref(v, "previous").map(Some) } };
65 -
        let mut delegates = array(field(m, 8, "delegates")?, "delegates")?.iter().map(|d| oref(d, "delegate")).collect::<Result<Vec<_>, _>>()?;
87 +
        let opt_u = |v: &Value| -> Result<Option<u64>, ObjectError> {
88 +
            if v.is_null() {
89 +
                Ok(None)
90 +
            } else {
91 +
                uint(v, "expires").map(Some)
92 +
            }
93 +
        };
94 +
        let opt_r = |v: &Value| -> Result<Option<KeyId>, ObjectError> {
95 +
            if v.is_null() {
96 +
                Ok(None)
97 +
            } else {
98 +
                oref(v, "previous").map(Some)
99 +
            }
100 +
        };
101 +
        let mut delegates = array(field(m, 8, "delegates")?, "delegates")?
102 +
            .iter()
103 +
            .map(|d| oref(d, "delegate"))
104 +
            .collect::<Result<Vec<_>, _>>()?;
66 105
        delegates.sort();
67 106
        delegates.dedup();
68 107
        Ok(Identity {
…
19 unchanged lines
88 127
        let mut delegates = delegates;
89 128
        delegates.sort();
90 129
        delegates.dedup();
91 -
        Identity { previous: Some(self.hash()), delegates, expires, created, ..self.clone() }
130 +
        Identity {
131 +
            previous: Some(self.hash()),
132 +
            delegates,
133 +
            expires,
134 +
            created,
135 +
            ..self.clone()
136 +
        }
92 137
    }
93 138
94 139
    fn ed_key(&self) -> Result<EdVerifyingKey, VerifyError> {
95 140
        EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| VerifyError::BadKey)
96 141
    }
97 142
    fn ml_key(&self) -> Result<MlVerifyingKey<MlDsa65>, VerifyError> {
98 -
        let enc = EncodedVerifyingKey::<MlDsa65>::try_from(self.mldsa65_pk.as_slice()).map_err(|_| VerifyError::BadKey)?;
143 +
        let enc = EncodedVerifyingKey::<MlDsa65>::try_from(self.mldsa65_pk.as_slice())
144 +
            .map_err(|_| VerifyError::BadKey)?;
99 145
        Ok(MlVerifyingKey::decode(&enc))
100 146
    }
101 147
}
…
49 unchanged lines
151 197
        ])
152 198
    }
153 199
    pub fn from_value(v: &Value) -> Result<Signature, ObjectError> {
154 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("signature: expected map".into()))?;
200 +
        let m = v
201 +
            .as_map()
202 +
            .ok_or_else(|| ObjectError::Schema("signature: expected map".into()))?;
155 203
        let alg = uint(field(m, 1, "alg")?, "alg")?;
156 204
        if alg != ALG_COMPOSITE {
157 -
            return Err(ObjectError::Schema(format!("unknown signature algorithm {alg}")));
205 +
            return Err(ObjectError::Schema(format!(
206 +
                "unknown signature algorithm {alg}"
207 +
            )));
158 208
        }
159 209
        let ed = bytes(field(m, 2, "ed")?, "ed")?;
160 210
        let ml = bytes(field(m, 3, "ml")?, "ml")?;
161 211
        if ed.len() != ED25519_SIG_LEN || ml.len() != MLDSA65_SIG_LEN {
162 212
            return Err(ObjectError::Schema("signature: wrong length".into()));
163 213
        }
164 -
        Ok(Signature { key: oref(field(m, 0, "key")?, "key")?, ed: ed.try_into().unwrap(), ml: ml.to_vec() })
214 +
        Ok(Signature {
215 +
            key: oref(field(m, 0, "key")?, "key")?,
216 +
            ed: ed.try_into().unwrap(),
217 +
            ml: ml.to_vec(),
218 +
        })
165 219
    }
166 220
    pub fn encode_list(sigs: &[Signature]) -> Vec<u8> {
167 221
        cbor::encode(&Value::Array(sigs.iter().map(|s| s.to_value()).collect()))
168 222
    }
169 223
    pub fn decode_list(b: &[u8]) -> Result<Vec<Signature>, ObjectError> {
170 224
        let v = cbor::decode(b)?;
171 -
        array(&v, "signatures")?.iter().map(Signature::from_value).collect()
225 +
        array(&v, "signatures")?
226 +
            .iter()
227 +
            .map(Signature::from_value)
228 +
            .collect()
172 229
    }
173 230
}
174 231
…
10 unchanged lines
185 242
}
186 243
187 244
/// Verify a composite signature by `identity` over `target` (spec §14.3).
188 -
pub fn verify(identity: &Identity, sig: &Signature, domain: Domain, target: &ObjectRef) -> Result<(), VerifyError> {
245 +
pub fn verify(
246 +
    identity: &Identity,
247 +
    sig: &Signature,
248 +
    domain: Domain,
249 +
    target: &ObjectRef,
250 +
) -> Result<(), VerifyError> {
189 251
    let key = identity.hash();
190 252
    if sig.key != key {
191 253
        return Err(VerifyError::KeyMismatch);
192 254
    }
193 255
    let msg = signing_message(domain, &key, target);
194 256
    let ed_sig = EdSignature::from_bytes(&sig.ed);
195 -
    identity.ed_key()?.verify_strict(&msg, &ed_sig).map_err(|_| VerifyError::Ed25519)?;
196 -
    let enc = EncodedSignature::<MlDsa65>::try_from(sig.ml.as_slice()).map_err(|_| VerifyError::MlDsa)?;
257 +
    identity
258 +
        .ed_key()?
259 +
        .verify_strict(&msg, &ed_sig)
260 +
        .map_err(|_| VerifyError::Ed25519)?;
261 +
    let enc =
262 +
        EncodedSignature::<MlDsa65>::try_from(sig.ml.as_slice()).map_err(|_| VerifyError::MlDsa)?;
197 263
    let ml_sig = MlSignature::<MlDsa65>::decode(&enc).ok_or(VerifyError::MlDsa)?;
198 264
    if !identity.ml_key()?.verify_with_context(&msg, b"", &ml_sig) {
199 265
        return Err(VerifyError::MlDsa);
…
34 unchanged lines
234 300
    pub fn identity(&self, name: &str, email: &str, created: u64) -> Identity {
235 301
        Identity {
236 302
            ed25519_pk: self.ed().verifying_key().to_bytes(),
237 -
            mldsa65_pk: self.ml().expanded_key().verifying_key().encode().as_slice().to_vec(),
303 +
            mldsa65_pk: self
304 +
                .ml()
305 +
                .expanded_key()
306 +
                .verifying_key()
307 +
                .encode()
308 +
                .as_slice()
309 +
                .to_vec(),
238 310
            name: name.into(),
239 311
            email: email.into(),
240 312
            created,
…
6 unchanged lines
247 319
    pub fn sign(&self, domain: Domain, key: &KeyId, target: &ObjectRef) -> Signature {
248 320
        let msg = signing_message(domain, key, target);
249 321
        let ed = self.ed().sign(&msg).to_bytes();
250 -
        let ml = self.ml().expanded_key().sign_deterministic(&msg, b"").expect("empty context is valid").encode().as_slice().to_vec();
322 +
        let ml = self
323 +
            .ml()
324 +
            .expanded_key()
325 +
            .sign_deterministic(&msg, b"")
326 +
            .expect("empty context is valid")
327 +
            .encode()
328 +
            .as_slice()
329 +
            .to_vec();
251 330
        Signature { key: *key, ed, ml }
252 331
    }
253 332
…
8 unchanged lines
262 341
263 342
    pub fn decode(b: &[u8]) -> Result<(KeyId, SecretKey), ObjectError> {
264 343
        let v = cbor::decode(b)?;
265 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("secret key: expected map".into()))?;
344 +
        let m = v
345 +
            .as_map()
346 +
            .ok_or_else(|| ObjectError::Schema("secret key: expected map".into()))?;
266 347
        check_version(m)?;
267 348
        let seed = |k: u64| -> Result<[u8; 32], ObjectError> {
268 -
            bytes(field(m, k, "seed")?, "seed")?.try_into().map_err(|_| ObjectError::Schema("seed: expected 32 bytes".into()))
349 +
            bytes(field(m, k, "seed")?, "seed")?
350 +
                .try_into()
351 +
                .map_err(|_| ObjectError::Schema("seed: expected 32 bytes".into()))
269 352
        };
270 -
        Ok((oref(field(m, 1, "key")?, "key")?, SecretKey { ed_seed: seed(2)?, ml_seed: seed(3)? }))
353 +
        Ok((
354 +
            oref(field(m, 1, "key")?, "key")?,
355 +
            SecretKey {
356 +
                ed_seed: seed(2)?,
357 +
                ml_seed: seed(3)?,
358 +
            },
359 +
        ))
271 360
    }
272 361
}
273 362
…
2 unchanged lines
276 365
    use super::*;
277 366
278 367
    fn fixed_key() -> SecretKey {
279 -
        SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] }
368 +
        SecretKey {
369 +
            ed_seed: [1; 32],
370 +
            ml_seed: [2; 32],
371 +
        }
280 372
    }
281 373
282 374
    #[test]
…
5 unchanged lines
288 380
        assert_eq!(d, id);
289 381
        assert_eq!(d.hash(), id.hash());
290 382
        // Deterministic from seeds.
291 -
        assert_eq!(fixed_key().identity("Ada", "[email protected]", 1_700_000_000_000).hash(), id.hash());
383 +
        assert_eq!(
384 +
            fixed_key()
385 +
                .identity("Ada", "[email protected]", 1_700_000_000_000)
386 +
                .hash(),
387 +
            id.hash()
388 +
        );
292 389
        let mut other = id.clone();
293 390
        other.delegates.push(ObjectRef([9; 32]));
294 391
        assert_ne!(other.hash(), id.hash());
…
9 unchanged lines
304 401
        assert_eq!(sig.ml.len(), MLDSA65_SIG_LEN);
305 402
        assert_eq!(verify(&id, &sig, Domain::Change, &target), Ok(()));
306 403
        // Different target, different domain: invalid.
307 -
        assert_eq!(verify(&id, &sig, Domain::Change, &ObjectRef([8; 32])), Err(VerifyError::Ed25519));
308 -
        assert_eq!(verify(&id, &sig, Domain::Attest, &target), Err(VerifyError::Ed25519));
404 +
        assert_eq!(
405 +
            verify(&id, &sig, Domain::Change, &ObjectRef([8; 32])),
406 +
            Err(VerifyError::Ed25519)
407 +
        );
408 +
        assert_eq!(
409 +
            verify(&id, &sig, Domain::Attest, &target),
410 +
            Err(VerifyError::Ed25519)
411 +
        );
309 412
        // Deterministic signatures.
310 413
        assert_eq!(sk.sign(Domain::Change, &key, &target), sig);
311 414
        // Signature list round trip.
312 -
        let l = Signature::decode_list(&Signature::encode_list(std::slice::from_ref(&sig))).unwrap();
415 +
        let l =
416 +
            Signature::decode_list(&Signature::encode_list(std::slice::from_ref(&sig))).unwrap();
313 417
        assert_eq!(l, vec![sig]);
314 418
    }
315 419
…
7 unchanged lines
323 427
324 428
        let mut no_ml = good.clone();
325 429
        no_ml.ml = vec![0; MLDSA65_SIG_LEN];
326 -
        assert_eq!(verify(&id, &no_ml, Domain::Change, &target), Err(VerifyError::MlDsa));
430 +
        assert_eq!(
431 +
            verify(&id, &no_ml, Domain::Change, &target),
432 +
            Err(VerifyError::MlDsa)
433 +
        );
327 434
328 435
        let mut bad_ed = good.clone();
329 436
        bad_ed.ed[10] ^= 1;
330 -
        assert_eq!(verify(&id, &bad_ed, Domain::Change, &target), Err(VerifyError::Ed25519));
437 +
        assert_eq!(
438 +
            verify(&id, &bad_ed, Domain::Change, &target),
439 +
            Err(VerifyError::Ed25519)
440 +
        );
331 441
332 442
        // A signature made for another KeyId over the same target does not transfer.
333 -
        let other = SecretKey { ed_seed: [3; 32], ml_seed: [4; 32] };
443 +
        let other = SecretKey {
444 +
            ed_seed: [3; 32],
445 +
            ml_seed: [4; 32],
446 +
        };
334 447
        let other_id = other.identity("Bob", "b@b", 1);
335 448
        let theirs = other.sign(Domain::Change, &other_id.hash(), &target);
336 -
        assert_eq!(verify(&id, &theirs, Domain::Change, &target), Err(VerifyError::KeyMismatch));
449 +
        assert_eq!(
450 +
            verify(&id, &theirs, Domain::Change, &target),
451 +
            Err(VerifyError::KeyMismatch)
452 +
        );
337 453
        let mut relabelled = theirs.clone();
338 454
        relabelled.key = key;
339 -
        assert_eq!(verify(&id, &relabelled, Domain::Change, &target), Err(VerifyError::Ed25519));
455 +
        assert_eq!(
456 +
            verify(&id, &relabelled, Domain::Change, &target),
457 +
            Err(VerifyError::Ed25519)
458 +
        );
340 459
341 460
        // Wrong length is rejected at decode time.
342 461
        let mut short = good.to_value();
…
17 unchanged lines
modified crates/oxy-core/src/ids.rs +8 −2
…
49 unchanged lines
50 50
    };
51 51
}
52 52
53 -
id128!(ChangeId, "Stable identity of a change across rewrites (spec §6.2).");
53 +
id128!(
54 +
    ChangeId,
55 +
    "Stable identity of a change across rewrites (spec §6.2)."
56 +
);
54 57
id128!(NodeId, "Stable identity of a file, directory, or symlink across renames (spec §7.1). `NodeId::ZERO` is the root directory.");
55 -
id128!(WorkspaceId, "Identity of a checkout directory (spec §12.2).");
58 +
id128!(
59 +
    WorkspaceId,
60 +
    "Identity of a checkout directory (spec §12.2)."
61 +
);
56 62
57 63
impl NodeId {
58 64
    pub const ROOT: NodeId = NodeId::ZERO;
…
35 unchanged lines
modified crates/oxy-core/src/objects.rs +315 −72
…
28 unchanged lines
29 29
pub(crate) type Map = BTreeMap<u64, Value>;
30 30
31 31
pub(crate) fn field<'a>(m: &'a Map, k: u64, what: &str) -> Result<&'a Value, ObjectError> {
32 -
    m.get(&k).ok_or_else(|| ObjectError::Schema(format!("missing field {k} ({what})")))
32 +
    m.get(&k)
33 +
        .ok_or_else(|| ObjectError::Schema(format!("missing field {k} ({what})")))
33 34
}
34 35
pub(crate) fn uint(v: &Value, what: &str) -> Result<u64, ObjectError> {
35 -
    v.as_uint().ok_or_else(|| ObjectError::Schema(format!("{what}: expected unsigned integer")))
36 +
    v.as_uint()
37 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected unsigned integer")))
36 38
}
37 39
pub(crate) fn u32_(v: &Value, what: &str) -> Result<u32, ObjectError> {
38 40
    u32::try_from(uint(v, what)?).map_err(|_| ObjectError::Schema(format!("{what}: exceeds u32")))
39 41
}
40 42
pub(crate) fn text<'a>(v: &'a Value, what: &str) -> Result<&'a str, ObjectError> {
41 -
    v.as_text().ok_or_else(|| ObjectError::Schema(format!("{what}: expected text")))
43 +
    v.as_text()
44 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected text")))
42 45
}
43 46
pub(crate) fn array<'a>(v: &'a Value, what: &str) -> Result<&'a [Value], ObjectError> {
44 -
    v.as_array().ok_or_else(|| ObjectError::Schema(format!("{what}: expected array")))
47 +
    v.as_array()
48 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected array")))
45 49
}
46 50
pub(crate) fn map<'a>(v: &'a Value, what: &str) -> Result<&'a Map, ObjectError> {
47 -
    v.as_map().ok_or_else(|| ObjectError::Schema(format!("{what}: expected map")))
51 +
    v.as_map()
52 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected map")))
48 53
}
49 54
pub(crate) fn bytes<'a>(v: &'a Value, what: &str) -> Result<&'a [u8], ObjectError> {
50 -
    v.as_bytes().ok_or_else(|| ObjectError::Schema(format!("{what}: expected bytes")))
55 +
    v.as_bytes()
56 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected bytes")))
51 57
}
52 58
pub(crate) fn oref(v: &Value, what: &str) -> Result<ObjectRef, ObjectError> {
53 -
    ObjectRef::from_slice(bytes(v, what)?).ok_or_else(|| ObjectError::Schema(format!("{what}: expected 32 bytes")))
59 +
    ObjectRef::from_slice(bytes(v, what)?)
60 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected 32 bytes")))
54 61
}
55 62
pub(crate) fn node_id(v: &Value, what: &str) -> Result<NodeId, ObjectError> {
56 -
    NodeId::from_slice(bytes(v, what)?).ok_or_else(|| ObjectError::Schema(format!("{what}: expected 16 bytes")))
63 +
    NodeId::from_slice(bytes(v, what)?)
64 +
        .ok_or_else(|| ObjectError::Schema(format!("{what}: expected 16 bytes")))
57 65
}
58 66
pub(crate) fn check_version(m: &Map) -> Result<(), ObjectError> {
59 67
    let v = uint(field(m, 0, "version")?, "version")?;
…
18 unchanged lines
78 86
            .duration_since(std::time::UNIX_EPOCH)
79 87
            .map(|d| d.as_millis() as u64)
80 88
            .unwrap_or(0);
81 -
        Hlc { wall_ms, logical: 0 }
89 +
        Hlc {
90 +
            wall_ms,
91 +
            logical: 0,
92 +
        }
82 93
    }
83 94
    pub(crate) fn to_value(self) -> Value {
84 -
        Value::map([(0, Value::Uint(self.wall_ms)), (1, Value::Uint(self.logical as u64))])
95 +
        Value::map([
96 +
            (0, Value::Uint(self.wall_ms)),
97 +
            (1, Value::Uint(self.logical as u64)),
98 +
        ])
85 99
    }
86 100
    pub(crate) fn from_value(v: &Value) -> Result<Hlc, ObjectError> {
87 101
        let m = map(v, "hlc")?;
…
43 unchanged lines
131 145
}
132 146
133 147
impl Mode {
134 -
    pub const TEXT: Mode = Mode { exec: false, opaque: false };
135 -
    pub const OPAQUE: Mode = Mode { exec: false, opaque: true };
148 +
    pub const TEXT: Mode = Mode {
149 +
        exec: false,
150 +
        opaque: false,
151 +
    };
152 +
    pub const OPAQUE: Mode = Mode {
153 +
        exec: false,
154 +
        opaque: true,
155 +
    };
136 156
    pub(crate) fn bits(self) -> u64 {
137 157
        (self.exec as u64) | ((self.opaque as u64) << 1)
138 158
    }
…
1 unchanged lines
140 160
        if b > 3 {
141 161
            return schema(format!("unknown mode bits {b}"));
142 162
        }
143 -
        Ok(Mode { exec: b & 1 != 0, opaque: b & 2 != 0 })
163 +
        Ok(Mode {
164 +
            exec: b & 1 != 0,
165 +
            opaque: b & 2 != 0,
166 +
        })
144 167
    }
145 168
}
146 169
…
7 unchanged lines
154 177
155 178
impl OpRef {
156 179
    fn to_value(self) -> Value {
157 -
        Value::Array(vec![Value::Bytes(self.change.0.to_vec()), Value::Uint(self.index as u64)])
180 +
        Value::Array(vec![
181 +
            Value::Bytes(self.change.0.to_vec()),
182 +
            Value::Uint(self.index as u64),
183 +
        ])
158 184
    }
159 185
    fn from_value(v: &Value, what: &str) -> Result<OpRef, ObjectError> {
160 186
        let a = array(v, what)?;
161 187
        if a.len() != 2 {
162 188
            return schema(format!("{what}: expected [hash, index]"));
163 189
        }
164 -
        Ok(OpRef { change: oref(&a[0], what)?, index: u32_(&a[1], what)? })
190 +
        Ok(OpRef {
191 +
            change: oref(&a[0], what)?,
192 +
            index: u32_(&a[1], what)?,
193 +
        })
165 194
    }
166 195
    /// Replace a self reference with the concrete change hash.
167 196
    pub fn resolve(self, me: ChangeHash) -> OpRef {
168 -
        if self.change.is_zero() { OpRef { change: me, ..self } } else { self }
197 +
        if self.change.is_zero() {
198 +
            OpRef { change: me, ..self }
199 +
        } else {
200 +
            self
201 +
        }
169 202
    }
170 203
}
171 204
172 205
#[derive(Debug, Clone, PartialEq, Eq)]
173 206
pub enum TreeOp {
174 -
    AddNode { node: NodeId, kind: NodeKind, mode: Mode, parent: NodeId, name: String },
175 -
    RemoveNode { node: NodeId, placement: OpRef },
176 -
    Move { node: NodeId, old: OpRef, parent: NodeId, name: String },
177 -
    SetMode { node: NodeId, old: OpRef, mode: Mode },
207 +
    AddNode {
208 +
        node: NodeId,
209 +
        kind: NodeKind,
210 +
        mode: Mode,
211 +
        parent: NodeId,
212 +
        name: String,
213 +
    },
214 +
    RemoveNode {
215 +
        node: NodeId,
216 +
        placement: OpRef,
217 +
    },
218 +
    Move {
219 +
        node: NodeId,
220 +
        old: OpRef,
221 +
        parent: NodeId,
222 +
        name: String,
223 +
    },
224 +
    SetMode {
225 +
        node: NodeId,
226 +
        old: OpRef,
227 +
        mode: Mode,
228 +
    },
178 229
}
179 230
180 231
/// Names must be valid, non-empty, not `.`/`..`, and free of `/` and NUL (spec §7.1).
…
17 unchanged lines
198 249
    fn to_value(&self) -> Value {
199 250
        let nid = |n: &NodeId| Value::Bytes(n.0.to_vec());
200 251
        match self {
201 -
            TreeOp::AddNode { node, kind, mode, parent, name } => Value::map([
252 +
            TreeOp::AddNode {
253 +
                node,
254 +
                kind,
255 +
                mode,
256 +
                parent,
257 +
                name,
258 +
            } => Value::map([
202 259
                (0, Value::Uint(0)),
203 260
                (1, nid(node)),
204 261
                (2, Value::Uint(kind.tag())),
…
1 unchanged lines
206 263
                (4, nid(parent)),
207 264
                (5, Value::Text(name.clone())),
208 265
            ]),
209 -
            TreeOp::RemoveNode { node, placement } => {
210 -
                Value::map([(0, Value::Uint(1)), (1, nid(node)), (2, placement.to_value())])
211 -
            }
212 -
            TreeOp::Move { node, old, parent, name } => Value::map([
266 +
            TreeOp::RemoveNode { node, placement } => Value::map([
267 +
                (0, Value::Uint(1)),
268 +
                (1, nid(node)),
269 +
                (2, placement.to_value()),
270 +
            ]),
271 +
            TreeOp::Move {
272 +
                node,
273 +
                old,
274 +
                parent,
275 +
                name,
276 +
            } => Value::map([
213 277
                (0, Value::Uint(2)),
214 278
                (1, nid(node)),
215 279
                (2, old.to_value()),
…
25 unchanged lines
241 305
                    name,
242 306
                }
243 307
            }
244 -
            1 => TreeOp::RemoveNode { node, placement: OpRef::from_value(field(m, 2, "placement")?, "placement")? },
308 +
            1 => TreeOp::RemoveNode {
309 +
                node,
310 +
                placement: OpRef::from_value(field(m, 2, "placement")?, "placement")?,
311 +
            },
245 312
            2 => {
246 313
                let name = text(field(m, 5, "name")?, "name")?.to_owned();
247 314
                validate_name(&name)?;
…
32 unchanged lines
280 347
        match self {
281 348
            AtomRef::Start => Value::Uint(0),
282 349
            AtomRef::End => Value::Uint(1),
283 -
            AtomRef::Atom { change, index } => {
284 -
                Value::Array(vec![Value::Bytes(change.0.to_vec()), Value::Uint(index as u64)])
285 -
            }
350 +
            AtomRef::Atom { change, index } => Value::Array(vec![
351 +
                Value::Bytes(change.0.to_vec()),
352 +
                Value::Uint(index as u64),
353 +
            ]),
286 354
        }
287 355
    }
288 356
    fn from_value(v: &Value, what: &str) -> Result<AtomRef, ObjectError> {
289 357
        match v {
290 358
            Value::Uint(0) => Ok(AtomRef::Start),
291 359
            Value::Uint(1) => Ok(AtomRef::End),
292 -
            Value::Array(a) if a.len() == 2 => {
293 -
                Ok(AtomRef::Atom { change: oref(&a[0], what)?, index: u32_(&a[1], what)? })
294 -
            }
360 +
            Value::Array(a) if a.len() == 2 => Ok(AtomRef::Atom {
361 +
                change: oref(&a[0], what)?,
362 +
                index: u32_(&a[1], what)?,
363 +
            }),
295 364
            _ => schema(format!("{what}: expected atom reference")),
296 365
        }
297 366
    }
298 367
    pub fn resolve(self, me: ChangeHash) -> AtomRef {
299 368
        match self {
300 -
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change: me, index },
369 +
            AtomRef::Atom { change, index } if change.is_zero() => {
370 +
                AtomRef::Atom { change: me, index }
371 +
            }
301 372
            other => other,
302 373
        }
303 374
    }
…
20 unchanged lines
324 395
        if a.len() != 3 {
325 396
            return schema("atom range: expected [hash, from, to]");
326 397
        }
327 -
        let r = AtomRange { change: oref(&a[0], "range.change")?, from: u32_(&a[1], "range.from")?, to: u32_(&a[2], "range.to")? };
398 +
        let r = AtomRange {
399 +
            change: oref(&a[0], "range.change")?,
400 +
            from: u32_(&a[1], "range.from")?,
401 +
            to: u32_(&a[2], "range.to")?,
402 +
        };
328 403
        if r.from >= r.to {
329 404
            return schema("atom range: empty or inverted");
330 405
        }
331 406
        Ok(r)
332 407
    }
333 408
    pub fn resolve(self, me: ChangeHash) -> AtomRange {
334 -
        if self.change.is_zero() { AtomRange { change: me, ..self } } else { self }
409 +
        if self.change.is_zero() {
410 +
            AtomRange { change: me, ..self }
411 +
        } else {
412 +
            self
413 +
        }
335 414
    }
336 415
}
337 416
338 417
#[derive(Debug, Clone, PartialEq, Eq)]
339 418
pub enum Edit {
340 -
    Insert { node: NodeId, after: AtomRef, before: AtomRef, lines: Vec<Vec<u8>> },
341 -
    Delete { node: NodeId, atoms: Vec<AtomRange> },
342 -
    Replace { node: NodeId, old: Option<ObjectRef>, new: Option<ObjectRef> },
419 +
    Insert {
420 +
        node: NodeId,
421 +
        after: AtomRef,
422 +
        before: AtomRef,
423 +
        lines: Vec<Vec<u8>>,
424 +
    },
425 +
    Delete {
426 +
        node: NodeId,
427 +
        atoms: Vec<AtomRange>,
428 +
    },
429 +
    Replace {
430 +
        node: NodeId,
431 +
        old: Option<ObjectRef>,
432 +
        new: Option<ObjectRef>,
433 +
    },
343 434
}
344 435
345 436
impl Edit {
346 437
    pub fn node(&self) -> NodeId {
347 438
        match self {
348 -
            Edit::Insert { node, .. } | Edit::Delete { node, .. } | Edit::Replace { node, .. } => *node,
439 +
            Edit::Insert { node, .. } | Edit::Delete { node, .. } | Edit::Replace { node, .. } => {
440 +
                *node
441 +
            }
349 442
        }
350 443
    }
351 444
…
4 unchanged lines
356 449
            None => Value::Null,
357 450
        };
358 451
        match self {
359 -
            Edit::Insert { node, after, before, lines } => Value::map([
452 +
            Edit::Insert {
453 +
                node,
454 +
                after,
455 +
                before,
456 +
                lines,
457 +
            } => Value::map([
360 458
                (0, Value::Uint(0)),
361 459
                (1, nid(node)),
362 460
                (2, after.to_value()),
363 461
                (3, before.to_value()),
364 -
                (4, Value::Array(lines.iter().map(|l| Value::Bytes(l.clone())).collect())),
462 +
                (
463 +
                    4,
464 +
                    Value::Array(lines.iter().map(|l| Value::Bytes(l.clone())).collect()),
465 +
                ),
365 466
            ]),
366 467
            Edit::Delete { node, atoms } => Value::map([
367 468
                (0, Value::Uint(1)),
368 469
                (1, nid(node)),
369 -
                (2, Value::Array(atoms.iter().map(|r| r.to_value()).collect())),
470 +
                (
471 +
                    2,
472 +
                    Value::Array(atoms.iter().map(|r| r.to_value()).collect()),
473 +
                ),
370 474
            ]),
371 -
            Edit::Replace { node, old, new } => {
372 -
                Value::map([(0, Value::Uint(2)), (1, nid(node)), (2, opt(old)), (3, opt(new))])
373 -
            }
475 +
            Edit::Replace { node, old, new } => Value::map([
476 +
                (0, Value::Uint(2)),
477 +
                (1, nid(node)),
478 +
                (2, opt(old)),
479 +
                (3, opt(new)),
480 +
            ]),
374 481
        }
375 482
    }
376 483
…
2 unchanged lines
379 486
        let tag = uint(field(m, 0, "edit tag")?, "edit tag")?;
380 487
        let node = node_id(field(m, 1, "edit.node")?, "edit.node")?;
381 488
        let opt = |v: &Value, what: &str| -> Result<Option<ObjectRef>, ObjectError> {
382 -
            if v.is_null() { Ok(None) } else { Ok(Some(oref(v, what)?)) }
489 +
            if v.is_null() {
490 +
                Ok(None)
491 +
            } else {
492 +
                Ok(Some(oref(v, what)?))
493 +
            }
383 494
        };
384 495
        Ok(match tag {
385 496
            0 => {
…
21 unchanged lines
407 518
                }
408 519
                Edit::Delete { node, atoms }
409 520
            }
410 -
            2 => Edit::Replace { node, old: opt(field(m, 2, "old")?, "old")?, new: opt(field(m, 3, "new")?, "new")? },
521 +
            2 => Edit::Replace {
522 +
                node,
523 +
                old: opt(field(m, 2, "old")?, "old")?,
524 +
                new: opt(field(m, 3, "new")?, "new")?,
525 +
            },
411 526
            _ => return schema(format!("unknown edit tag {tag}")),
412 527
        })
413 528
    }
…
49 unchanged lines
463 578
        Value::map([
464 579
            (0, Value::Uint(FORMAT_VERSION)),
465 580
            (1, Value::Bytes(self.change_id.0.to_vec())),
466 -
            (2, Value::Array(self.deps.iter().map(|d| Value::Bytes(d.0.to_vec())).collect())),
581 +
            (
582 +
                2,
583 +
                Value::Array(
584 +
                    self.deps
585 +
                        .iter()
586 +
                        .map(|d| Value::Bytes(d.0.to_vec()))
587 +
                        .collect(),
588 +
                ),
589 +
            ),
467 590
            (3, Value::Bytes(self.author.0.to_vec())),
468 591
            (4, self.hlc.to_value()),
469 592
            (5, Value::Text(self.message.clone())),
470 -
            (6, Value::Array(self.tree_ops.iter().map(|t| t.to_value()).collect())),
471 -
            (7, Value::Array(self.edits.iter().map(|e| e.to_value()).collect())),
593 +
            (
594 +
                6,
595 +
                Value::Array(self.tree_ops.iter().map(|t| t.to_value()).collect()),
596 +
            ),
597 +
            (
598 +
                7,
599 +
                Value::Array(self.edits.iter().map(|e| e.to_value()).collect()),
600 +
            ),
472 601
            (8, Value::Uint(self.kind.tag())),
473 602
            (9, Value::Map(BTreeMap::new())),
474 603
        ])
475 604
    }
476 605
477 606
    pub fn encode(&self) -> Vec<u8> {
478 -
        debug_assert!(self.deps.windows(2).all(|w| w[0] < w[1]), "deps must be normalized");
607 +
        debug_assert!(
608 +
            self.deps.windows(2).all(|w| w[0] < w[1]),
609 +
            "deps must be normalized"
610 +
        );
479 611
        cbor::encode(&self.to_value())
480 612
    }
481 613
…
58 unchanged lines
540 672
        cbor::encode(&Value::map([
541 673
            (0, Value::Uint(FORMAT_VERSION)),
542 674
            (1, Value::Uint(self.length)),
543 -
            (2, Value::Array(self.chunks.iter().map(|c| Value::Bytes(c.0.to_vec())).collect())),
675 +
            (
676 +
                2,
677 +
                Value::Array(
678 +
                    self.chunks
679 +
                        .iter()
680 +
                        .map(|c| Value::Bytes(c.0.to_vec()))
681 +
                        .collect(),
682 +
                ),
683 +
            ),
544 684
        ]))
545 685
    }
546 686
    pub fn hash(&self) -> ObjectRef {
…
28 unchanged lines
575 715
    pub fn encode(&self) -> Vec<u8> {
576 716
        cbor::encode(&Value::map([
577 717
            (0, Value::Uint(FORMAT_VERSION)),
578 -
            (1, Value::Array(self.heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())),
718 +
            (
719 +
                1,
720 +
                Value::Array(
721 +
                    self.heads
722 +
                        .iter()
723 +
                        .map(|h| Value::Bytes(h.0.to_vec()))
724 +
                        .collect(),
725 +
                ),
726 +
            ),
579 727
            (2, Value::Uint(self.count)),
580 728
            (3, Value::Bytes(self.payload.0.to_vec())),
581 729
        ]))
…
6 unchanged lines
588 736
        let m = map(&v, "checkpoint")?;
589 737
        check_version(m)?;
590 738
        Ok(Checkpoint {
591 -
            heads: array(field(m, 1, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?,
739 +
            heads: array(field(m, 1, "heads")?, "heads")?
740 +
                .iter()
741 +
                .map(|h| oref(h, "head"))
742 +
                .collect::<Result<Vec<_>, _>>()?,
592 743
            count: uint(field(m, 2, "count")?, "count")?,
593 744
            payload: oref(field(m, 3, "payload")?, "payload")?,
594 745
        })
…
18 unchanged lines
613 764
        debug_assert!(self.heads.windows(2).all(|w| w[0] < w[1]));
614 765
        cbor::encode(&Value::map([
615 766
            (0, Value::Uint(FORMAT_VERSION)),
616 -
            (1, Value::Array(self.heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())),
767 +
            (
768 +
                1,
769 +
                Value::Array(
770 +
                    self.heads
771 +
                        .iter()
772 +
                        .map(|h| Value::Bytes(h.0.to_vec()))
773 +
                        .collect(),
774 +
                ),
775 +
            ),
617 776
        ]))
618 777
    }
619 778
    pub fn hash(&self) -> ObjectRef {
…
3 unchanged lines
623 782
        let v = cbor::decode(bytes)?;
624 783
        let m = map(&v, "state")?;
625 784
        check_version(m)?;
626 -
        let heads = array(field(m, 1, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?;
785 +
        let heads = array(field(m, 1, "heads")?, "heads")?
786 +
            .iter()
787 +
            .map(|h| oref(h, "head"))
788 +
            .collect::<Result<Vec<_>, _>>()?;
627 789
        if !heads.windows(2).all(|w| w[0] < w[1]) {
628 790
            return schema("state heads must be sorted and unique");
629 791
        }
…
12 unchanged lines
642 804
            change_id: ChangeId([9; 16]),
643 805
            deps: vec![ObjectRef([2; 32]), dep],
644 806
            author: ObjectRef([3; 32]),
645 -
            hlc: Hlc { wall_ms: 1_700_000_000_000, logical: 4 },
807 +
            hlc: Hlc {
808 +
                wall_ms: 1_700_000_000_000,
809 +
                logical: 4,
810 +
            },
646 811
            message: "Add greeting".into(),
647 812
            tree_ops: vec![
648 -
                TreeOp::AddNode { node: n, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "hello.txt".into() },
649 -
                TreeOp::SetMode { node: n, old: OpRef { change: ObjectRef::ZERO, index: 0 }, mode: Mode { exec: true, opaque: false } },
813 +
                TreeOp::AddNode {
814 +
                    node: n,
815 +
                    kind: NodeKind::File,
816 +
                    mode: Mode::TEXT,
817 +
                    parent: NodeId::ROOT,
818 +
                    name: "hello.txt".into(),
819 +
                },
820 +
                TreeOp::SetMode {
821 +
                    node: n,
822 +
                    old: OpRef {
823 +
                        change: ObjectRef::ZERO,
824 +
                        index: 0,
825 +
                    },
826 +
                    mode: Mode {
827 +
                        exec: true,
828 +
                        opaque: false,
829 +
                    },
830 +
                },
650 831
            ],
651 832
            edits: vec![
652 -
                Edit::Insert { node: n, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"hello\n".to_vec(), b"world\n".to_vec()] },
653 -
                Edit::Delete { node: n, atoms: vec![AtomRange { change: dep, from: 0, to: 2 }] },
654 -
                Edit::Replace { node: n, old: None, new: Some(ObjectRef([5; 32])) },
833 +
                Edit::Insert {
834 +
                    node: n,
835 +
                    after: AtomRef::Start,
836 +
                    before: AtomRef::End,
837 +
                    lines: vec![b"hello\n".to_vec(), b"world\n".to_vec()],
838 +
                },
839 +
                Edit::Delete {
840 +
                    node: n,
841 +
                    atoms: vec![AtomRange {
842 +
                        change: dep,
843 +
                        from: 0,
844 +
                        to: 2,
845 +
                    }],
846 +
                },
847 +
                Edit::Replace {
848 +
                    node: n,
849 +
                    old: None,
850 +
                    new: Some(ObjectRef([5; 32])),
851 +
                },
655 852
            ],
656 853
            kind: ChangeKind::Normal,
657 854
        };
…
22 unchanged lines
680 877
        if let Value::Map(m) = &mut v {
681 878
            m.insert(0, Value::Uint(2));
682 879
        }
683 -
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("version")));
880 +
        assert!(
881 +
            matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("version"))
882 +
        );
684 883
685 884
        let mut v = c.to_value();
686 885
        if let Value::Map(m) = &mut v {
687 -
            m.insert(2, Value::Array(vec![Value::Bytes(vec![2; 32]), Value::Bytes(vec![1; 32])]));
886 +
            m.insert(
887 +
                2,
888 +
                Value::Array(vec![Value::Bytes(vec![2; 32]), Value::Bytes(vec![1; 32])]),
889 +
            );
688 890
        }
689 -
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("sorted")));
891 +
        assert!(
892 +
            matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("sorted"))
893 +
        );
690 894
691 895
        let mut v = c.to_value();
692 896
        if let Value::Map(m) = &mut v {
693 897
            m.insert(9, Value::map([(0, Value::Null)]));
694 898
        }
695 -
        assert!(matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("extra")));
899 +
        assert!(
900 +
            matches!(Change::from_value(&v), Err(ObjectError::Schema(s)) if s.contains("extra"))
901 +
        );
696 902
    }
697 903
698 904
    #[test]
…
7 unchanged lines
706 912
707 913
    #[test]
708 914
    fn mode_and_kind_tags() {
709 -
        for m in [Mode::TEXT, Mode::OPAQUE, Mode { exec: true, opaque: true }] {
915 +
        for m in [
916 +
            Mode::TEXT,
917 +
            Mode::OPAQUE,
918 +
            Mode {
919 +
                exec: true,
920 +
                opaque: true,
921 +
            },
922 +
        ] {
710 923
            assert_eq!(Mode::from_bits(m.bits()).unwrap(), m);
711 924
        }
712 925
        assert!(Mode::from_bits(4).is_err());
713 -
        for k in [NodeKind::File, NodeKind::Dir, NodeKind::Symlink, NodeKind::Subrepo] {
926 +
        for k in [
927 +
            NodeKind::File,
928 +
            NodeKind::Dir,
929 +
            NodeKind::Symlink,
930 +
            NodeKind::Subrepo,
931 +
        ] {
714 932
            assert_eq!(NodeKind::from_tag(k.tag()).unwrap(), k);
715 933
        }
716 934
    }
…
1 unchanged lines
718 936
    #[test]
719 937
    fn self_references_resolve() {
720 938
        let me = ObjectRef([8; 32]);
721 -
        assert_eq!(AtomRef::Atom { change: ObjectRef::ZERO, index: 3 }.resolve(me), AtomRef::Atom { change: me, index: 3 });
939 +
        assert_eq!(
940 +
            AtomRef::Atom {
941 +
                change: ObjectRef::ZERO,
942 +
                index: 3
943 +
            }
944 +
            .resolve(me),
945 +
            AtomRef::Atom {
946 +
                change: me,
947 +
                index: 3
948 +
            }
949 +
        );
722 950
        assert_eq!(AtomRef::Start.resolve(me), AtomRef::Start);
723 951
        let other = ObjectRef([1; 32]);
724 -
        assert_eq!(OpRef { change: other, index: 1 }.resolve(me).change, other);
952 +
        assert_eq!(
953 +
            OpRef {
954 +
                change: other,
955 +
                index: 1
956 +
            }
957 +
            .resolve(me)
958 +
            .change,
959 +
            other
960 +
        );
725 961
    }
726 962
727 963
    #[test]
728 964
    fn state_object_round_trip() {
729 -
        let st = StateObject::new(vec![ObjectRef([2; 32]), ObjectRef([1; 32]), ObjectRef([2; 32])]);
965 +
        let st = StateObject::new(vec![
966 +
            ObjectRef([2; 32]),
967 +
            ObjectRef([1; 32]),
968 +
            ObjectRef([2; 32]),
969 +
        ]);
730 970
        assert_eq!(st.heads, vec![ObjectRef([1; 32]), ObjectRef([2; 32])]);
731 971
        assert_eq!(StateObject::decode(&st.encode()).unwrap(), st);
732 972
        assert_eq!(StateObject::new(vec![]).heads, vec![]);
…
2 unchanged lines
735 975
736 976
    #[test]
737 977
    fn blob_round_trip() {
738 -
        let b = Blob { length: 10, chunks: vec![ObjectRef([1; 32]), ObjectRef([2; 32])] };
978 +
        let b = Blob {
979 +
            length: 10,
980 +
            chunks: vec![ObjectRef([1; 32]), ObjectRef([2; 32])],
981 +
        };
739 982
        assert_eq!(Blob::decode(&b.encode()).unwrap(), b);
740 983
    }
741 984
}
modified crates/oxy-core/src/oplog.rs +147 −33
…
9 unchanged lines
10 10
use crate::cbor::{self, Value};
11 11
use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
12 12
use crate::ids::WorkspaceId;
13 -
use crate::objects::{array, bytes, check_version, field, oref, text, Hlc, ObjectError, FORMAT_VERSION};
13 +
use crate::objects::{
14 +
    array, bytes, check_version, field, oref, text, Hlc, ObjectError, FORMAT_VERSION,
15 +
};
14 16
use std::collections::BTreeMap;
15 17
16 18
#[derive(Clone, Debug, Default, PartialEq, Eq)]
…
32 unchanged lines
49 51
                Value::Array(
50 52
                    self.bookmarks
51 53
                        .iter()
52 -
                        .map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())]))
54 +
                        .map(|(n, st)| {
55 +
                            Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])
56 +
                        })
53 57
                        .collect(),
54 58
                ),
55 59
            ),
…
2 unchanged lines
58 62
                Value::Array(
59 63
                    self.workspaces
60 64
                        .iter()
61 -
                        .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())]))
65 +
                        .map(|(w, h)| {
66 +
                            Value::Array(vec![
67 +
                                Value::Bytes(w.0.to_vec()),
68 +
                                Value::Bytes(h.0.to_vec()),
69 +
                            ])
70 +
                        })
62 71
                        .collect(),
63 72
                ),
64 73
            ),
…
6 unchanged lines
71 80
                            Value::Array(vec![
72 81
                                Value::Text(r.clone()),
73 82
                                Value::Array(
74 -
                                    bms.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect(),
83 +
                                    bms.iter()
84 +
                                        .map(|(n, st)| {
85 +
                                            Value::Array(vec![
86 +
                                                Value::Text(n.clone()),
87 +
                                                Value::Bytes(st.0.to_vec()),
88 +
                                            ])
89 +
                                        })
90 +
                                        .collect(),
75 91
                                ),
76 92
                            ])
77 93
                        })
78 94
                        .collect(),
79 95
                ),
80 96
            ),
81 -
            (3, self.policy.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)),
82 -
            (4, Value::Array(self.tags.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect())),
97 +
            (
98 +
                3,
99 +
                self.policy
100 +
                    .map(|p| Value::Bytes(p.0.to_vec()))
101 +
                    .unwrap_or(Value::Null),
102 +
            ),
103 +
            (
104 +
                4,
105 +
                Value::Array(
106 +
                    self.tags
107 +
                        .iter()
108 +
                        .map(|(n, st)| {
109 +
                            Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])
110 +
                        })
111 +
                        .collect(),
112 +
                ),
113 +
            ),
83 114
        ])
84 115
    }
85 116
86 117
    fn from_value(v: &Value) -> Result<View, ObjectError> {
87 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?;
118 +
        let m = v
119 +
            .as_map()
120 +
            .ok_or_else(|| ObjectError::Schema("view: expected map".into()))?;
88 121
        let mut view = View::default();
89 122
        for b in array(field(m, 0, "bookmarks")?, "bookmarks")? {
90 123
            let pair = array(b, "bookmark")?;
91 124
            if pair.len() != 2 {
92 -
                return Err(ObjectError::Schema("bookmark: expected [name, heads]".into()));
125 +
                return Err(ObjectError::Schema(
126 +
                    "bookmark: expected [name, heads]".into(),
127 +
                ));
93 128
            }
94 129
            let name = text(&pair[0], "bookmark name")?.to_owned();
95 -
            view.bookmarks.insert(name, oref(&pair[1], "bookmark state")?);
130 +
            view.bookmarks
131 +
                .insert(name, oref(&pair[1], "bookmark state")?);
96 132
        }
97 133
        view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?;
98 134
        if let Some(rs) = m.get(&2) {
99 135
            for r in array(rs, "remotes")? {
100 136
                let pair = array(r, "remote")?;
101 137
                if pair.len() != 2 {
102 -
                    return Err(ObjectError::Schema("remote: expected [name, bookmarks]".into()));
138 +
                    return Err(ObjectError::Schema(
139 +
                        "remote: expected [name, bookmarks]".into(),
140 +
                    ));
103 141
                }
104 142
                let name = text(&pair[0], "remote name")?.to_owned();
105 143
                let mut bms = BTreeMap::new();
106 144
                for b in array(&pair[1], "remote bookmarks")? {
107 145
                    let bp = array(b, "remote bookmark")?;
108 146
                    if bp.len() != 2 {
109 -
                        return Err(ObjectError::Schema("remote bookmark: expected [name, state]".into()));
147 +
                        return Err(ObjectError::Schema(
148 +
                            "remote bookmark: expected [name, state]".into(),
149 +
                        ));
110 150
                    }
111 -
                    bms.insert(text(&bp[0], "bookmark name")?.to_owned(), oref(&bp[1], "bookmark state")?);
151 +
                    bms.insert(
152 +
                        text(&bp[0], "bookmark name")?.to_owned(),
153 +
                        oref(&bp[1], "bookmark state")?,
154 +
                    );
112 155
                }
113 156
                view.remotes.insert(name, bms);
114 157
            }
115 158
        }
116 159
        if let Some(p) = m.get(&3) {
117 -
            view.policy = if p.is_null() { None } else { Some(oref(p, "policy")?) };
160 +
            view.policy = if p.is_null() {
161 +
                None
162 +
            } else {
163 +
                Some(oref(p, "policy")?)
164 +
            };
118 165
        }
119 166
        if let Some(ts) = m.get(&4) {
120 167
            for t in array(ts, "tags")? {
…
1 unchanged lines
122 169
                if pair.len() != 2 {
123 170
                    return Err(ObjectError::Schema("tag: expected [name, state]".into()));
124 171
                }
125 -
                view.tags.insert(text(&pair[0], "tag name")?.to_owned(), oref(&pair[1], "tag state")?);
172 +
                view.tags.insert(
173 +
                    text(&pair[0], "tag name")?.to_owned(),
174 +
                    oref(&pair[1], "tag state")?,
175 +
                );
126 176
            }
127 177
        }
128 178
        Ok(view)
…
5 unchanged lines
134 184
    for w in array(v, "workspaces")? {
135 185
        let pair = array(w, "workspace")?;
136 186
        if pair.len() != 2 {
137 -
            return Err(ObjectError::Schema("workspace: expected [id, change]".into()));
187 +
            return Err(ObjectError::Schema(
188 +
                "workspace: expected [id, change]".into(),
189 +
            ));
138 190
        }
139 191
        let id = WorkspaceId::from_slice(bytes(&pair[0], "workspace id")?)
140 192
            .ok_or_else(|| ObjectError::Schema("workspace id: expected 16 bytes".into()))?;
…
4 unchanged lines
145 197
146 198
impl Format1View {
147 199
    fn from_value(v: &Value) -> Result<Format1View, ObjectError> {
148 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?;
200 +
        let m = v
201 +
            .as_map()
202 +
            .ok_or_else(|| ObjectError::Schema("view: expected map".into()))?;
149 203
        let mut view = Format1View::default();
150 204
        for b in array(field(m, 0, "bookmarks")?, "bookmarks")? {
151 205
            let pair = array(b, "bookmark")?;
152 206
            if pair.len() != 2 {
153 -
                return Err(ObjectError::Schema("bookmark: expected [name, heads]".into()));
207 +
                return Err(ObjectError::Schema(
208 +
                    "bookmark: expected [name, heads]".into(),
209 +
                ));
154 210
            }
155 211
            let name = text(&pair[0], "bookmark name")?.to_owned();
156 -
            let mut heads = array(&pair[1], "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?;
212 +
            let mut heads = array(&pair[1], "heads")?
213 +
                .iter()
214 +
                .map(|h| oref(h, "head"))
215 +
                .collect::<Result<Vec<_>, _>>()?;
157 216
            heads.sort();
158 217
            heads.dedup();
159 218
            view.bookmarks.insert(name, heads);
…
14 unchanged lines
174 233
                        .map(|(n, hs)| {
175 234
                            Value::Array(vec![
176 235
                                Value::Text(n.clone()),
177 -
                                Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
236 +
                                Value::Array(
237 +
                                    hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect(),
238 +
                                ),
178 239
                            ])
179 240
                        })
180 241
                        .collect(),
…
4 unchanged lines
185 246
                Value::Array(
186 247
                    self.workspaces
187 248
                        .iter()
188 -
                        .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())]))
249 +
                        .map(|(w, h)| {
250 +
                            Value::Array(vec![
251 +
                                Value::Bytes(w.0.to_vec()),
252 +
                                Value::Bytes(h.0.to_vec()),
253 +
                            ])
254 +
                        })
189 255
                        .collect(),
190 256
                ),
191 257
            ),
…
15 unchanged lines
207 273
    pub(crate) fn encode(&self) -> Vec<u8> {
208 274
        cbor::encode(&Value::map([
209 275
            (0, Value::Uint(FORMAT_VERSION)),
210 -
            (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())),
276 +
            (
277 +
                1,
278 +
                Value::Array(
279 +
                    self.parents
280 +
                        .iter()
281 +
                        .map(|p| Value::Bytes(p.0.to_vec()))
282 +
                        .collect(),
283 +
                ),
284 +
            ),
211 285
            (2, self.hlc.to_value()),
212 286
            (3, Value::Text(self.command.clone())),
213 287
            (4, self.view.to_value()),
…
14 unchanged lines
228 302
    pub fn to_value(&self) -> Value {
229 303
        Value::map([
230 304
            (0, Value::Uint(FORMAT_VERSION)),
231 -
            (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())),
305 +
            (
306 +
                1,
307 +
                Value::Array(
308 +
                    self.parents
309 +
                        .iter()
310 +
                        .map(|p| Value::Bytes(p.0.to_vec()))
311 +
                        .collect(),
312 +
                ),
313 +
            ),
232 314
            (2, self.hlc.to_value()),
233 315
            (3, Value::Text(self.command.clone())),
234 316
            (4, self.view.to_value()),
…
7 unchanged lines
242 324
    }
243 325
    pub fn decode(b: &[u8]) -> Result<Op, ObjectError> {
244 326
        let v = cbor::decode(b)?;
245 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?;
327 +
        let m = v
328 +
            .as_map()
329 +
            .ok_or_else(|| ObjectError::Schema("op: expected map".into()))?;
246 330
        check_version(m)?;
247 331
        Ok(Op {
248 -
            parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::<Result<Vec<_>, _>>()?,
332 +
            parents: array(field(m, 1, "parents")?, "parents")?
333 +
                .iter()
334 +
                .map(|p| oref(p, "parent"))
335 +
                .collect::<Result<Vec<_>, _>>()?,
249 336
            hlc: Hlc::from_value(field(m, 2, "hlc")?)?,
250 337
            command: text(field(m, 3, "command")?, "command")?.to_owned(),
251 338
            view: View::from_value(field(m, 4, "view")?)?,
…
3 unchanged lines
255 342
    /// Read an operation written by repository format 1.
256 343
    pub fn decode_format1(b: &[u8]) -> Result<Format1Op, ObjectError> {
257 344
        let v = cbor::decode(b)?;
258 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?;
345 +
        let m = v
346 +
            .as_map()
347 +
            .ok_or_else(|| ObjectError::Schema("op: expected map".into()))?;
259 348
        check_version(m)?;
260 349
        Ok(Format1Op {
261 -
            parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::<Result<Vec<_>, _>>()?,
350 +
            parents: array(field(m, 1, "parents")?, "parents")?
351 +
                .iter()
352 +
                .map(|p| oref(p, "parent"))
353 +
                .collect::<Result<Vec<_>, _>>()?,
262 354
            hlc: Hlc::from_value(field(m, 2, "hlc")?)?,
263 355
            command: text(field(m, 3, "command")?, "command")?.to_owned(),
264 356
            view: Format1View::from_value(field(m, 4, "view")?)?,
…
10 unchanged lines
275 367
        let mut view = View::default();
276 368
        view.bookmarks.insert("main".into(), ObjectRef([2; 32]));
277 369
        view.bookmarks.insert("dev".into(), ObjectRef([3; 32]));
278 -
        view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
279 -
        view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32]));
370 +
        view.workspaces
371 +
            .insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
372 +
        view.remotes
373 +
            .entry("origin".into())
374 +
            .or_default()
375 +
            .insert("main".into(), ObjectRef([4; 32]));
280 376
        view.policy = Some(ObjectRef([6; 32]));
281 377
        view.tags.insert("v1".into(), ObjectRef([8; 32]));
282 -
        let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view };
378 +
        let op = Op {
379 +
            parents: vec![ObjectRef([7; 32])],
380 +
            hlc: Hlc {
381 +
                wall_ms: 5,
382 +
                logical: 1,
383 +
            },
384 +
            command: "oxy commit".into(),
385 +
            view,
386 +
        };
283 387
        let d = Op::decode(&op.encode()).unwrap();
284 388
        assert_eq!(d, op);
285 389
        assert_eq!(d.hash(), Op::decode(&d.encode()).unwrap().hash());
286 390
        // A format-1 op does not decode as format 2, but does through the migration reader.
287 391
        let mut v1 = Format1View::default();
288 -
        v1.bookmarks.insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]);
289 -
        v1.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
290 -
        let old = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1 };
392 +
        v1.bookmarks
393 +
            .insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]);
394 +
        v1.workspaces
395 +
            .insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
396 +
        let old = Format1Op {
397 +
            parents: vec![],
398 +
            hlc: Hlc::default(),
399 +
            command: "oxy init".into(),
400 +
            view: v1,
401 +
        };
291 402
        assert!(Op::decode(&old.encode()).is_err());
292 403
        let back = Op::decode_format1(&old.encode()).unwrap();
293 -
        assert_eq!(back.view.bookmarks["main"], vec![ObjectRef([1; 32]), ObjectRef([2; 32])]);
404 +
        assert_eq!(
405 +
            back.view.bookmarks["main"],
406 +
            vec![ObjectRef([1; 32]), ObjectRef([2; 32])]
407 +
        );
294 408
    }
295 409
}
modified crates/oxy-core/src/policy.rs +97 −20
…
8 unchanged lines
9 9
use crate::cbor::{self, Value};
10 10
use crate::hash::{hash_object, KeyId, ObjectRef, ObjectType};
11 11
use crate::identity::{self, Domain, Identity, Signature};
12 -
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, ObjectError, FORMAT_VERSION};
12 +
use crate::objects::{
13 +
    array, bytes, check_version, field, map, oref, text, uint, ObjectError, FORMAT_VERSION,
14 +
};
13 15
use std::collections::BTreeMap;
14 16
15 17
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
…
68 unchanged lines
84 86
        Value::Array(keys.iter().map(|k| Value::Bytes(k.0.to_vec())).collect())
85 87
    }
86 88
    fn keys_from(v: &Value, what: &str) -> Result<Vec<KeyId>, ObjectError> {
87 -
        let mut ks = array(v, what)?.iter().map(|k| oref(k, what)).collect::<Result<Vec<_>, _>>()?;
89 +
        let mut ks = array(v, what)?
90 +
            .iter()
91 +
            .map(|k| oref(k, what))
92 +
            .collect::<Result<Vec<_>, _>>()?;
88 93
        ks.sort();
89 94
        ks.dedup();
90 95
        Ok(ks)
…
4 unchanged lines
95 100
            (0, Value::Uint(FORMAT_VERSION)),
96 101
            (1, Value::Bytes(self.repo.to_vec())),
97 102
            (2, Value::Uint(self.serial)),
98 -
            (3, Value::map([(0, Self::keys_value(&self.roots)), (1, Value::Uint(self.root_threshold as u64))])),
103 +
                3,
104 +
                Value::map([
105 +
                    (0, Self::keys_value(&self.roots)),
106 +
                    (1, Value::Uint(self.root_threshold as u64)),
107 +
                ]),
108 +
            ),
109 +
            (
99 110
            (
100 111
                4,
101 112
                Value::Array(
…
11 unchanged lines
113 124
                        .collect(),
114 125
                ),
115 126
            ),
116 -
            (5, Value::Array(self.identities.iter().map(|i| Value::Bytes(i.encode())).collect())),
127 +
            (
128 +
                5,
129 +
                Value::Array(
130 +
                    self.identities
131 +
                        .iter()
132 +
                        .map(|i| Value::Bytes(i.encode()))
133 +
                        .collect(),
134 +
                ),
135 +
            ),
117 136
            (6, Self::keys_value(&self.revoked)),
118 137
            (7, Value::Uint(self.expires)),
119 138
        ])
…
20 unchanged lines
140 159
                keys: Self::keys_from(field(rm, 1, "rule keys")?, "rule keys")?,
141 160
                threshold: uint(field(rm, 2, "threshold")?, "threshold")? as u32,
142 161
                require: Require::from_tag(uint(field(rm, 3, "require")?, "require")?)?,
143 -
                allow_conflicts: field(rm, 4, "allow_conflicts")?.as_bool().ok_or_else(|| ObjectError::Schema("allow_conflicts: expected bool".into()))?,
162 +
                allow_conflicts: field(rm, 4, "allow_conflicts")?
163 +
                    .as_bool()
164 +
                    .ok_or_else(|| ObjectError::Schema("allow_conflicts: expected bool".into()))?,
144 165
            });
145 166
        }
146 167
        let identities = array(field(m, 5, "identities")?, "identities")?
…
1 unchanged lines
148 169
            .map(|i| Identity::decode(bytes(i, "identity")?))
149 170
            .collect::<Result<Vec<_>, _>>()?;
150 171
        let p = Policy {
151 -
            repo: bytes(field(m, 1, "repo")?, "repo")?.try_into().map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?,
172 +
            repo: bytes(field(m, 1, "repo")?, "repo")?
173 +
                .try_into()
174 +
                .map_err(|_| ObjectError::Schema("repo: expected 16 bytes".into()))?,
152 175
            serial: uint(field(m, 2, "serial")?, "serial")?,
153 176
            roots: Self::keys_from(field(roots, 0, "root keys")?, "root keys")?,
154 177
            root_threshold: uint(field(roots, 1, "root threshold")?, "root threshold")? as u32,
…
3 unchanged lines
158 181
            expires: uint(field(m, 7, "expires")?, "expires")?,
159 182
        };
160 183
        if p.root_threshold == 0 || p.root_threshold as usize > p.roots.len() {
161 -
            return Err(ObjectError::Schema("root threshold must be between 1 and the number of root keys".into()));
184 +
            return Err(ObjectError::Schema(
185 +
                "root threshold must be between 1 and the number of root keys".into(),
186 +
            ));
162 187
        }
163 188
        for r in &p.rules {
164 189
            if r.threshold == 0 || r.threshold as usize > r.keys.len() {
165 -
                return Err(ObjectError::Schema(format!("rule {:?}: threshold must be between 1 and the number of keys", r.pattern)));
190 +
                return Err(ObjectError::Schema(format!(
191 +
                    "rule {:?}: threshold must be between 1 and the number of keys",
192 +
                    r.pattern
193 +
                )));
166 194
            }
167 195
        }
168 196
        Ok(p)
…
13 unchanged lines
182 210
183 211
    /// The first rule whose pattern matches `reference`.
184 212
    pub fn rule_for(&self, reference: &str) -> Option<&Rule> {
185 -
        self.rules.iter().find(|r| glob_matches(&r.pattern, reference))
213 +
        self.rules
214 +
            .iter()
215 +
            .find(|r| glob_matches(&r.pattern, reference))
186 216
    }
187 217
188 218
    /// Whether `key` may update `reference`: listed in the matching rule and not revoked.
189 219
    pub fn authorizes(&self, key: &KeyId, reference: &str) -> bool {
190 -
        !self.is_revoked(key) && self.rule_for(reference).is_some_and(|r| r.keys.contains(key))
220 +
        !self.is_revoked(key)
221 +
            && self
222 +
                .rule_for(reference)
223 +
                .is_some_and(|r| r.keys.contains(key))
191 224
    }
192 225
193 226
    /// Verify that `sigs` over this policy's hash meet `trusted`'s root
194 227
    /// threshold (`trusted` is the previous policy, or this one when
195 228
    /// bootstrapping). Returns the signing root keys.
196 -
    pub fn verify_signatures(&self, sigs: &[Signature], trusted: &Policy) -> Result<Vec<KeyId>, String> {
229 +
    pub fn verify_signatures(
230 +
        &self,
231 +
        sigs: &[Signature],
232 +
        trusted: &Policy,
233 +
    ) -> Result<Vec<KeyId>, String> {
197 234
        let target = self.hash();
198 235
        let mut signers = Vec::new();
199 236
        for sig in sigs {
200 237
            if !trusted.roots.contains(&sig.key) || trusted.is_revoked(&sig.key) {
201 238
                continue;
202 239
            }
203 -
            let Some(id) = trusted.identity(&sig.key).or_else(|| self.identity(&sig.key)) else { continue };
204 -
            if identity::verify(id, sig, Domain::Policy, &target).is_ok() && !signers.contains(&sig.key) {
240 +
            let Some(id) = trusted
241 +
                .identity(&sig.key)
242 +
                .or_else(|| self.identity(&sig.key))
243 +
            else {
244 +
                continue;
245 +
            };
246 +
            if identity::verify(id, sig, Domain::Policy, &target).is_ok()
247 +
                && !signers.contains(&sig.key)
248 +
            {
205 249
                signers.push(sig.key);
206 250
            }
207 251
        }
208 252
        if (signers.len() as u32) < trusted.root_threshold {
209 -
            return Err(format!("policy needs {} root signature(s), has {}", trusted.root_threshold, signers.len()));
253 +
            return Err(format!(
254 +
                "policy needs {} root signature(s), has {}",
255 +
                trusted.root_threshold,
256 +
                signers.len()
257 +
            ));
210 258
        }
211 259
        Ok(signers)
212 260
    }
…
25 unchanged lines
238 286
            serial: 1,
239 287
            roots: vec![key],
240 288
            root_threshold: 1,
241 -
            rules: vec![Rule { pattern: "*".into(), keys: vec![key], threshold: 1, require: Require::Any, allow_conflicts: true }],
289 +
            rules: vec![Rule {
290 +
                pattern: "*".into(),
291 +
                keys: vec![key],
292 +
                threshold: 1,
293 +
                require: Require::Any,
294 +
                allow_conflicts: true,
295 +
            }],
242 296
            identities: vec![root],
243 297
            revoked: vec![],
244 298
            expires,
…
2 unchanged lines
247 301
248 302
    /// Whether `key` appears anywhere in the policy (roots, rules, identities).
249 303
    pub fn mentions(&self, key: &KeyId) -> bool {
250 -
        self.roots.contains(key) || self.rules.iter().any(|r| r.keys.contains(key)) || self.identity(key).is_some()
304 +
        self.roots.contains(key)
305 +
            || self.rules.iter().any(|r| r.keys.contains(key))
306 +
            || self.identity(key).is_some()
251 307
    }
252 308
253 309
    /// Whether `key` is `rule_key` or one of its predecessors in the
…
20 unchanged lines
274 330
    /// not revoked.
275 331
    pub fn substitute_key(&mut self, old: &KeyId, new: Identity) {
276 332
        let nk = new.hash();
277 -
        for k in self.roots.iter_mut().chain(self.rules.iter_mut().flat_map(|r| r.keys.iter_mut())) {
333 +
        for k in self
334 +
            .roots
335 +
            .iter_mut()
336 +
            .chain(self.rules.iter_mut().flat_map(|r| r.keys.iter_mut()))
337 +
        {
278 338
            if k == old {
279 339
                *k = nk;
280 340
            }
…
21 unchanged lines
302 362
    use crate::identity::SecretKey;
303 363
304 364
    fn sk(n: u8) -> SecretKey {
305 -
        SecretKey { ed_seed: [n; 32], ml_seed: [n + 100; 32] }
365 +
        SecretKey {
366 +
            ed_seed: [n; 32],
367 +
            ml_seed: [n + 100; 32],
368 +
        }
306 369
    }
307 370
308 371
    #[test]
…
12 unchanged lines
321 384
        let root = sk(1).identity("Root", "r@x", 0);
322 385
        let dev = sk(2).identity("Dev", "d@x", 0);
323 386
        let mut p = Policy::single_root([9; 16], root.clone(), 10);
324 -
        p.rules.insert(0, Rule { pattern: "release/*".into(), keys: vec![root.hash()], threshold: 1, require: Require::Signed, allow_conflicts: false });
387 +
        p.rules.insert(
388 +
            0,
389 +
            Rule {
390 +
                pattern: "release/*".into(),
391 +
                keys: vec![root.hash()],
392 +
                threshold: 1,
393 +
                require: Require::Signed,
394 +
                allow_conflicts: false,
395 +
            },
396 +
        );
325 397
        p.rules[1].keys.push(dev.hash());
326 398
        p.identities.push(dev.clone());
327 399
        p.revoked.push(ObjectRef([7; 32]));
…
27 unchanged lines
355 427
        let s1 = r1.sign(Domain::Policy, &i1.hash(), &h);
356 428
        let s2 = r2.sign(Domain::Policy, &i2.hash(), &h);
357 429
        assert!(p.verify_signatures(std::slice::from_ref(&s1), &p).is_err());
358 -
        assert_eq!(p.verify_signatures(&[s1.clone(), s2.clone()], &p).unwrap().len(), 2);
430 +
        assert_eq!(
431 +
            p.verify_signatures(&[s1.clone(), s2.clone()], &p)
432 +
                .unwrap()
433 +
                .len(),
434 +
            2
435 +
        );
359 436
        // Duplicates do not count twice; a non-root signer does not count.
360 437
        assert!(p.verify_signatures(&[s1.clone(), s1.clone()], &p).is_err());
361 438
        let stranger = sk(3);
…
13 unchanged lines
modified crates/oxy-core/src/protocol.rs +353 −73
…
38 unchanged lines
39 39
        crate::cbor::encode(&wire::policy_value(self))
40 40
    }
41 41
    pub fn decode(b: &[u8]) -> Result<SignedPolicy> {
42 -
        wire::policy_from(&crate::cbor::decode(b).map_err(|e| RepoError::Object(ObjectError::from(e)))?)
42 +
        wire::policy_from(
43 +
            &crate::cbor::decode(b).map_err(|e| RepoError::Object(ObjectError::from(e)))?,
44 +
        )
43 45
    }
44 46
}
45 47
…
82 unchanged lines
128 130
    /// Push changes, bookmark updates, new tags, and optionally a newer
129 131
    /// policy. The policy is applied first; a result named `policy` reports
130 132
    /// it, and each tag reports as `tags/<name>`.
131 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>>;
133 +
    fn push(
134 +
        &self,
135 +
        bundle: Bundle,
136 +
        updates: Vec<RefUpdate>,
137 +
        policy: Option<SignedPolicy>,
138 +
        tags: Vec<TagRef>,
139 +
    ) -> Result<Vec<RefResult>>;
132 140
}
133 141
134 142
// ---------------------------------------------------------------- local
…
5 unchanged lines
140 148
141 149
impl LocalRemote {
142 150
    pub fn open(path: &Path) -> Result<LocalRemote> {
143 -
        Ok(LocalRemote { repo: Repo::open(path)? })
151 +
        Ok(LocalRemote {
152 +
            repo: Repo::open(path)?,
153 +
        })
144 154
    }
145 155
}
146 156
…
19 unchanged lines
166 176
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
167 177
        self.repo.shallow_changes(heads, depth)
168 178
    }
169 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> {
179 +
    fn push(
180 +
        &self,
181 +
        bundle: Bundle,
182 +
        updates: Vec<RefUpdate>,
183 +
        policy: Option<SignedPolicy>,
184 +
        tags: Vec<TagRef>,
185 +
    ) -> Result<Vec<RefResult>> {
170 186
        self.repo.receive_push(bundle, updates, policy, tags)
171 187
    }
172 188
}
…
8 unchanged lines
181 197
    /// PEM file with extra root certificates to trust for `https://` remotes
182 198
    /// (self-hosted servers); the system web roots are always trusted.
183 199
    pub ca: Option<PathBuf>,
200 +
    /// The local repository's id, sent as `Oxy-Repo-Id` so an unbound
201 +
    /// hosted repository can adopt it on the first push (spec §16.1).
202 +
    pub repo_id: Option<[u8; 16]>,
184 203
}
185 204
205 +
/// The request header carrying the client's repository id.
206 +
pub const REPO_ID_HEADER: &str = "Oxy-Repo-Id";
207 +
208 +
/// The id of a hosted repository that has not adopted one yet.
209 +
pub const UNBOUND_ID: [u8; 16] = [0; 16];
210 +
186 211
/// A remote served by `oxy-server` at `<base>/oxy/v1/...`.
187 212
pub struct HttpRemote {
188 213
    base: String,
189 214
    token: Option<String>,
215 +
    repo_id: Option<[u8; 16]>,
190 216
    agent: ureq::Agent,
191 217
}
192 218
…
2 unchanged lines
195 221
    let mut roots = rustls::RootCertStore::empty();
196 222
    roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
197 223
    if let Some(path) = ca {
198 -
        let pem = std::fs::read(path).map_err(|e| RepoError::Msg(format!("reading CA file {}: {e}", path.display())))?;
224 +
        let pem = std::fs::read(path)
225 +
            .map_err(|e| RepoError::Msg(format!("reading CA file {}: {e}", path.display())))?;
199 226
        let certs = rustls_pemfile::certs(&mut pem.as_slice())
200 227
            .collect::<std::result::Result<Vec<_>, _>>()
201 228
            .map_err(|e| RepoError::Msg(format!("parsing CA file {}: {e}", path.display())))?;
202 229
        if certs.is_empty() {
203 -
            return Err(RepoError::Msg(format!("CA file {} holds no certificates", path.display())));
230 +
            return Err(RepoError::Msg(format!(
231 +
                "CA file {} holds no certificates",
232 +
                path.display()
233 +
            )));
204 234
        }
205 235
        for c in certs {
206 -
            roots.add(c).map_err(|e| RepoError::Msg(format!("CA file {}: {e}", path.display())))?;
236 +
            roots
237 +
                .add(c)
238 +
                .map_err(|e| RepoError::Msg(format!("CA file {}: {e}", path.display())))?;
207 239
        }
208 240
    }
209 -
    let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider()))
210 -
        .with_safe_default_protocol_versions()
211 -
        .map_err(|e| RepoError::Msg(e.to_string()))?
212 -
        .with_root_certificates(roots)
213 -
        .with_no_client_auth();
241 +
    let cfg = rustls::ClientConfig::builder_with_provider(Arc::new(
242 +
        rustls::crypto::ring::default_provider(),
243 +
    ))
244 +
    .with_safe_default_protocol_versions()
245 +
    .map_err(|e| RepoError::Msg(e.to_string()))?
246 +
    .with_root_certificates(roots)
247 +
    .with_no_client_auth();
214 248
    Ok(Arc::new(cfg))
215 249
}
216 250
217 251
impl HttpRemote {
218 252
    pub fn new(base: &str, options: &RemoteOptions) -> Result<HttpRemote> {
219 -
        let agent = ureq::AgentBuilder::new().tls_config(tls_client_config(options.ca.as_deref())?).build();
220 -
        Ok(HttpRemote { base: base.trim_end_matches('/').to_string(), token: options.token.clone(), agent })
253 +
        let agent = ureq::AgentBuilder::new()
254 +
            .tls_config(tls_client_config(options.ca.as_deref())?)
255 +
            .build();
256 +
        Ok(HttpRemote {
257 +
            base: base.trim_end_matches('/').to_string(),
258 +
            token: options.token.clone(),
259 +
            repo_id: options.repo_id,
260 +
            agent,
261 +
        })
221 262
    }
222 263
223 264
    fn call(&self, post: bool, endpoint: &str, body: Option<&[u8]>) -> Result<Vec<u8>> {
224 265
        let url = format!("{}/oxy/v1/{endpoint}", self.base);
225 -
        let mut req = if post { self.agent.post(&url) } else { self.agent.get(&url) };
266 +
        let mut req = if post {
267 +
            self.agent.post(&url)
268 +
        } else {
269 +
            self.agent.get(&url)
270 +
        };
226 271
        req = req.set("Accept", CONTENT_TYPE);
227 272
        if let Some(t) = &self.token {
228 273
            req = req.set("Authorization", &format!("Bearer {t}"));
229 274
        }
275 +
        if let Some(id) = &self.repo_id {
276 +
            req = req.set(REPO_ID_HEADER, &hex::encode(id));
277 +
        }
230 278
        let resp = match body {
231 279
            Some(b) => req.set("Content-Type", CONTENT_TYPE).send_bytes(b),
232 280
            None => req.call(),
…
1 unchanged lines
234 282
        match resp {
235 283
            Ok(r) => {
236 284
                let mut out = Vec::new();
237 -
                r.into_reader().read_to_end(&mut out).map_err(|e| RepoError::Msg(format!("{url}: {e}")))?;
285 +
                r.into_reader()
286 +
                    .read_to_end(&mut out)
287 +
                    .map_err(|e| RepoError::Msg(format!("{url}: {e}")))?;
238 288
                Ok(out)
239 289
            }
240 290
            Err(ureq::Error::Status(code, r)) => {
241 291
                let text = r.into_string().unwrap_or_default();
242 -
                Err(RepoError::Msg(format!("{url}: HTTP {code}: {}", text.trim())))
292 +
                Err(RepoError::Msg(format!(
293 +
                    "{url}: HTTP {code}: {}",
294 +
                    text.trim()
295 +
                )))
243 296
            }
244 297
            Err(e) => Err(RepoError::Msg(format!("{url}: {e}"))),
245 298
        }
…
8 unchanged lines
254 307
        Ok(wire::decode_refs(&self.call(false, "refs", None)?)?.1)
255 308
    }
256 309
    fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
257 -
        wire::decode_hashes(&self.call(true, "negotiate", Some(&wire::encode_negotiate(have, want)))?)
310 +
        wire::decode_hashes(&self.call(
311 +
            true,
312 +
            "negotiate",
313 +
            Some(&wire::encode_negotiate(have, want)),
314 +
        )?)
258 315
    }
259 316
    fn fetch(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
260 -
        wire::decode_bundle(&self.call(true, "fetch", Some(&wire::encode_fetch(changes, max_blob)))?)
317 +
        wire::decode_bundle(&self.call(
318 +
            true,
319 +
            "fetch",
320 +
            Some(&wire::encode_fetch(changes, max_blob)),
321 +
        )?)
261 322
    }
262 323
    fn objects(&self, refs: &[ObjectRef]) -> Result<Vec<(ObjectType, Vec<u8>)>> {
263 -
        Ok(wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?.objects)
324 +
        Ok(
325 +
            wire::decode_bundle(&self.call(true, "objects", Some(&wire::encode_hashes(refs)))?)?
326 +
                .objects,
327 +
        )
264 328
    }
265 329
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle> {
266 330
        wire::decode_bundle(&self.call(true, "checkpoint", Some(&wire::encode_hashes(heads)))?)
267 331
    }
268 332
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
269 -
        wire::decode_hashes(&self.call(true, "shallow", Some(&wire::encode_shallow(heads, depth)))?)
333 +
        wire::decode_hashes(&self.call(
334 +
            true,
335 +
            "shallow",
336 +
            Some(&wire::encode_shallow(heads, depth)),
337 +
        )?)
270 338
    }
271 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> {
272 -
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref(), &tags)))?)
339 +
    fn push(
340 +
        &self,
341 +
        bundle: Bundle,
342 +
        updates: Vec<RefUpdate>,
343 +
        policy: Option<SignedPolicy>,
344 +
        tags: Vec<TagRef>,
345 +
    ) -> Result<Vec<RefResult>> {
346 +
        wire::decode_push_response(&self.call(
347 +
            true,
348 +
            "push",
349 +
            Some(&wire::encode_push(
350 +
                &bundle,
351 +
                &updates,
352 +
                policy.as_ref(),
353 +
                &tags,
354 +
            )),
355 +
        )?)
273 356
    }
274 357
}
275 358
…
22 unchanged lines
298 381
        Value::Array(sigs.iter().map(|s| s.to_value()).collect())
299 382
    }
300 383
    fn sigs_from(v: &Value) -> Result<Vec<Signature>> {
301 -
        Ok(o::array(v, "signatures")?.iter().map(Signature::from_value).collect::<std::result::Result<Vec<_>, _>>()?)
384 +
        Ok(o::array(v, "signatures")?
385 +
            .iter()
386 +
            .map(Signature::from_value)
387 +
            .collect::<std::result::Result<Vec<_>, _>>()?)
302 388
    }
303 389
    fn hashes_value(hs: &[ObjectRef]) -> Value {
304 390
        Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
305 391
    }
306 392
    fn hashes_from(v: &Value) -> Result<Vec<ObjectRef>> {
307 -
        Ok(o::array(v, "hashes")?.iter().map(|h| o::oref(h, "hash")).collect::<std::result::Result<Vec<_>, _>>()?)
393 +
        Ok(o::array(v, "hashes")?
394 +
            .iter()
395 +
            .map(|h| o::oref(h, "hash"))
396 +
            .collect::<std::result::Result<Vec<_>, _>>()?)
308 397
    }
309 398
    fn attest_value(sa: &SignedAttest) -> Value {
310 399
        Value::Array(vec![Value::Bytes(sa.attest.encode()), sigs_value(&sa.sigs)])
…
3 unchanged lines
314 403
        if a.len() != 2 {
315 404
            return Err(schema("signed attest: expected [attest, sigs]"));
316 405
        }
317 -
        Ok(SignedAttest { attest: Attest::decode(o::bytes(&a[0], "attest")?)?, sigs: sigs_from(&a[1])? })
406 +
        Ok(SignedAttest {
407 +
            attest: Attest::decode(o::bytes(&a[0], "attest")?)?,
408 +
            sigs: sigs_from(&a[1])?,
409 +
        })
318 410
    }
319 411
    fn opt_attest_value(sa: &Option<SignedAttest>) -> Value {
320 412
        sa.as_ref().map(attest_value).unwrap_or(Value::Null)
321 413
    }
322 414
    fn opt_attest_from(v: &Value) -> Result<Option<SignedAttest>> {
323 -
        if v.is_null() { Ok(None) } else { attest_from(v).map(Some) }
415 +
        if v.is_null() {
416 +
            Ok(None)
417 +
        } else {
418 +
            attest_from(v).map(Some)
419 +
        }
324 420
    }
325 421
    /// `[policy bytes, [signature]]`: also the format of a policy proposal
326 422
    /// file exchanged between root holders (spec §14.5).
…
5 unchanged lines
332 428
        if a.len() != 2 {
333 429
            return Err(schema("signed policy: expected [policy, sigs]"));
334 430
        }
335 -
        Ok(SignedPolicy { policy: Policy::decode(o::bytes(&a[0], "policy")?)?, sigs: sigs_from(&a[1])? })
431 +
        Ok(SignedPolicy {
432 +
            policy: Policy::decode(o::bytes(&a[0], "policy")?)?,
433 +
            sigs: sigs_from(&a[1])?,
434 +
        })
336 435
    }
337 436
    fn opt_policy_value(sp: Option<&SignedPolicy>) -> Value {
338 437
        sp.map(policy_value).unwrap_or(Value::Null)
339 438
    }
340 439
    fn opt_policy_from(v: &Value) -> Result<Option<SignedPolicy>> {
341 -
        if v.is_null() { Ok(None) } else { policy_from(v).map(Some) }
440 +
        if v.is_null() {
441 +
            Ok(None)
442 +
        } else {
443 +
            policy_from(v).map(Some)
444 +
        }
342 445
    }
343 446
    fn map<'a>(v: &'a Value, what: &str) -> Result<&'a o::Map> {
344 447
        Ok(o::map(v, what)?)
…
6 unchanged lines
351 454
        Value::map([
352 455
            (
353 456
                0,
354 -
                Value::Array(b.changes.iter().map(|(c, s)| Value::Array(vec![Value::Bytes(c.encode()), sigs_value(s)])).collect()),
457 +
                Value::Array(
458 +
                    b.changes
459 +
                        .iter()
460 +
                        .map(|(c, s)| Value::Array(vec![Value::Bytes(c.encode()), sigs_value(s)]))
461 +
                        .collect(),
462 +
                ),
355 463
            ),
356 -
            (1, Value::Array(b.attests.iter().map(attest_value).collect())),
357 464
            (
465 +
                1,
466 +
                Value::Array(b.attests.iter().map(attest_value).collect()),
467 +
            ),
468 +
            (
358 469
                2,
359 470
                Value::Array(
360 -
                    b.objects.iter().map(|(t, body)| Value::Array(vec![Value::Text(t.name().into()), Value::Bytes(body.clone())])).collect(),
471 +
                    b.objects
472 +
                        .iter()
473 +
                        .map(|(t, body)| {
474 +
                            Value::Array(vec![
475 +
                                Value::Text(t.name().into()),
476 +
                                Value::Bytes(body.clone()),
477 +
                            ])
478 +
                        })
479 +
                        .collect(),
361 480
                ),
362 481
            ),
363 -
            (3, Value::Array(b.policies.iter().map(policy_value).collect())),
482 +
            (
483 +
                3,
484 +
                Value::Array(b.policies.iter().map(policy_value).collect()),
485 +
            ),
364 486
        ])
365 487
    }
366 488
…
5 unchanged lines
372 494
            if pair.len() != 2 {
373 495
                return Err(schema("change entry: expected [change, sigs]"));
374 496
            }
375 -
            b.changes.push((Change::decode(o::bytes(&pair[0], "change")?)?, sigs_from(&pair[1])?));
497 +
            b.changes.push((
498 +
                Change::decode(o::bytes(&pair[0], "change")?)?,
499 +
                sigs_from(&pair[1])?,
500 +
            ));
376 501
        }
377 502
        for a in o::array(field(m, 1, "attests")?, "attests")? {
378 503
            b.attests.push(attest_from(a)?);
…
3 unchanged lines
382 507
            if pair.len() != 2 {
383 508
                return Err(schema("object: expected [type, body]"));
384 509
            }
385 -
            let ty = ObjectType::from_name(o::text(&pair[0], "object type")?).ok_or_else(|| schema("unknown object type"))?;
386 -
            b.objects.push((ty, o::bytes(&pair[1], "object body")?.to_vec()));
510 +
            let ty = ObjectType::from_name(o::text(&pair[0], "object type")?)
511 +
                .ok_or_else(|| schema("unknown object type"))?;
512 +
            b.objects
513 +
                .push((ty, o::bytes(&pair[1], "object body")?.to_vec()));
387 514
        }
388 515
        if let Some(ps) = m.get(&3) {
389 516
            for p in o::array(ps, "policies")? {
…
21 unchanged lines
411 538
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
412 539
        let m = map(&v, "capabilities")?;
413 540
        let versions = o::array(field(m, 0, "versions")?, "versions")?;
414 -
        if !versions.iter().any(|x| x.as_uint() == Some(PROTOCOL_VERSION)) {
415 -
            return Err(RepoError::Msg("remote does not speak protocol version 1".into()));
541 +
        if !versions
542 +
            .iter()
543 +
            .any(|x| x.as_uint() == Some(PROTOCOL_VERSION))
544 +
        {
545 +
            return Err(RepoError::Msg(
546 +
                "remote does not speak protocol version 1".into(),
547 +
            ));
416 548
        }
417 -
        o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes"))
549 +
        o::bytes(field(m, 1, "repo id")?, "repo id")?
550 +
            .try_into()
551 +
            .map_err(|_| schema("repo id: expected 16 bytes"))
418 552
    }
419 553
420 554
    fn tags_value(tags: &[TagRef]) -> Value {
…
46 unchanged lines
467 601
            (1, Value::Bytes(id.to_vec())),
468 602
            (2, opt_policy_value(resp.policy.as_ref())),
469 603
            (3, tags_value(&resp.tags)),
470 -
            (4, Value::Array(resp.history.iter().map(policy_value).collect())),
604 +
            (
605 +
                4,
606 +
                Value::Array(resp.history.iter().map(policy_value).collect()),
607 +
            ),
471 608
        ]))
472 609
    }
473 610
    pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> {
474 611
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
475 612
        let m = map(&v, "refs")?;
476 -
        let id: [u8; 16] = o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes"))?;
613 +
        let id: [u8; 16] = o::bytes(field(m, 1, "repo id")?, "repo id")?
614 +
            .try_into()
615 +
            .map_err(|_| schema("repo id: expected 16 bytes"))?;
477 616
        let mut refs = Vec::new();
478 617
        for r in o::array(field(m, 0, "refs")?, "refs")? {
479 618
            let rm = map(r, "ref")?;
…
3 unchanged lines
483 622
                state: o::oref(field(rm, 1, "state")?, "state")?,
484 623
                heads: hashes_from(field(rm, 2, "heads")?)?,
485 624
                attestation: opt_attest_from(field(rm, 3, "attestation")?)?,
486 -
                last_serial: if ls.is_null() { None } else { Some(o::uint(ls, "last serial")?) },
625 +
                last_serial: if ls.is_null() {
626 +
                    None
627 +
                } else {
628 +
                    Some(o::uint(ls, "last serial")?)
629 +
                },
487 630
            });
488 631
        }
489 632
        let policy = match m.get(&2) {
…
10 unchanged lines
500 643
                history.push(policy_from(p)?);
501 644
            }
502 645
        }
503 -
        Ok((id, RefsResponse { refs, policy, tags, history }))
646 +
        Ok((
647 +
            id,
648 +
            RefsResponse {
649 +
                refs,
650 +
                policy,
651 +
                tags,
652 +
                history,
653 +
            },
654 +
        ))
504 655
    }
505 656
506 657
    pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> {
507 -
        cbor::encode(&Value::map([(0, hashes_value(have)), (1, hashes_value(want))]))
658 +
        cbor::encode(&Value::map([
659 +
            (0, hashes_value(have)),
660 +
            (1, hashes_value(want)),
661 +
        ]))
508 662
    }
509 663
    pub fn decode_negotiate(bytes: &[u8]) -> Result<(Vec<ChangeHash>, Vec<ChangeHash>)> {
510 664
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
511 665
        let m = map(&v, "negotiate")?;
512 -
        Ok((hashes_from(field(m, 0, "have")?)?, hashes_from(field(m, 1, "want")?)?))
666 +
        Ok((
667 +
            hashes_from(field(m, 0, "have")?)?,
668 +
            hashes_from(field(m, 1, "want")?)?,
669 +
        ))
513 670
    }
514 671
515 672
    /// `fetch` request: `{0: [changes], 1: max blob size or null}`.
516 673
    pub fn encode_fetch(hs: &[ChangeHash], max_blob: Option<u64>) -> Vec<u8> {
517 -
        cbor::encode(&Value::map([(0, hashes_value(hs)), (1, max_blob.map(Value::Uint).unwrap_or(Value::Null))]))
674 +
        cbor::encode(&Value::map([
675 +
            (0, hashes_value(hs)),
676 +
            (1, max_blob.map(Value::Uint).unwrap_or(Value::Null)),
677 +
        ]))
518 678
    }
519 679
    pub fn decode_fetch(bytes: &[u8]) -> Result<(Vec<ChangeHash>, Option<u64>)> {
520 680
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
…
7 unchanged lines
528 688
529 689
    /// `shallow` request: `{0: [heads], 1: depth}`.
530 690
    pub fn encode_shallow(hs: &[ChangeHash], depth: u32) -> Vec<u8> {
531 -
        cbor::encode(&Value::map([(0, hashes_value(hs)), (1, Value::Uint(depth as u64))]))
691 +
        cbor::encode(&Value::map([
692 +
            (0, hashes_value(hs)),
693 +
            (1, Value::Uint(depth as u64)),
694 +
        ]))
532 695
    }
533 696
    pub fn decode_shallow(bytes: &[u8]) -> Result<(Vec<ChangeHash>, u32)> {
534 697
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
535 698
        let m = map(&v, "shallow")?;
536 -
        Ok((hashes_from(field(m, 0, "heads")?)?, o::u32_(field(m, 1, "depth")?, "depth")?))
699 +
        Ok((
700 +
            hashes_from(field(m, 0, "heads")?)?,
701 +
            o::u32_(field(m, 1, "depth")?, "depth")?,
702 +
        ))
537 703
    }
538 704
539 705
    pub fn encode_hashes(hs: &[ChangeHash]) -> Vec<u8> {
…
4 unchanged lines
544 710
        hashes_from(field(map(&v, "hashes")?, 0, "hashes")?)
545 711
    }
546 712
547 -
    pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>, tags: &[TagRef]) -> Vec<u8> {
713 +
    pub fn encode_push(
714 +
        bundle: &Bundle,
715 +
        updates: &[RefUpdate],
716 +
        policy: Option<&SignedPolicy>,
717 +
        tags: &[TagRef],
718 +
    ) -> Vec<u8> {
548 719
        cbor::encode(&Value::map([
549 720
            (0, bundle_value(bundle)),
550 721
            (2, opt_policy_value(policy)),
…
31 unchanged lines
582 753
                name: o::text(field(um, 0, "name")?, "name")?.to_owned(),
583 754
                old_state: o::oref(field(um, 1, "old_state")?, "old_state")?,
584 755
                new_state: o::oref(field(um, 2, "new_state")?, "new_state")?,
585 -
                force: field(um, 3, "force")?.as_bool().ok_or_else(|| schema("force: expected bool"))?,
756 +
                force: field(um, 3, "force")?
757 +
                    .as_bool()
758 +
                    .ok_or_else(|| schema("force: expected bool"))?,
586 759
                attestation: opt_attest_from(field(um, 4, "attestation")?)?,
587 760
            });
588 761
        }
…
14 unchanged lines
603 776
            Value::Array(
604 777
                results
605 778
                    .iter()
606 -
                    .map(|r| Value::map([(0, Value::Text(r.name.clone())), (1, Value::Bool(r.ok)), (2, Value::Text(r.reason.clone()))]))
779 +
                    .map(|r| {
780 +
                        Value::map([
781 +
                            (0, Value::Text(r.name.clone())),
782 +
                            (1, Value::Bool(r.ok)),
783 +
                            (2, Value::Text(r.reason.clone())),
784 +
                        ])
785 +
                    })
607 786
                    .collect(),
608 787
            ),
609 788
        )]))
…
6 unchanged lines
616 795
            let rm = map(r, "result")?;
617 796
            out.push(RefResult {
618 797
                name: o::text(field(rm, 0, "name")?, "name")?.to_owned(),
619 -
                ok: field(rm, 1, "ok")?.as_bool().ok_or_else(|| schema("ok: expected bool"))?,
798 +
                ok: field(rm, 1, "ok")?
799 +
                    .as_bool()
800 +
                    .ok_or_else(|| schema("ok: expected bool"))?,
620 801
                reason: o::text(field(rm, 2, "reason")?, "reason")?.to_owned(),
621 802
            });
622 803
        }
…
10 unchanged lines
633 814
    #[test]
634 815
    fn wire_round_trips() {
635 816
        let c = crate::objects::tests::sample_change();
636 -
        let sk = crate::identity::SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
817 +
        let sk = crate::identity::SecretKey {
818 +
            ed_seed: [1; 32],
819 +
            ml_seed: [2; 32],
820 +
        };
637 821
        let id = sk.identity("a", "b", 0);
638 822
        let key = id.hash();
639 823
        let sig = sk.sign(crate::identity::Domain::Change, &key, &c.hash());
…
3 unchanged lines
643 827
        let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash());
644 828
        let bundle = Bundle {
645 829
            changes: vec![(c.clone(), vec![sig.clone()])],
646 -
            attests: vec![SignedAttest { attest: seal.clone(), sigs: vec![seal_sig] }],
647 -
            objects: vec![(ObjectType::Identity, id.encode()), (ObjectType::Chunk, b"xyz".to_vec())],
648 -
            policies: vec![SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }],
830 +
            attests: vec![SignedAttest {
831 +
                attest: seal.clone(),
832 +
                sigs: vec![seal_sig],
833 +
            }],
834 +
            objects: vec![
835 +
                (ObjectType::Identity, id.encode()),
836 +
                (ObjectType::Chunk, b"xyz".to_vec()),
837 +
            ],
838 +
            policies: vec![SignedPolicy {
839 +
                policy: policy.clone(),
840 +
                sigs: vec![psig.clone()],
841 +
            }],
649 842
        };
650 843
        let d = wire::decode_bundle(&wire::encode_bundle(&bundle)).unwrap();
651 844
        assert_eq!(d.changes, bundle.changes);
…
1 unchanged lines
653 846
        assert_eq!(d.objects, bundle.objects);
654 847
        assert_eq!(d.policies, bundle.policies);
655 848
656 -
        let att = Attest { repo: [3; 16], hlc: Hlc::default(), body: AttestBody::Ref { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), serial: 4 } };
849 +
        let att = Attest {
850 +
            repo: [3; 16],
851 +
            hlc: Hlc::default(),
852 +
            body: AttestBody::Ref {
853 +
                name: "main".into(),
854 +
                old_state: ObjectRef([1; 32]),
855 +
                new_state: ObjectRef([2; 32]),
856 +
                serial: 4,
857 +
            },
858 +
        };
657 859
        let refs = vec![
658 -
            RemoteRef { name: "main".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: Some(SignedAttest { attest: att.clone(), sigs: vec![sig.clone()] }), last_serial: Some(4) },
659 -
            RemoteRef { name: "dev".into(), state: ObjectRef([5; 32]), heads: vec![], attestation: None, last_serial: None },
860 +
            RemoteRef {
861 +
                name: "main".into(),
862 +
                state: ObjectRef([2; 32]),
863 +
                heads: vec![c.hash()],
864 +
                attestation: Some(SignedAttest {
865 +
                    attest: att.clone(),
866 +
                    sigs: vec![sig.clone()],
867 +
                }),
868 +
                last_serial: Some(4),
869 +
            },
870 +
            RemoteRef {
871 +
                name: "dev".into(),
872 +
                state: ObjectRef([5; 32]),
873 +
                heads: vec![],
874 +
                attestation: None,
875 +
                last_serial: None,
876 +
            },
660 877
        ];
661 -
        let tag = TagRef { name: "v1".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: None };
662 -
        let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }), tags: vec![tag.clone()], history: vec![SignedPolicy { policy: policy.clone(), sigs: vec![] }] };
878 +
        let tag = TagRef {
879 +
            name: "v1".into(),
880 +
            state: ObjectRef([2; 32]),
881 +
            heads: vec![c.hash()],
882 +
            attestation: None,
883 +
        };
884 +
        let resp = RefsResponse {
885 +
            refs,
886 +
            policy: Some(SignedPolicy {
887 +
                policy: policy.clone(),
888 +
                sigs: vec![psig.clone()],
889 +
            }),
890 +
            tags: vec![tag.clone()],
891 +
            history: vec![SignedPolicy {
892 +
                policy: policy.clone(),
893 +
                sigs: vec![],
894 +
            }],
895 +
        };
663 896
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap();
664 897
        assert_eq!(id_back, [3; 16]);
665 898
        assert_eq!(back.refs.len(), 2);
…
3 unchanged lines
669 902
        assert_eq!(back.history, resp.history);
670 903
        assert_eq!(back.tags, vec![tag.clone()]);
671 904
672 -
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap();
905 +
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(
906 +
            &[ObjectRef([1; 32])],
907 +
            &[ObjectRef([2; 32])],
908 +
        ))
909 +
        .unwrap();
673 910
        assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])]));
674 -
        assert_eq!(wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(), vec![c.hash()]);
675 -
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(), (vec![c.hash()], Some(4096)));
676 -
        assert_eq!(wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(), (vec![], None));
677 -
        assert_eq!(wire::decode_shallow(&wire::encode_shallow(&[c.hash()], 3)).unwrap(), (vec![c.hash()], 3));
911 +
        assert_eq!(
912 +
            wire::decode_hashes(&wire::encode_hashes(&[c.hash()])).unwrap(),
913 +
            vec![c.hash()]
914 +
        );
915 +
        assert_eq!(
916 +
            wire::decode_fetch(&wire::encode_fetch(&[c.hash()], Some(4096))).unwrap(),
917 +
            (vec![c.hash()], Some(4096))
918 +
        );
919 +
        assert_eq!(
920 +
            wire::decode_fetch(&wire::encode_fetch(&[], None)).unwrap(),
921 +
            (vec![], None)
922 +
        );
923 +
        assert_eq!(
924 +
            wire::decode_shallow(&wire::encode_shallow(&[c.hash()], 3)).unwrap(),
925 +
            (vec![c.hash()], 3)
926 +
        );
678 927
        // An old-style fetch request without the filter field still decodes.
679 -
        assert_eq!(wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(), (vec![c.hash()], None));
928 +
        assert_eq!(
929 +
            wire::decode_fetch(&wire::encode_hashes(&[c.hash()])).unwrap(),
930 +
            (vec![c.hash()], None)
931 +
        );
680 932
681 -
        let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) };
682 -
        let sp = SignedPolicy { policy, sigs: vec![psig] };
683 -
        let (b2, ups, pol, tags) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp), std::slice::from_ref(&tag))).unwrap();
933 +
        let up = RefUpdate {
934 +
            name: "main".into(),
935 +
            old_state: ObjectRef([1; 32]),
936 +
            new_state: ObjectRef([2; 32]),
937 +
            force: true,
938 +
            attestation: Some(SignedAttest {
939 +
                attest: att,
940 +
                sigs: vec![sig],
941 +
            }),
942 +
        };
943 +
        let sp = SignedPolicy {
944 +
            policy,
945 +
            sigs: vec![psig],
946 +
        };
947 +
        let (b2, ups, pol, tags) = wire::decode_push(&wire::encode_push(
948 +
            &bundle,
949 +
            std::slice::from_ref(&up),
950 +
            Some(&sp),
951 +
            std::slice::from_ref(&tag),
952 +
        ))
953 +
        .unwrap();
684 954
        assert_eq!(pol, Some(sp));
685 955
        assert_eq!(tags, vec![tag]);
686 956
        assert_eq!(b2.changes.len(), 1);
687 957
        assert_eq!(ups[0].name, "main");
688 958
        assert!(ups[0].force);
689 959
        assert_eq!(ups[0].attestation, up.attestation);
690 -
        let res = vec![RefResult { name: "main".into(), ok: false, reason: "stale".into() }];
691 -
        assert_eq!(wire::decode_push_response(&wire::encode_push_response(&res)).unwrap(), res);
692 -
        assert_eq!(wire::decode_capabilities(&wire::encode_capabilities([7; 16])).unwrap(), [7; 16]);
960 +
        let res = vec![RefResult {
961 +
            name: "main".into(),
962 +
            ok: false,
963 +
            reason: "stale".into(),
964 +
        }];
965 +
        assert_eq!(
966 +
            wire::decode_push_response(&wire::encode_push_response(&res)).unwrap(),
967 +
            res
968 +
        );
969 +
        assert_eq!(
970 +
            wire::decode_capabilities(&wire::encode_capabilities([7; 16])).unwrap(),
971 +
            [7; 16]
972 +
        );
693 973
    }
694 974
}
modified crates/oxy-core/src/repo/bookmarks.rs +68 −15
…
37 unchanged lines
38 38
39 39
impl Repo {
40 40
    fn workspace_bookmark_path(&self) -> PathBuf {
41 -
        self.oxy.join("workspaces").join(self.workspace.to_hex()).join("bookmark")
41 +
        self.oxy
42 +
            .join("workspaces")
43 +
            .join(self.workspace.to_hex())
44 +
            .join("bookmark")
42 45
    }
43 46
44 47
    /// Every bookmark with its heads and remote-tracking difference.
…
5 unchanged lines
50 53
            let (ahead, behind) = match view.remotes.get("origin").and_then(|b| b.get(name)) {
51 54
                Some(rs) => {
52 55
                    let remote_heads = self.store.get_state(rs)?.heads;
53 -
                    (self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(), self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok())
56 +
                    (
57 +
                        self.negotiate(&remote_heads, &heads).map(|v| v.len()).ok(),
58 +
                        self.negotiate(&heads, &remote_heads).map(|v| v.len()).ok(),
59 +
                    )
54 60
                }
55 61
                None => (None, None),
56 62
            };
57 -
            out.push(BookmarkInfo { name: name.clone(), heads, current: *name == self.config.bookmark, ahead, behind });
63 +
            out.push(BookmarkInfo {
64 +
                name: name.clone(),
65 +
                heads,
66 +
                current: *name == self.config.bookmark,
67 +
                ahead,
68 +
                behind,
69 +
            });
58 70
        }
59 71
        Ok(out)
60 72
    }
61 73
62 74
    /// Create `name` at `heads` (default: the working copy's base heads).
63 -
    pub fn bookmark_create(&self, name: &str, heads: Option<&[ChangeHash]>) -> Result<Vec<ChangeHash>> {
75 +
    pub fn bookmark_create(
76 +
        &self,
77 +
        name: &str,
78 +
        heads: Option<&[ChangeHash]>,
79 +
    ) -> Result<Vec<ChangeHash>> {
64 80
        if !valid_reference_name(name) {
65 81
            return fail(format!("{name:?} is not a valid bookmark name"));
66 82
        }
…
9 unchanged lines
76 92
            }
77 93
        };
78 94
        let mut view = op.view.clone();
79 -
        view.bookmarks.insert(name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?);
95 +
        view.bookmarks.insert(
96 +
            name.to_string(),
97 +
            self.store.put_state(&StateObject::new(heads.clone()))?,
98 +
        );
80 99
        self.record_op(&[op_ref], &format!("oxy bookmark create {name}"), view)?;
81 100
        Ok(heads)
82 101
    }
…
2 unchanged lines
85 104
    pub fn bookmark_set(&self, name: &str, heads: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
86 105
        let (op_ref, op) = self.head_op()?;
87 106
        if !op.view.bookmarks.contains_key(name) {
88 -
            return fail(format!("no bookmark {name}; create it with `oxy bookmark create`"));
107 +
            return fail(format!(
108 +
                "no bookmark {name}; create it with `oxy bookmark create`"
109 +
            ));
89 110
        }
90 111
        let heads = self.load_state(heads)?.maximal(heads);
91 112
        let mut view = op.view.clone();
92 -
        view.bookmarks.insert(name.to_string(), self.store.put_state(&StateObject::new(heads.clone()))?);
113 +
        view.bookmarks.insert(
114 +
            name.to_string(),
115 +
            self.store.put_state(&StateObject::new(heads.clone()))?,
116 +
        );
93 117
        self.record_op(&[op_ref], &format!("oxy bookmark set {name}"), view)?;
94 118
        if name == self.config.bookmark {
95 119
            self.drop_manifest();
…
4 unchanged lines
100 124
    /// Remove a bookmark; its changes stay until unreachable (spec §21).
101 125
    pub fn bookmark_delete(&self, name: &str) -> Result<()> {
102 126
        if name == self.config.bookmark {
103 -
            return fail(format!("{name} is this workspace's current bookmark; switch to another first"));
127 +
            return fail(format!(
128 +
                "{name} is this workspace's current bookmark; switch to another first"
129 +
            ));
104 130
        }
105 131
        for w in self.workspaces()? {
106 132
            if !w.current && self.workspace_bookmark_of(&w.id).as_deref() == Some(name) {
107 -
                return fail(format!("{name} is the current bookmark of workspace {} at {}", w.id.short(8), w.path.display()));
133 +
                return fail(format!(
134 +
                    "{name} is the current bookmark of workspace {} at {}",
135 +
                    w.id.short(8),
136 +
                    w.path.display()
137 +
                ));
108 138
            }
109 139
        }
110 140
        let (op_ref, op) = self.head_op()?;
…
6 unchanged lines
117 147
    }
118 148
119 149
    fn workspace_bookmark_of(&self, id: &WorkspaceId) -> Option<String> {
120 -
        fs::read_to_string(self.oxy.join("workspaces").join(id.to_hex()).join("bookmark")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
150 +
        fs::read_to_string(
151 +
            self.oxy
152 +
                .join("workspaces")
153 +
                .join(id.to_hex())
154 +
                .join("bookmark"),
155 +
        )
156 +
        .ok()
157 +
        .map(|s| s.trim().to_string())
158 +
        .filter(|s| !s.is_empty())
121 159
    }
122 160
123 161
    /// Make `name` this workspace's current bookmark and check it out.
124 162
    pub fn bookmark_switch(&mut self, name: &str, discard: bool) -> Result<CheckoutReport> {
125 163
        let view = self.view()?;
126 -
        let Some(st) = view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")) };
164 +
        let Some(st) = view.bookmarks.get(name) else {
165 +
            return fail(format!("no bookmark {name}"));
166 +
        };
127 167
        let heads = self.store.get_state(st)?.heads;
128 168
        let report = self.checkout(&heads, discard, &format!("oxy bookmark switch {name}"))?;
129 169
        fs::create_dir_all(self.workspace_bookmark_path().parent().unwrap())?;
…
17 unchanged lines
147 187
        if name == current {
148 188
            return fail(format!("{name} is the current bookmark"));
149 189
        }
150 -
        let Some(st) = op.view.bookmarks.get(name) else { return fail(format!("no bookmark {name}")) };
190 +
        let Some(st) = op.view.bookmarks.get(name) else {
191 +
            return fail(format!("no bookmark {name}"));
192 +
        };
151 193
        let other = self.store.get_state(st)?.heads;
152 194
        let local = self.heads_of(&op.view, &current)?;
153 195
        let mut all = local.clone();
…
3 unchanged lines
157 199
        let mut sorted_local = local.clone();
158 200
        sorted_local.sort();
159 201
        if heads == sorted_local {
160 -
            return Ok(MergeReport { heads, changed: false, checked_out: false });
202 +
            return Ok(MergeReport {
203 +
                heads,
204 +
                changed: false,
205 +
                checked_out: false,
206 +
            });
161 207
        }
162 208
        let mut view = op.view.clone();
163 -
        view.bookmarks.insert(current, self.store.put_state(&StateObject::new(heads.clone()))?);
209 +
        view.bookmarks.insert(
210 +
            current,
211 +
            self.store.put_state(&StateObject::new(heads.clone()))?,
212 +
        );
164 213
        self.record_op(&[op_ref], &format!("oxy merge {name}"), view)?;
165 214
        let snap = self.snapshot("oxy merge")?;
166 215
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
…
4 unchanged lines
171 220
            self.checkout(&heads, false, "oxy merge (checkout)")?;
172 221
            checked_out = true;
173 222
        }
174 -
        Ok(MergeReport { heads, changed: true, checked_out })
223 +
        Ok(MergeReport {
224 +
            heads,
225 +
            changed: true,
226 +
            checked_out,
227 +
        })
175 228
    }
176 229
}
modified crates/oxy-core/src/repo/checkout.rs +47 −11
…
14 unchanged lines
15 15
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
16 16
    /// the working directory is rewritten to that state and a fresh
17 17
    /// working-copy change is started on it. The bookmark does not move.
18 -
    pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> {
18 +
    pub fn checkout(
19 +
        &self,
20 +
        heads: &[ChangeHash],
21 +
        discard: bool,
22 +
        command: &str,
23 +
    ) -> Result<CheckoutReport> {
19 24
        let snap = self.snapshot(command)?;
20 25
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
21 26
            return fail("the working copy has changes; commit them first or pass --discard");
…
11 unchanged lines
33 38
        for (path, entry) in &current {
34 39
            if !target.files.contains_key(path) {
35 40
                if entry.kind == NodeKind::Subrepo {
36 -
                    report.notes.push(format!("{path}: nested repository left in place"));
41 +
                    report
42 +
                        .notes
43 +
                        .push(format!("{path}: nested repository left in place"));
37 44
                    continue;
38 45
                }
39 46
                let full = self.safe_path(path)?;
…
13 unchanged lines
53 60
                FileContent::Dir => continue,
54 61
                FileContent::Text { bytes, .. } => {
55 62
                    let a = attrs.lookup(path);
56 -
                    let on_disk: std::borrow::Cow<[u8]> = if a.eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
57 -
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == *on_disk && c.exec == f.mode.exec);
63 +
                    let on_disk: std::borrow::Cow<[u8]> =
64 +
                        if a.eol == Some(crate::attributes::Eol::Crlf) {
65 +
                            crate::attributes::to_crlf(bytes).into()
66 +
                        } else {
67 +
                            bytes.as_slice().into()
68 +
                        };
69 +
                    let same = current.get(path).is_some_and(|c| {
70 +
                        c.kind == NodeKind::File && c.content == *on_disk && c.exec == f.mode.exec
71 +
                    });
58 72
                    if !same {
59 73
                        self.write_file(path, &on_disk, f.mode.exec)?;
60 74
                        report.written += 1;
…
17 unchanged lines
78 92
                        continue;
79 93
                    }
80 94
                    if f.kind == NodeKind::Symlink {
81 -
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
95 +
                        let same = current
96 +
                            .get(path)
97 +
                            .is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
82 98
                        if !same {
83 99
                            self.write_symlink(path, &bytes)?;
84 100
                            report.written += 1;
85 101
                        }
86 102
                    } else {
87 -
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec);
103 +
                        let same = current.get(path).is_some_and(|c| {
104 +
                            c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec
105 +
                        });
88 106
                        if !same {
89 107
                            self.write_file(path, &bytes, f.mode.exec)?;
90 108
                            report.written += 1;
…
4 unchanged lines
95 113
                    }
96 114
                    if blobs.len() > 1 {
97 115
                        for (i, b) in blobs.iter().enumerate().skip(1) {
98 -
                            self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?;
116 +
                            self.write_file(
117 +
                                &format!("{path}.oxy-conflict-{}", i + 1),
118 +
                                &self.read_blob(b)?,
119 +
                                f.mode.exec,
120 +
                            )?;
99 121
                        }
100 122
                    }
101 123
                }
…
2 unchanged lines
104 126
        // Prune directories left empty, deepest first.
105 127
        for d in dirs_to_prune.iter().rev() {
106 128
            let mut d = d.clone();
107 -
            while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) {
129 +
            while d != self.root
130 +
                && fs::read_dir(&d)
131 +
                    .map(|mut i| i.next().is_none())
132 +
                    .unwrap_or(false)
133 +
            {
108 134
                fs::remove_dir(&d)?;
109 135
                let Some(p) = d.parent() else { break };
110 136
                d = p.to_path_buf();
…
11 unchanged lines
122 148
            .files
123 149
            .iter()
124 150
            .filter_map(|(p, f)| match &f.content {
125 -
                FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => Some((p.clone(), blobs[0])),
151 +
                FileContent::Opaque { blobs } if f.kind == NodeKind::File && blobs.len() == 1 => {
152 +
                    Some((p.clone(), blobs[0]))
153 +
                }
126 154
                _ => None,
127 155
            })
128 156
            .collect();
…
1 unchanged lines
130 158
        self.write_manifest_entries(report.conflicted, false, &entries, &blobs)?;
131 159
        let _ = self.run_hook(
132 160
            "post-checkout",
133 -
            &format!("{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}", json_array(heads.iter().map(|h| json_str(&h.to_hex()))), report.written, report.removed),
161 +
            &format!(
162 +
                "{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}",
163 +
                json_array(heads.iter().map(|h| json_str(&h.to_hex()))),
164 +
                report.written,
165 +
                report.removed
166 +
            ),
134 167
        );
135 168
        Ok(report)
136 169
    }
…
14 unchanged lines
151 184
                full.push(comp);
152 185
                if let Ok(m) = fs::symlink_metadata(&full) {
153 186
                    if m.file_type().is_symlink() {
154 -
                        return fail(format!("refusing to write through symlink {}", full.display()));
187 +
                        return fail(format!(
188 +
                            "refusing to write through symlink {}",
189 +
                            full.display()
190 +
                        ));
155 191
                    }
156 192
                }
157 193
            } else {
…
43 unchanged lines
modified crates/oxy-core/src/repo/checkpoint.rs +21 −5
…
18 unchanged lines
19 19
    pub fn latest_checkpoint(&self, bookmark: &str) -> Result<Option<(ObjectRef, Checkpoint)>> {
20 20
        match fs::read_to_string(self.checkpoint_pointer(bookmark)) {
21 21
            Ok(h) => {
22 -
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt checkpoint pointer".into()))?;
22 +
                let r = ObjectRef::from_hex(h.trim())
23 +
                    .ok_or_else(|| RepoError::Msg("corrupt checkpoint pointer".into()))?;
23 24
                match self.store.get_typed(&r, ObjectType::Checkpoint) {
24 25
                    Ok(bytes) => Ok(Some((r, Checkpoint::decode(&bytes)?))),
25 26
                    Err(StoreError::NotFound(_)) => Ok(None),
…
22 unchanged lines
48 49
    }
49 50
50 51
    /// Write a checkpoint of `state` (whose heads are `heads`) for `bookmark`.
51 -
    pub(crate) fn write_checkpoint(&self, bookmark: &str, state: &State, heads: &[ChangeHash]) -> Result<(ObjectRef, usize)> {
52 +
    pub(crate) fn write_checkpoint(
53 +
        &self,
54 +
        bookmark: &str,
55 +
        state: &State,
56 +
        heads: &[ChangeHash],
57 +
    ) -> Result<(ObjectRef, usize)> {
52 58
        let payload = state.checkpoint_payload();
53 59
        let batch = self.store.begin_batch()?;
54 60
        let blob = self.store_blob(&payload)?;
55 -
        let cp = Checkpoint { heads: StateObject::new(heads.to_vec()).heads, count: state.len() as u64, payload: blob };
61 +
        let cp = Checkpoint {
62 +
            heads: StateObject::new(heads.to_vec()).heads,
63 +
            count: state.len() as u64,
64 +
            payload: blob,
65 +
        };
56 66
        let r = self.store.put(ObjectType::Checkpoint, &cp.encode())?;
57 67
        batch.finish()?;
58 68
        fs::create_dir_all(self.oxy.join("checkpoints"))?;
…
33 unchanged lines
92 102
                } else {
93 103
                    let payload = state.checkpoint_payload();
94 104
                    let blob = self.store_blob(&payload)?;
95 -
                    let cp = Checkpoint { heads: wanted.clone(), count: state.len() as u64, payload: blob };
105 +
                    let cp = Checkpoint {
106 +
                        heads: wanted.clone(),
107 +
                        count: state.len() as u64,
108 +
                        payload: blob,
109 +
                    };
96 110
                    self.store.put(ObjectType::Checkpoint, &cp.encode())?
97 111
                }
98 112
            }
…
20 unchanged lines
119 133
            }
120 134
        }
121 135
        for b in bookmarks {
122 -
            let Some((_, cp)) = self.latest_checkpoint(&b)? else { continue };
136 +
            let Some((_, cp)) = self.latest_checkpoint(&b)? else {
137 +
                continue;
138 +
            };
123 139
            let payload = self.read_blob(&cp.payload)?;
124 140
            let base = State::from_checkpoint_payload(&payload)?;
125 141
            let cp_heads: BTreeSet<ChangeHash> = cp.heads.iter().copied().collect();
…
31 unchanged lines
modified crates/oxy-core/src/repo/config.rs +35 −10
…
42 unchanged lines
43 43
        Config {
44 44
            repo_layer: BTreeMap::new(),
45 45
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
46 -
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
46 +
            author_email: std::env::var("OXY_AUTHOR_EMAIL")
47 +
                .unwrap_or_else(|_| format!("{user}@localhost")),
47 48
            author_key: None,
48 49
            sign_changes: false,
49 50
            bookmark: "main".into(),
…
44 unchanged lines
94 95
                self.author_key = if value.is_empty() {
95 96
                    None
96 97
                } else {
97 -
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
98 +
                    Some(KeyId::from_hex(value).ok_or_else(|| {
99 +
                        RepoError::Msg("author.key must be a 64-character hex KeyId".into())
100 +
                    })?)
98 101
                }
99 102
            }
100 103
            "core.sign" => {
…
6 unchanged lines
107 110
            "workspace.bookmark" => self.bookmark = value.into(),
108 111
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
109 112
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
110 -
            "filter.max-blob" => self.max_blob = if value.is_empty() { None } else { Some(parse_size(value)?) },
113 +
            "filter.max-blob" => {
114 +
                self.max_blob = if value.is_empty() {
115 +
                    None
116 +
                } else {
117 +
                    Some(parse_size(value)?)
118 +
                }
119 +
            }
111 120
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
112 121
            "filter.paths" => {
113 122
                self.sparse_paths = value
114 123
                    .split(',')
115 -
                    .map(|p| p.trim().trim_start_matches("./").trim_matches('/').to_string())
124 +
                    .map(|p| {
125 +
                        p.trim()
126 +
                            .trim_start_matches("./")
127 +
                            .trim_matches('/')
128 +
                            .to_string()
129 +
                    })
116 130
                    .filter(|p| !p.is_empty())
117 131
                    .collect();
118 132
            }
119 133
            "core.checkpoint-interval" => {
120 -
                self.checkpoint_interval = value.parse().map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
134 +
                self.checkpoint_interval = value
135 +
                    .parse()
136 +
                    .map_err(|_| RepoError::Msg(format!("expected a number, got {value:?}")))?;
121 137
            }
122 138
            k if k.starts_with("remote.") => {
123 139
                let rest = &k["remote.".len()..];
…
59 unchanged lines
183 199
        Some('G' | 'g') => (&v[..v.len() - 1], 1u64 << 30),
184 200
        _ => (v, 1),
185 201
    };
186 -
    let n: u64 = num.trim().parse().map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
202 +
    let n: u64 = num
203 +
        .trim()
204 +
        .parse()
205 +
        .map_err(|_| RepoError::Msg(format!("expected a size like 1M, got {v:?}")))?;
187 206
    Ok(n * mult)
188 207
}
189 208
190 209
/// A count of days, optionally suffixed `d`.
191 210
fn parse_days(v: &str) -> Result<u64> {
192 -
    v.trim_end_matches('d').parse().map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
211 +
    v.trim_end_matches('d')
212 +
        .parse()
213 +
        .map_err(|_| RepoError::Msg(format!("expected a number of days, got {v:?}")))
193 214
}
194 215
195 216
/// Read `key = "value"` lines; a missing file is empty.
…
9 unchanged lines
205 226
        if line.is_empty() || line.starts_with('#') {
206 227
            continue;
207 228
        }
208 -
        let Some((k, v)) = line.split_once('=') else { continue };
229 +
        let Some((k, v)) = line.split_once('=') else {
230 +
            continue;
231 +
        };
209 232
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
210 233
    }
211 234
    Ok(out)
212 235
}
213 236
214 237
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
215 -
    let text: String = entries.iter().map(|(k, v)| format!("{k} = \"{v}\"\n")).collect();
238 +
    let text: String = entries
239 +
        .iter()
240 +
        .map(|(k, v)| format!("{k} = \"{v}\"\n"))
241 +
        .collect();
216 242
    write_atomic(path, text.as_bytes())
217 243
}
218 -
modified crates/oxy-core/src/repo/gc.rs +14 −3
…
25 unchanged lines
26 26
    pub fn gc(&self, now: bool) -> Result<GcReport> {
27 27
        let packed = self.store.pack_loose()?;
28 28
        let keep = self.reachable()?;
29 -
        let grace = if now { std::time::Duration::ZERO } else { std::time::Duration::from_secs(self.config.gc_grace_days * 86_400) };
29 +
        let grace = if now {
30 +
            std::time::Duration::ZERO
31 +
        } else {
32 +
            std::time::Duration::from_secs(self.config.gc_grace_days * 86_400)
33 +
        };
30 34
        let pruned = self.store.prune(&keep, grace)?;
31 35
        let merged_packs = self.store.merge_packs()?;
32 -
        Ok(GcReport { packed, pruned, reachable: keep.len(), merged_packs })
36 +
        Ok(GcReport {
37 +
            packed,
38 +
            pruned,
39 +
            reachable: keep.len(),
40 +
            merged_packs,
41 +
        })
33 42
    }
34 43
35 44
    /// Every object that must survive garbage collection.
…
5 unchanged lines
41 50
        let mut policies: Vec<ObjectRef> = Vec::new();
42 51
43 52
        // Operations: always kept; views within retention are roots.
44 -
        let cutoff = Hlc::now().wall_ms.saturating_sub(self.config.op_retention_days * 86_400_000);
53 +
        let cutoff = Hlc::now()
54 +
            .wall_ms
55 +
            .saturating_sub(self.config.op_retention_days * 86_400_000);
45 56
        for (i, (r, op)) in self.op_log()?.into_iter().enumerate() {
46 57
            keep.insert(r);
47 58
            if i == 0 || op.hlc.wall_ms >= cutoff {
…
89 unchanged lines
modified crates/oxy-core/src/repo/git.rs +287 −55
…
38 unchanged lines
39 39
            .map_err(|e| RepoError::Msg(format!("running git cat-file: {e}")))?;
40 40
        let stdin = child.stdin.take().unwrap();
41 41
        let stdout = std::io::BufReader::new(child.stdout.take().unwrap());
42 -
        Ok(CatFile { child, stdin, stdout })
42 +
        Ok(CatFile {
43 +
            child,
44 +
            stdin,
45 +
            stdout,
46 +
        })
43 47
    }
44 48
45 49
    fn read(&mut self, sha: &str) -> Result<Vec<u8>> {
…
6 unchanged lines
52 56
        if parts.len() != 3 {
53 57
            return fail(format!("git cat-file: object {sha} not found"));
54 58
        }
55 -
        let size: usize = parts[2].parse().map_err(|_| RepoError::Msg("git cat-file: bad size".into()))?;
59 +
        let size: usize = parts[2]
60 +
            .parse()
61 +
            .map_err(|_| RepoError::Msg("git cat-file: bad size".into()))?;
56 62
        let mut body = vec![0u8; size];
57 63
        self.stdout.read_exact(&mut body)?;
58 64
        let mut nl = [0u8; 1];
…
22 unchanged lines
81 87
    let out = Command::new("git")
82 88
        .arg("-C")
83 89
        .arg(git_dir)
84 -
        .args(["log", "--reverse", "--topo-order", "--format=%H%x00%P%x00%at%x00%an <%ae>%x00%B%x1e", branch])
90 +
        .args([
91 +
            "log",
92 +
            "--reverse",
93 +
            "--topo-order",
94 +
            "--format=%H%x00%P%x00%at%x00%an <%ae>%x00%B%x1e",
95 +
            branch,
96 +
        ])
85 97
        .output()
86 98
        .map_err(|e| RepoError::Msg(format!("running git log: {e}")))?;
87 99
    if !out.status.success() {
88 -
        return fail(format!("git log {branch} failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
100 +
        return fail(format!(
101 +
            "git log {branch} failed: {}",
102 +
            String::from_utf8_lossy(&out.stderr).trim()
103 +
        ));
89 104
    }
90 105
    let text = String::from_utf8_lossy(&out.stdout);
91 106
    let mut commits = Vec::new();
…
25 unchanged lines
117 132
        if entry.is_empty() {
118 133
            continue;
119 134
        }
120 -
        let Some((meta, path)) = entry.split_once('\t') else { continue };
135 +
        let Some((meta, path)) = entry.split_once('\t') else {
136 +
            continue;
137 +
        };
121 138
        let m: Vec<&str> = meta.split(' ').collect();
122 139
        if m.len() == 3 {
123 140
            out.insert(path.to_string(), (m[0].to_string(), m[2].to_string()));
…
35 unchanged lines
159 176
}
160 177
161 178
fn git(dir: &Path, args: &[&str]) -> Result<String> {
162 -
    let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
179 +
    let out = Command::new("git")
180 +
        .arg("-C")
181 +
        .arg(dir)
182 +
        .args(args)
183 +
        .output()
184 +
        .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
163 185
    if !out.status.success() {
164 -
        return fail(format!("git {} failed: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
186 +
        return fail(format!(
187 +
            "git {} failed: {}",
188 +
            args.join(" "),
189 +
            String::from_utf8_lossy(&out.stderr).trim()
190 +
        ));
165 191
    }
166 192
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
167 193
}
168 194
169 195
impl Repo {
170 196
    fn git_marks_dir(&self, git_dir: &Path) -> Result<PathBuf> {
171 -
        let canonical = git_dir.canonicalize().unwrap_or_else(|_| git_dir.to_path_buf());
197 +
        let canonical = git_dir
198 +
            .canonicalize()
199 +
            .unwrap_or_else(|_| git_dir.to_path_buf());
172 200
        let key = hash_object(ObjectType::Chunk, canonical.to_string_lossy().as_bytes()).short(16);
173 201
        let dir = self.oxy.join("git").join(key);
174 202
        fs::create_dir_all(&dir)?;
…
20 unchanged lines
195 223
        let path = self.git_marks_dir(git_dir)?.join("git-states");
196 224
        let mut states = Self::read_git_states(&path)?;
197 225
        states.insert(*state, sha.to_string());
198 -
        let text: String = states.iter().map(|(h, s)| format!("{} {s}\n", h.to_hex())).collect();
226 +
        let text: String = states
227 +
            .iter()
228 +
            .map(|(h, s)| format!("{} {s}\n", h.to_hex()))
229 +
            .collect();
199 230
        write_atomic(&path, text.as_bytes())
200 231
    }
201 232
…
15 unchanged lines
217 248
            if e.is_working_copy {
218 249
                continue;
219 250
            }
220 -
            if let Some(sha) = e.change.message.lines().rev().find_map(|l| l.strip_prefix("Git-Commit: ")) {
251 +
            if let Some(sha) = e
252 +
                .change
253 +
                .message
254 +
                .lines()
255 +
                .rev()
256 +
                .find_map(|l| l.strip_prefix("Git-Commit: "))
257 +
            {
221 258
                map.insert(sha.trim().to_string(), e.hash);
222 259
            }
223 260
        }
…
8 unchanged lines
232 269
            Ok(_) => Ok(()),
233 270
            Err(e) => {
234 271
                let ls = git(mirror, &["ls-remote", "--heads", url, branch]).unwrap_or_default();
235 -
                if ls.trim().is_empty() { Ok(()) } else { Err(e) }
272 +
                if ls.trim().is_empty() {
273 +
                    Ok(())
274 +
                } else {
275 +
                    Err(e)
276 +
                }
236 277
            }
237 278
        }
238 279
    }
…
5 unchanged lines
244 285
    pub fn git_fetch(&self, url: &str, branch: &str) -> Result<GitImportReport> {
245 286
        let mirror = self.git_mirror(url)?;
246 287
        self.git_mirror_fetch(&mirror, url, branch)?;
247 -
        if git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]).is_err() {
288 +
        if git(
289 +
            &mirror,
290 +
            &[
291 +
                "rev-parse",
292 +
                "--verify",
293 +
                "-q",
294 +
                &format!("refs/heads/{branch}"),
295 +
            ],
296 +
        )
297 +
        .is_err()
298 +
        {
248 299
            return fail(format!("{url} has no branch {branch:?}"));
249 300
        }
250 301
        self.import_git_commits(&mirror, branch, &format!("oxy git fetch {url} {branch}"))
…
5 unchanged lines
256 307
    pub fn git_push(&self, url: &str, branch: &str) -> Result<GitExportReport> {
257 308
        let mirror = self.git_mirror(url)?;
258 309
        self.git_mirror_fetch(&mirror, url, branch)?;
259 -
        if let Ok(tip) = git(&mirror, &["rev-parse", "--verify", "-q", &format!("refs/heads/{branch}")]) {
310 +
        if let Ok(tip) = git(
311 +
            &mirror,
312 +
            &[
313 +
                "rev-parse",
314 +
                "--verify",
315 +
                "-q",
316 +
                &format!("refs/heads/{branch}"),
317 +
            ],
318 +
        ) {
260 319
            let known = self.git_known(&mirror)?;
261 320
            if !known.imported.contains_key(&tip) && !known.exported.contains_key(&tip) {
262 -
                return fail(format!("{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first"));
321 +
                return fail(format!(
322 +
                    "{url} {branch} has commits not yet fetched; run `oxy git fetch {url}` first"
323 +
                ));
263 324
            }
264 325
        }
265 326
        let report = self.git_export_as(&mirror, branch)?;
…
42 unchanged lines
308 369
309 370
        let bookmark = self.config.bookmark.clone();
310 371
        let mut stream: Vec<u8> = Vec::new();
311 -
        let mut report = GitExportReport { branch: branch.to_string(), heads: self.bookmark_heads()?.len(), ..Default::default() };
372 +
        let mut report = GitExportReport {
373 +
            branch: branch.to_string(),
374 +
            heads: self.bookmark_heads()?.len(),
375 +
            ..Default::default()
376 +
        };
312 377
313 378
        // States along the bookmark, oldest first, skipping unchanged and pruned ones.
314 379
        let mut ops = self.op_log()?;
…
2 unchanged lines
317 382
        // The fast-import parent of the next commit: `:mark` or a commit sha.
318 383
        let mut last_parent: Option<String> = None;
319 384
        for (_, op) in &ops {
320 -
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else { continue };
385 +
            let Some(state_ref) = op.view.bookmarks.get(&bookmark).copied() else {
386 +
                continue;
387 +
            };
321 388
            if prev.as_ref().is_some_and(|(p, _)| *p == state_ref) {
322 389
                continue;
323 390
            }
324 -
            let Ok(state) = self.store.get_state(&state_ref) else { continue };
391 +
            let Ok(state) = self.store.get_state(&state_ref) else {
392 +
                continue;
393 +
            };
325 394
            if state.heads.is_empty() {
326 395
                prev = Some((state_ref, vec![]));
327 396
                continue;
…
11 unchanged lines
339 408
                continue;
340 409
            }
341 410
            let prev_heads = prev.as_ref().map(|(_, h)| h.clone()).unwrap_or_default();
342 -
            let added = self.negotiate(&prev_heads, &state.heads).unwrap_or_default();
411 +
            let added = self
412 +
                .negotiate(&prev_heads, &state.heads)
413 +
                .unwrap_or_default();
343 414
            let loaded = self.load_state(&state.heads)?;
344 415
            let snapshot = loaded.materialize();
345 416
            let (author, secs, mut message) = match added.as_slice() {
…
3 unchanged lines
349 420
                    if !m.ends_with('\n') {
350 421
                        m.push('\n');
351 422
                    }
352 -
                    m.push_str(&format!("\nChange-Id: {}\nOxy-Hash: {}\n", c.change_id.to_hex(), h.to_hex()));
423 +
                    m.push_str(&format!(
424 +
                        "\nChange-Id: {}\nOxy-Hash: {}\n",
425 +
                        c.change_id.to_hex(),
426 +
                        h.to_hex()
427 +
                    ));
353 428
                    (self.git_author(&c), c.hlc.wall_ms / 1000, m)
354 429
                }
355 -
                [] => ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, format!("oxy: {}\n\n", op.command)),
430 +
                [] => (
431 +
                    "oxy <oxy@localhost>".to_string(),
432 +
                    op.hlc.wall_ms / 1000,
433 +
                    format!("oxy: {}\n\n", op.command),
434 +
                ),
356 435
                many => {
357 436
                    let mut m = format!("oxy: {} ({} changes)\n\n", op.command, many.len());
358 437
                    for h in many {
359 438
                        let c = self.store.get_change(h)?;
360 -
                        m.push_str(&format!("- {} {}\n", c.change_id.short(8), c.message.lines().next().unwrap_or("")));
439 +
                        m.push_str(&format!(
440 +
                            "- {} {}\n",
441 +
                            c.change_id.short(8),
442 +
                            c.message.lines().next().unwrap_or("")
443 +
                        ));
361 444
                    }
362 -
                    m.push_str(&format!("\nOxy-Changes: {}\n", many.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")));
445 +
                    m.push_str(&format!(
446 +
                        "\nOxy-Changes: {}\n",
447 +
                        many.iter()
448 +
                            .map(|h| h.to_hex())
449 +
                            .collect::<Vec<_>>()
450 +
                            .join(" ")
451 +
                    ));
363 452
                    ("oxy <oxy@localhost>".to_string(), op.hlc.wall_ms / 1000, m)
364 453
                }
365 454
            };
…
4 unchanged lines
370 459
            let mark = next_mark;
371 460
            next_mark += 1;
372 461
            let parents: Vec<String> = last_parent.clone().into_iter().collect();
373 -
            self.write_commit(&mut stream, "refs/oxy/export", mark, &author, secs, &message, &parents, &snapshot)?;
462 +
            self.write_commit(
463 +
                &mut stream,
464 +
                "refs/oxy/export",
465 +
                mark,
466 +
                &author,
467 +
                secs,
468 +
                &message,
469 +
                &parents,
470 +
                &snapshot,
471 +
            )?;
374 472
            marks.insert(state_ref, mark);
375 473
            last_parent = Some(format!(":{mark}"));
376 474
            prev = Some((state_ref, state.heads.clone()));
…
13 unchanged lines
390 488
                    let snapshot = loaded.materialize();
391 489
                    let mark = next_mark;
392 490
                    next_mark += 1;
393 -
                    let message = format!("oxy: tag {}\n\n{}Oxy-State: {}\n", t.name, if t.message.is_empty() { String::new() } else { format!("{}\n\n", t.message) }, t.state.to_hex());
394 -
                    self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?;
491 +
                    let message = format!(
492 +
                        "oxy: tag {}\n\n{}Oxy-State: {}\n",
493 +
                        t.name,
494 +
                        if t.message.is_empty() {
495 +
                            String::new()
496 +
                        } else {
497 +
                            format!("{}\n\n", t.message)
498 +
                        },
499 +
                        t.state.to_hex()
500 +
                    );
501 +
                    self.write_commit(
502 +
                        &mut stream,
503 +
                        "refs/oxy/export",
504 +
                        mark,
505 +
                        "oxy <oxy@localhost>",
506 +
                        Hlc::now().wall_ms / 1000,
507 +
                        &message,
508 +
                        &[],
509 +
                        &snapshot,
510 +
                    )?;
395 511
                    marks.insert(t.state, mark);
396 512
                    report.exported += 1;
397 513
                    format!(":{mark}")
398 514
                }
399 515
            };
400 -
            stream.extend_from_slice(format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes());
516 +
            stream.extend_from_slice(
517 +
                format!("reset refs/tags/{}\nfrom {target}\n\n", t.name).as_bytes(),
518 +
            );
401 519
        }
402 520
        stream.extend_from_slice(b"done\n");
403 521
404 522
        let mut cmd = Command::new("git");
405 523
        // `--force`: the scratch ref may jump between unrelated states.
406 -
        cmd.arg("-C").arg(git_dir).args(["fast-import", "--quiet", "--done", "--force"]).arg(format!("--export-marks={}", git_marks.display()));
524 +
        cmd.arg("-C")
525 +
            .arg(git_dir)
526 +
            .args(["fast-import", "--quiet", "--done", "--force"])
527 +
            .arg(format!("--export-marks={}", git_marks.display()));
407 528
        if git_marks.exists() {
408 529
            cmd.arg(format!("--import-marks={}", git_marks.display()));
409 530
        }
410 -
        let mut child = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::piped()).spawn().map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
531 +
        let mut child = cmd
532 +
            .stdin(Stdio::piped())
533 +
            .stdout(Stdio::null())
534 +
            .stderr(Stdio::piped())
535 +
            .spawn()
536 +
            .map_err(|e| RepoError::Msg(format!("running git fast-import: {e}")))?;
411 537
        child.stdin.take().unwrap().write_all(&stream)?;
412 538
        let out = child.wait_with_output()?;
413 539
        if !out.status.success() {
414 -
            return fail(format!("git fast-import failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
540 +
            return fail(format!(
541 +
                "git fast-import failed: {}",
542 +
                String::from_utf8_lossy(&out.stderr).trim()
543 +
            ));
415 544
        }
416 -
        let text: String = marks.iter().map(|(h, m)| format!("{} {m}\n", h.to_hex())).collect();
545 +
        let text: String = marks
546 +
            .iter()
547 +
            .map(|(h, m)| format!("{} {m}\n", h.to_hex()))
548 +
            .collect();
417 549
        write_atomic(&oxy_marks_path, text.as_bytes())?;
418 550
        let _ = git(git_dir, &["update-ref", "-d", "refs/oxy/export"]);
419 551
        report.skipped_subrepos = self.git_export_skipped.replace(0);
…
8 unchanged lines
428 560
        if !self.bookmark_heads()?.is_empty() {
429 561
            return fail("git import needs an empty bookmark; import into a fresh repository");
430 562
        }
431 -
        self.import_git_commits(git_dir, branch, &format!("oxy git import {} {branch}", git_dir.display()))
563 +
        self.import_git_commits(
564 +
            git_dir,
565 +
            branch,
566 +
            &format!("oxy git import {} {branch}", git_dir.display()),
567 +
        )
432 568
    }
433 569
434 570
    /// Import the commits of `branch` that the bookmark does not have yet,
435 571
    /// then move the bookmark to the union of its heads and theirs.
436 -
    fn import_git_commits(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
572 +
    fn import_git_commits(
573 +
        &self,
574 +
        git_dir: &Path,
575 +
        branch: &str,
576 +
        command: &str,
577 +
    ) -> Result<GitImportReport> {
437 578
        let batch = self.store.begin_batch()?;
438 579
        let result = self.import_git_commits_batched(git_dir, branch, command);
439 580
        batch.finish()?;
440 581
        result
441 582
    }
442 583
443 -
    fn import_git_commits_batched(&self, git_dir: &Path, branch: &str, command: &str) -> Result<GitImportReport> {
584 +
    fn import_git_commits_batched(
585 +
        &self,
586 +
        git_dir: &Path,
587 +
        branch: &str,
588 +
        command: &str,
589 +
    ) -> Result<GitImportReport> {
444 590
        let commits = list_commits(git_dir, branch)?;
445 591
        let mut cat = CatFile::start(git_dir)?;
446 592
        let mut report = GitImportReport::default();
…
63 unchanged lines
510 656
            // pins, and `.gitmodules` also yields `.oxymodules`.
511 657
            if !gitlinks.is_empty() {
512 658
                report.submodules += gitlinks.len();
513 -
                let gm = entries.get(".gitmodules").map(|e| crate::subrepo::Modules::from_gitmodules(&String::from_utf8_lossy(&e.content))).unwrap_or_default();
514 -
                let mut modules = entries.get(".oxymodules").map(|e| crate::subrepo::Modules::parse(&String::from_utf8_lossy(&e.content))).unwrap_or_default();
659 +
                let gm = entries
660 +
                    .get(".gitmodules")
661 +
                    .map(|e| {
662 +
                        crate::subrepo::Modules::from_gitmodules(&String::from_utf8_lossy(
663 +
                            &e.content,
664 +
                        ))
665 +
                    })
666 +
                    .unwrap_or_default();
667 +
                let mut modules = entries
668 +
                    .get(".oxymodules")
669 +
                    .map(|e| crate::subrepo::Modules::parse(&String::from_utf8_lossy(&e.content)))
670 +
                    .unwrap_or_default();
515 671
                for (path, sha) in &gitlinks {
516 672
                    let m = gm.entries.get(path);
517 -
                    let branch = m.and_then(|m| m.bookmark.clone()).unwrap_or_else(|| "HEAD".into());
518 -
                    entries.insert(path.clone(), FsEntry::new(NodeKind::Subrepo, false, crate::subrepo::Pin::Git { sha: sha.clone(), branch }.encode()));
673 +
                    let branch = m
674 +
                        .and_then(|m| m.bookmark.clone())
675 +
                        .unwrap_or_else(|| "HEAD".into());
676 +
                    entries.insert(
677 +
                        path.clone(),
678 +
                        FsEntry::new(
679 +
                            NodeKind::Subrepo,
680 +
                            false,
681 +
                            crate::subrepo::Pin::Git {
682 +
                                sha: sha.clone(),
683 +
                                branch,
684 +
                            }
685 +
                            .encode(),
686 +
                        ),
687 +
                    );
519 688
                    if let Some(m) = m {
520 689
                        modules.entries.insert(path.clone(), m.clone());
521 690
                    }
522 691
                }
523 692
                if !modules.entries.is_empty() {
524 -
                    entries.insert(".oxymodules".into(), FsEntry::new(NodeKind::File, false, modules.to_text().into_bytes()));
693 +
                    entries.insert(
694 +
                        ".oxymodules".into(),
695 +
                        FsEntry::new(NodeKind::File, false, modules.to_text().into_bytes()),
696 +
                    );
525 697
                }
526 698
            }
527 699
            let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
…
2 unchanged lines
530 702
            if !message.ends_with('\n') {
531 703
                message.push('\n');
532 704
            }
533 -
            message.push_str(&format!("\nGit-Commit: {}\nGit-Author: {}\n", gc.sha, gc.author));
705 +
            message.push_str(&format!(
706 +
                "\nGit-Commit: {}\nGit-Author: {}\n",
707 +
                gc.sha, gc.author
708 +
            ));
534 709
            let mut change = Change {
535 710
                change_id: change_id_for(&gc.sha, &gc.message),
536 711
                deps: vec![],
537 712
                author: ObjectRef::ZERO,
538 -
                hlc: Hlc { wall_ms: gc.author_secs * 1000, logical: 0 },
713 +
                hlc: Hlc {
714 +
                    wall_ms: gc.author_secs * 1000,
715 +
                    logical: 0,
716 +
                },
539 717
                message,
540 718
                tree_ops: built.tree_ops,
541 719
                edits: built.edits,
542 -
                kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
720 +
                kind: if built.resolves {
721 +
                    ChangeKind::Resolution
722 +
                } else {
723 +
                    ChangeKind::Normal
724 +
                },
543 725
            };
544 726
            deps.extend(referenced_changes(&change, &base));
545 727
            change.deps = deps.into_iter().collect();
…
22 unchanged lines
568 750
        let mut view = op.view.clone();
569 751
        let old_state_ref = view.bookmarks.get(&self.config.bookmark).copied();
570 752
        let state_ref = self.store.put_state(&StateObject::new(heads.clone()))?;
571 -
        view.bookmarks.insert(self.config.bookmark.clone(), state_ref);
753 +
        view.bookmarks
754 +
            .insert(self.config.bookmark.clone(), state_ref);
572 755
        self.record_op(&[op_ref], command, view)?;
573 756
        // The new state equals the tip's tree when the old one was itself a
574 757
        // Git commit's tree (fetched or exported), or there was none.
575 -
        let old_known = old_heads.is_empty() || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
758 +
        let old_known = old_heads.is_empty()
759 +
            || old_state_ref.is_some_and(|s| known.exported.values().any(|e| *e == s));
576 760
        if let (Some(tip), true) = (commits.last(), old_known) {
577 761
            self.record_git_state(git_dir, &state_ref, &tip.sha)?;
578 762
        }
…
17 unchanged lines
596 780
    /// Add to `st` the changes of every Git commit `rev-list args` lists:
597 781
    /// an imported commit's change, or the whole state an exported commit
598 782
    /// was made from (which may hold changes Git never saw).
599 -
    fn add_git_ancestors(&self, st: &mut State, git_dir: &Path, args: &[&str], known: &GitKnown) -> Result<()> {
783 +
    fn add_git_ancestors(
784 +
        &self,
785 +
        st: &mut State,
786 +
        git_dir: &Path,
787 +
        args: &[&str],
788 +
        known: &GitKnown,
789 +
    ) -> Result<()> {
600 790
        let mut full = vec!["rev-list"];
601 791
        full.extend(args);
602 792
        let out = git(git_dir, &full)?;
…
40 unchanged lines
643 833
644 834
    fn git_author(&self, c: &Change) -> String {
645 835
        match self.store.get_identity(&c.author) {
646 -
            Ok(id) if !id.name.is_empty() => format!("{} <{}>", id.name.replace(['<', '>', '\n'], " "), id.email.replace(['<', '>', '\n', ' '], "")),
836 +
            Ok(id) if !id.name.is_empty() => format!(
837 +
                "{} <{}>",
838 +
                id.name.replace(['<', '>', '\n'], " "),
839 +
                id.email.replace(['<', '>', '\n', ' '], "")
840 +
            ),
647 841
            _ => "oxy <oxy@localhost>".to_string(),
648 842
        }
649 843
    }
650 844
651 845
    #[allow(clippy::too_many_arguments)]
652 -
    fn write_commit(&self, out: &mut Vec<u8>, reference: &str, mark: u64, author: &str, secs: u64, message: &str, parents: &[String], snapshot: &Snapshot) -> Result<()> {
846 +
    fn write_commit(
847 +
        &self,
848 +
        out: &mut Vec<u8>,
849 +
        reference: &str,
850 +
        mark: u64,
851 +
        author: &str,
852 +
        secs: u64,
853 +
        message: &str,
854 +
        parents: &[String],
855 +
        snapshot: &Snapshot,
856 +
    ) -> Result<()> {
653 857
        out.extend_from_slice(format!("commit {reference}\nmark :{mark}\nauthor {author} {secs} +0000\ncommitter {author} {secs} +0000\ndata {}\n{message}\n", message.len()).as_bytes());
654 858
        for (i, p) in parents.iter().enumerate() {
655 -
            out.extend_from_slice(format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes());
859 +
            out.extend_from_slice(
860 +
                format!("{} {p}\n", if i == 0 { "from" } else { "merge" }).as_bytes(),
861 +
            );
656 862
        }
657 863
        out.extend_from_slice(b"deleteall\n");
658 864
        let has_gitmodules = snapshot.files.contains_key(".gitmodules");
…
2 unchanged lines
661 867
                // Git reads `.gitmodules`; derive it unless the tree has one.
662 868
                if !has_gitmodules {
663 869
                    if let FileContent::Text { bytes, .. } = &f.content {
664 -
                        let gm = crate::subrepo::Modules::parse(&String::from_utf8_lossy(bytes)).to_gitmodules();
870 +
                        let gm = crate::subrepo::Modules::parse(&String::from_utf8_lossy(bytes))
871 +
                            .to_gitmodules();
665 872
                        if !gm.is_empty() {
666 -
                            out.extend_from_slice(format!("M 100644 inline .gitmodules\ndata {}\n", gm.len()).as_bytes());
873 +
                            out.extend_from_slice(
874 +
                                format!("M 100644 inline .gitmodules\ndata {}\n", gm.len())
875 +
                                    .as_bytes(),
876 +
                            );
667 877
                            out.extend_from_slice(gm.as_bytes());
668 878
                            out.push(b'\n');
669 879
                        }
…
5 unchanged lines
675 885
                if let FileContent::Opaque { blobs } = &f.content {
676 886
                    if let Some(b) = blobs.first() {
677 887
                        match crate::subrepo::Pin::decode(&self.read_blob(b)?)? {
678 -
                            crate::subrepo::Pin::Git { sha, .. } => out.extend_from_slice(format!("M 160000 {sha} {path}\n").as_bytes()),
679 -
                            crate::subrepo::Pin::Oxy { .. } => self.git_export_skipped.set(self.git_export_skipped.get() + 1),
888 +
                            crate::subrepo::Pin::Git { sha, .. } => {
889 +
                                out.extend_from_slice(format!("M 160000 {sha} {path}\n").as_bytes())
890 +
                            }
891 +
                            crate::subrepo::Pin::Oxy { .. } => self
892 +
                                .git_export_skipped
893 +
                                .set(self.git_export_skipped.get() + 1),
680 894
                        }
681 895
                    }
682 896
                }
…
1 unchanged lines
684 898
            }
685 899
            let (mode, bytes): (&str, Vec<u8>) = match (&f.kind, &f.content) {
686 900
                (NodeKind::Dir, _) => continue,
687 -
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => ("120000", blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
688 -
                (_, FileContent::Text { bytes, .. }) => (if f.mode.exec { "100755" } else { "100644" }, bytes.clone()),
689 -
                (_, FileContent::Opaque { blobs }) => (if f.mode.exec { "100755" } else { "100644" }, blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default()),
901 +
                (NodeKind::Symlink, FileContent::Opaque { blobs }) => (
902 +
                    "120000",
903 +
                    blobs
904 +
                        .first()
905 +
                        .map(|b| self.read_blob(b))
906 +
                        .transpose()?
907 +
                        .unwrap_or_default(),
908 +
                ),
909 +
                (_, FileContent::Text { bytes, .. }) => {
910 +
                    (if f.mode.exec { "100755" } else { "100644" }, bytes.clone())
911 +
                }
912 +
                (_, FileContent::Opaque { blobs }) => (
913 +
                    if f.mode.exec { "100755" } else { "100644" },
914 +
                    blobs
915 +
                        .first()
916 +
                        .map(|b| self.read_blob(b))
917 +
                        .transpose()?
918 +
                        .unwrap_or_default(),
919 +
                ),
690 920
                (_, FileContent::Dir) => continue,
691 921
            };
692 -
            out.extend_from_slice(format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes());
922 +
            out.extend_from_slice(
923 +
                format!("M {mode} inline {path}\ndata {}\n", bytes.len()).as_bytes(),
924 +
            );
693 925
            out.extend_from_slice(&bytes);
694 926
            out.push(b'\n');
695 927
        }
…
4 unchanged lines
modified crates/oxy-core/src/repo/history.rs +443 −90
…
28 unchanged lines
29 29
    /// Replace head `old` with `new` (or with nothing) on the bookmark and
30 30
    /// restart the working copy on the resulting base. Files on disk are not
31 31
    /// touched: the next snapshot diffs them against the new base.
32 -
    fn replace_head(&self, old: &ChangeHash, new: Option<&Change>, command: &str) -> Result<Option<ChangeHash>> {
32 +
    fn replace_head(
33 +
        &self,
34 +
        old: &ChangeHash,
35 +
        new: Option<&Change>,
36 +
        command: &str,
37 +
    ) -> Result<Option<ChangeHash>> {
33 38
        let new_hash = match new {
34 39
            Some(c) => Some(self.store_signed(c)?),
35 40
            None => None,
…
28 unchanged lines
64 69
        let wc_hash = self.store.put_change(&fresh)?;
65 70
66 71
        let mut view = op.view.clone();
67 -
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
72 +
        view.bookmarks.insert(
73 +
            bookmark,
74 +
            self.store.put_state(&StateObject::new(new_heads))?,
75 +
        );
68 76
        view.workspaces.insert(self.workspace, wc_hash);
69 77
        self.record_op(&[op_ref], command, view)?;
70 78
        self.drop_manifest();
…
3 unchanged lines
74 82
    /// Replace `old` by `new` (or remove it) anywhere in the bookmark's state,
75 83
    /// rewriting every dependent, including the working copy. Returns the
76 84
    /// new hash of `old`, if any.
77 -
    fn rewrite_change(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<Option<ChangeHash>> {
78 -
        self.rewrite_change_noted(old, new, corr, command).map(|(h, _)| h)
85 +
    fn rewrite_change(
86 +
        &self,
87 +
        old: &ChangeHash,
88 +
        new: Option<&Change>,
89 +
        corr: &Correspondence,
90 +
        command: &str,
91 +
    ) -> Result<Option<ChangeHash>> {
92 +
        self.rewrite_change_noted(old, new, corr, command)
93 +
            .map(|(h, _)| h)
79 94
    }
80 95
81 96
    /// Like `rewrite_change`, also returning notes about reified references.
82 -
    fn rewrite_change_noted(&self, old: &ChangeHash, new: Option<&Change>, corr: &Correspondence, command: &str) -> Result<(Option<ChangeHash>, Vec<String>)> {
97 +
    fn rewrite_change_noted(
98 +
        &self,
99 +
        old: &ChangeHash,
100 +
        new: Option<&Change>,
101 +
        corr: &Correspondence,
102 +
        command: &str,
103 +
    ) -> Result<(Option<ChangeHash>, Vec<String>)> {
83 104
        let (op_ref, op) = self.head_op()?;
84 105
        let bookmark = self.config.bookmark.clone();
85 106
        let heads = self.heads_of(&op.view, &bookmark)?;
…
1 unchanged lines
87 108
        let mut all = heads.clone();
88 109
        all.push(wc_hash);
89 110
        let state = self.load_state(&all)?;
90 -
        if !state.closure(&wc_hash).contains(old) && !heads.contains(old) && !state.heads().contains(old) && !state.contains(old) {
91 -
            return fail(format!("change #{} is not in {} or under the working copy", old.short(12), bookmark));
111 +
        if !state.closure(&wc_hash).contains(old)
112 +
            && !heads.contains(old)
113 +
            && !state.heads().contains(old)
114 +
            && !state.contains(old)
115 +
        {
116 +
            return fail(format!(
117 +
                "change #{} is not in {} or under the working copy",
118 +
                old.short(12),
119 +
                bookmark
120 +
            ));
92 121
        }
93 122
        let old_change = self.store.get_change(old)?;
94 123
        if old_change.kind == ChangeKind::WorkingCopy {
95 124
            return fail("that is the working copy; commit it instead");
96 125
        }
97 126
        let load = |h: &ChangeHash| self.store.get_change(h).ok();
98 -
        let propagation = propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(e.to_string()))?;
127 +
        let propagation =
128 +
            propagate(&state, *old, new, corr, &load).map_err(|e| RepoError::Msg(e.to_string()))?;
99 129
        let rewritten = propagation.changes;
100 130
101 131
        let mut mapping: HashMap<ChangeHash, ChangeHash> = HashMap::new();
…
6 unchanged lines
108 138
            None => None,
109 139
        };
110 140
        for (h, c) in &rewritten {
111 -
            let nh = if c.kind == ChangeKind::WorkingCopy { self.store.put_change(c)? } else { self.store_signed(c)? };
141 +
            let nh = if c.kind == ChangeKind::WorkingCopy {
142 +
                self.store.put_change(c)?
143 +
            } else {
144 +
                self.store_signed(c)?
145 +
            };
112 146
            mapping.insert(*h, nh);
113 147
        }
114 148
        let remap = |set: &[ChangeHash]| -> Vec<ChangeHash> {
…
13 unchanged lines
128 162
        let new_wc = mapping.get(&wc_hash).copied().unwrap_or(wc_hash);
129 163
130 164
        let mut view = op.view.clone();
131 -
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(new_heads))?);
165 +
        view.bookmarks.insert(
166 +
            bookmark,
167 +
            self.store.put_state(&StateObject::new(new_heads))?,
168 +
        );
132 169
        view.workspaces.insert(self.workspace, new_wc);
133 170
        self.record_op(&[op_ref], command, view)?;
134 171
        self.drop_manifest();
…
16 unchanged lines
151 188
        c.message = message.trim_end().to_string();
152 189
        c.hlc = Hlc::now();
153 190
        let id = c.change_id;
154 -
        Ok(self.rewrite_change(hash, Some(&c), &corr, &format!("oxy describe {} -m {message:?}", id.short(8)))?.unwrap())
191 +
        Ok(self
192 +
            .rewrite_change(
193 +
                hash,
194 +
                Some(&c),
195 +
                &corr,
196 +
                &format!("oxy describe {} -m {message:?}", id.short(8)),
197 +
            )?
198 +
            .unwrap())
155 199
    }
156 200
157 201
    /// Fold the working copy into the head it sits on: the head is rebuilt
158 202
    /// as the difference between the state beneath it and the files on disk.
159 203
    pub fn amend(&self, message: Option<&str>) -> Result<ChangeHash> {
160 204
        let snap = self.snapshot("oxy amend")?;
161 -
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
205 +
        if snap
206 +
            .statuses
207 +
            .iter()
208 +
            .any(|s| s.status == ChangeStatus::ConflictEdited)
209 +
        {
162 210
            return fail("a file still contains conflict markers");
163 211
        }
164 212
        let base_heads = snap.base.maximal(&snap.wc.deps);
165 213
        let [head] = base_heads.as_slice() else {
166 -
            return fail(format!("the working copy sits on {} heads; check out the single change to amend", base_heads.len()));
214 +
            return fail(format!(
215 +
                "the working copy sits on {} heads; check out the single change to amend",
216 +
                base_heads.len()
217 +
            ));
167 218
        };
168 219
        let head = *head;
169 220
        let old = self.rewritable_head(&head)?;
170 221
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() && message.is_none() {
171 -
            return fail("nothing to amend: the working copy is clean and no new message was given");
222 +
            return fail(
223 +
                "nothing to amend: the working copy is clean and no new message was given",
224 +
            );
172 225
        }
173 226
        // Rebuild against the state beneath the head.
174 227
        let beneath = self.load_state(&old.deps)?;
…
10 unchanged lines
185 238
            deps: vec![],
186 239
            author: self.author(),
187 240
            hlc: Hlc::now(),
188 -
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
241 +
            message: message
242 +
                .map(|m| m.trim_end().to_string())
243 +
                .unwrap_or(old.message.clone()),
189 244
            tree_ops: built.tree_ops,
190 245
            edits: built.edits,
191 -
            kind: if built.resolves || old.kind == ChangeKind::Resolution { ChangeKind::Resolution } else { ChangeKind::Normal },
246 +
            kind: if built.resolves || old.kind == ChangeKind::Resolution {
247 +
                ChangeKind::Resolution
248 +
            } else {
249 +
                ChangeKind::Normal
250 +
            },
192 251
        };
193 252
        deps.extend(referenced_changes(&new, &beneath));
194 253
        new.deps = deps.into_iter().collect();
195 254
        new.normalize();
196 -
        Ok(self.replace_head(&head, Some(&new), &format!("oxy amend {}", old.change_id.short(8)))?.unwrap())
255 +
        Ok(self
256 +
            .replace_head(
257 +
                &head,
258 +
                Some(&new),
259 +
                &format!("oxy amend {}", old.change_id.short(8)),
260 +
            )?
261 +
            .unwrap())
197 262
    }
198 263
199 264
    /// Drop a change from the bookmark. Dependents inherit its dependencies;
…
10 unchanged lines
210 275
            self.replace_head(hash, None, &format!("oxy abandon {}", c.change_id.short(8)))?;
211 276
            Ok(vec![])
212 277
        } else {
213 -
            let (_, notes) = self.rewrite_change_noted(hash, None, &Correspondence::default(), &format!("oxy abandon {}", c.change_id.short(8)))?;
278 +
            let (_, notes) = self.rewrite_change_noted(
279 +
                hash,
280 +
                None,
281 +
                &Correspondence::default(),
282 +
                &format!("oxy abandon {}", c.change_id.short(8)),
283 +
            )?;
214 284
            Ok(notes)
215 285
        }
216 286
    }
…
2 unchanged lines
219 289
/// Translate a reference to change `target`'s own atom or op into a self
220 290
/// reference, and a working-copy self reference into one offset past the
221 291
/// target's own count; other references pass through.
222 -
fn fold_atom(r: AtomRef, target: &ChangeHash, wc_offset: &HashMap<NodeId, u32>, node: &NodeId) -> Option<AtomRef> {
292 +
fn fold_atom(
293 +
    r: AtomRef,
294 +
    target: &ChangeHash,
295 +
    wc_offset: &HashMap<NodeId, u32>,
296 +
    node: &NodeId,
297 +
) -> Option<AtomRef> {
223 298
    match r {
224 -
        AtomRef::Atom { change, index } if change == *target => Some(AtomRef::Atom { change: ObjectRef::ZERO, index }),
225 -
        AtomRef::Atom { change, index } if change.is_zero() => Some(AtomRef::Atom { change: ObjectRef::ZERO, index: index + wc_offset.get(node).copied().unwrap_or(0) }),
299 +
        AtomRef::Atom { change, index } if change == *target => Some(AtomRef::Atom {
300 +
            change: ObjectRef::ZERO,
301 +
            index,
302 +
        }),
303 +
        AtomRef::Atom { change, index } if change.is_zero() => Some(AtomRef::Atom {
304 +
            change: ObjectRef::ZERO,
305 +
            index: index + wc_offset.get(node).copied().unwrap_or(0),
306 +
        }),
226 307
        // References into the target's closure were validated by the caller and stay as they are.
227 308
        other => Some(other),
228 309
    }
…
7 unchanged lines
236 317
    /// atoms keep their indices.
237 318
    pub fn amend_into(&self, target: &ChangeHash, message: Option<&str>) -> Result<ChangeHash> {
238 319
        let snap = self.snapshot("oxy amend")?;
239 -
        if snap.statuses.iter().any(|s| s.status == ChangeStatus::ConflictEdited) {
320 +
        if snap
321 +
            .statuses
322 +
            .iter()
323 +
            .any(|s| s.status == ChangeStatus::ConflictEdited)
324 +
        {
240 325
            return fail("a file still contains conflict markers");
241 326
        }
242 327
        let wc = &snap.wc;
243 328
        if wc.tree_ops.is_empty() && wc.edits.is_empty() && message.is_none() {
244 -
            return fail("nothing to amend: the working copy is clean and no new message was given");
329 +
            return fail(
330 +
                "nothing to amend: the working copy is clean and no new message was given",
331 +
            );
245 332
        }
246 333
        let old = self.store.get_change(target)?;
247 334
        if old.kind == ChangeKind::WorkingCopy {
…
3 unchanged lines
251 338
        let closure = self.load_state(&[*target])?;
252 339
        let ok_change = |h: &ChangeHash| *h == *target || closure.contains(h);
253 340
        let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some();
254 -
        let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; amend into a later change instead", target.short(12)));
341 +
        let too_new = |what: &str| {
342 +
            RepoError::Msg(format!(
343 +
                "{what} was introduced after change #{}; amend into a later change instead",
344 +
                target.short(12)
345 +
            ))
346 +
        };
255 347
        for op in &wc.tree_ops {
256 348
            match op {
257 349
                TreeOp::AddNode { parent, .. } => {
…
6 unchanged lines
264 356
                        return Err(too_new("a removed file"));
265 357
                    }
266 358
                }
267 -
                TreeOp::Move { node, old, parent, .. } => {
359 +
                TreeOp::Move {
360 +
                    node, old, parent, ..
361 +
                } => {
268 362
                    if !ok_node(node) || !ok_change(&old.change) || !ok_node(parent) {
269 363
                        return Err(too_new("a moved file"));
270 364
                    }
…
5 unchanged lines
276 370
                }
277 371
            }
278 372
        }
279 -
        let wc_added: BTreeSet<NodeId> = wc.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect();
373 +
        let wc_added: BTreeSet<NodeId> = wc
374 +
            .tree_ops
375 +
            .iter()
376 +
            .filter_map(|o| {
377 +
                if let TreeOp::AddNode { node, .. } = o {
378 +
                    Some(*node)
379 +
                } else {
380 +
                    None
381 +
                }
382 +
            })
383 +
            .collect();
280 384
        for e in &wc.edits {
281 385
            let n = e.node();
282 386
            if !wc_added.contains(&n) && !ok_node(&n) {
283 387
                return Err(too_new("an edited file"));
284 388
            }
285 389
            let refs: Vec<ChangeHash> = match e {
286 -
                Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(),
390 +
                Edit::Insert { after, before, .. } => [after, before]
391 +
                    .iter()
392 +
                    .filter_map(|r| {
393 +
                        if let AtomRef::Atom { change, .. } = r {
394 +
                            Some(*change)
395 +
                        } else {
396 +
                            None
397 +
                        }
398 +
                    })
399 +
                    .collect(),
287 400
                Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(),
288 401
                Edit::Replace { .. } => vec![],
289 402
            };
…
14 unchanged lines
304 417
        let op_offset = old.tree_ops.len() as u32;
305 418
        let fold_op = |r: OpRef| -> OpRef {
306 419
            if r.change == *target {
307 -
                OpRef { change: ObjectRef::ZERO, index: r.index }
420 +
                OpRef {
421 +
                    change: ObjectRef::ZERO,
422 +
                    index: r.index,
423 +
                }
308 424
            } else if r.change.is_zero() {
309 -
                OpRef { change: ObjectRef::ZERO, index: r.index + op_offset }
425 +
                OpRef {
426 +
                    change: ObjectRef::ZERO,
427 +
                    index: r.index + op_offset,
428 +
                }
310 429
            } else {
311 430
                r
312 431
            }
…
1 unchanged lines
314 433
        let mut tree_ops = old.tree_ops.clone();
315 434
        for op in &wc.tree_ops {
316 435
            tree_ops.push(match op {
317 -
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) },
318 -
                TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() },
319 -
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode },
436 +
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode {
437 +
                    node: *node,
438 +
                    placement: fold_op(*placement),
439 +
                },
440 +
                TreeOp::Move {
441 +
                    node,
442 +
                    old,
443 +
                    parent,
444 +
                    name,
445 +
                } => TreeOp::Move {
446 +
                    node: *node,
447 +
                    old: fold_op(*old),
448 +
                    parent: *parent,
449 +
                    name: name.clone(),
450 +
                },
451 +
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode {
452 +
                    node: *node,
453 +
                    old: fold_op(*old),
454 +
                    mode: *mode,
455 +
                },
320 456
                add => add.clone(),
321 457
            });
322 458
        }
323 459
        let mut edits = old.edits.clone();
324 460
        for e in &wc.edits {
325 461
            edits.push(match e {
326 -
                Edit::Insert { node, after, before, lines } => Edit::Insert {
462 +
                Edit::Insert {
463 +
                    node,
464 +
                    after,
465 +
                    before,
466 +
                    lines,
467 +
                } => Edit::Insert {
327 468
                    node: *node,
328 -
                    after: fold_atom(*after, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
329 -
                    before: fold_atom(*before, target, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
469 +
                    after: fold_atom(*after, target, &per_node, node)
470 +
                        .ok_or_else(|| too_new("an anchor line"))?,
471 +
                    before: fold_atom(*before, target, &per_node, node)
472 +
                        .ok_or_else(|| too_new("an anchor line"))?,
330 473
                    lines: lines.clone(),
331 474
                },
332 475
                Edit::Delete { node, atoms } => Edit::Delete {
…
2 unchanged lines
335 478
                        .iter()
336 479
                        .map(|r| {
337 480
                            if r.change == *target {
338 -
                                AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to }
481 +
                                AtomRange {
482 +
                                    change: ObjectRef::ZERO,
483 +
                                    from: r.from,
484 +
                                    to: r.to,
485 +
                                }
339 486
                            } else if r.change.is_zero() {
340 487
                                let off = per_node.get(node).copied().unwrap_or(0);
341 -
                                AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off }
488 +
                                AtomRange {
489 +
                                    change: ObjectRef::ZERO,
490 +
                                    from: r.from + off,
491 +
                                    to: r.to + off,
492 +
                                }
342 493
                            } else {
343 494
                                *r
344 495
                            }
…
8 unchanged lines
353 504
            deps: vec![],
354 505
            author: self.author(),
355 506
            hlc: Hlc::now(),
356 -
            message: message.map(|m| m.trim_end().to_string()).unwrap_or(old.message.clone()),
507 +
            message: message
508 +
                .map(|m| m.trim_end().to_string())
509 +
                .unwrap_or(old.message.clone()),
357 510
            tree_ops,
358 511
            edits,
359 512
            kind: old.kind,
360 513
        };
361 514
        let beneath = self.load_state(&old.deps)?;
362 515
        let mut deps: BTreeSet<ChangeHash> = old.deps.iter().copied().collect();
363 -
        deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *target));
364 -
        deps.extend(wc.deps.iter().filter(|d| closure.contains(d) && **d != *target).copied());
516 +
        deps.extend(
517 +
            referenced_changes(&new, &beneath)
518 +
                .into_iter()
519 +
                .filter(|h| *h != *target),
520 +
        );
521 +
        deps.extend(
522 +
            wc.deps
523 +
                .iter()
524 +
                .filter(|d| closure.contains(d) && **d != *target)
525 +
                .copied(),
526 +
        );
365 527
        new.deps = deps.into_iter().collect();
366 528
        new.normalize();
367 529
368 530
        // Rewrite dependents (identity correspondence), then start a fresh,
369 531
        // empty working copy: its edits now live in the amended change.
370 532
        let corr = Correspondence::identity(&old);
371 -
        let new_hash = self.rewrite_change(target, Some(&new), &corr, &format!("oxy amend --into {}", old.change_id.short(8)))?.unwrap();
533 +
        let new_hash = self
534 +
            .rewrite_change(
535 +
                target,
536 +
                Some(&new),
537 +
                &corr,
538 +
                &format!("oxy amend --into {}", old.change_id.short(8)),
539 +
            )?
540 +
            .unwrap();
372 541
        let (op_ref, op) = self.head_op()?;
373 542
        let heads = self.heads_of(&op.view, &self.config.bookmark)?;
374 543
        let fresh = self.new_working_copy(heads, "");
…
22 unchanged lines
397 566
        let beneath_snapshot = beneath.materialize();
398 567
        let full = self.load_state(&[*hash])?;
399 568
        let full_snapshot = full.materialize();
400 -
        let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
569 +
        let selected = |p: &str| {
570 +
            paths
571 +
                .iter()
572 +
                .any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))))
573 +
        };
401 574
        // Entries of the intermediate state: selected paths as the head
402 575
        // leaves them, everything else as beneath.
403 -
        let entries_of = |snap: &Snapshot, keep_from_full: &dyn Fn(&str) -> bool| -> Result<BTreeMap<String, FsEntry>> {
576 +
        let entries_of = |snap: &Snapshot,
577 +
                          keep_from_full: &dyn Fn(&str) -> bool|
578 +
         -> Result<BTreeMap<String, FsEntry>> {
404 579
            let mut out = BTreeMap::new();
405 580
            for (p, _) in snap.files.iter().chain(full_snapshot.files.iter()) {
406 581
                let take_full = keep_from_full(p);
407 -
                let src = if take_full { full_snapshot.files.get(p) } else { snap.files.get(p) };
582 +
                let src = if take_full {
583 +
                    full_snapshot.files.get(p)
584 +
                } else {
585 +
                    snap.files.get(p)
586 +
                };
408 587
                let Some(src) = src else { continue };
409 588
                if out.contains_key(p) {
410 589
                    continue;
…
11 unchanged lines
422 601
            Ok(out)
423 602
        };
424 603
        let mid_entries = entries_of(&beneath_snapshot, &|p| selected(p))?;
425 -
        let first = self.build_from_entries(&beneath, &beneath_snapshot, HashMap::new(), &mid_entries)?;
604 +
        let first =
605 +
            self.build_from_entries(&beneath, &beneath_snapshot, HashMap::new(), &mid_entries)?;
426 606
        if first.tree_ops.is_empty() && first.edits.is_empty() {
427 607
            return fail("the named paths are not touched by that change");
428 608
        }
…
2 unchanged lines
431 611
            deps: old.deps.clone(),
432 612
            author: old.author,
433 613
            hlc: Hlc::now(),
434 -
            message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")),
614 +
            message: format!(
615 +
                "{} (part 1: {})",
616 +
                old.message.lines().next().unwrap_or(""),
617 +
                paths.join(", ")
618 +
            ),
435 619
            tree_ops: first.tree_ops,
436 620
            edits: first.edits,
437 621
            kind: ChangeKind::Normal,
…
9 unchanged lines
447 631
        let rest_entries = entries_of(&full_snapshot, &|_| true)?;
448 632
        let rest = self.build_from_entries(&mid, &mid_snapshot, HashMap::new(), &rest_entries)?;
449 633
        if rest.tree_ops.is_empty() && rest.edits.is_empty() {
450 -
            return fail("everything in that change is under the named paths; nothing would remain");
634 +
            return fail(
635 +
                "everything in that change is under the named paths; nothing would remain",
636 +
            );
451 637
        }
452 638
        let mut c2 = Change {
453 639
            change_id: old.change_id,
…
9 unchanged lines
463 649
        d2.extend(old.deps.iter().copied());
464 650
        c2.deps = d2.into_iter().collect();
465 651
        c2.normalize();
466 -
        let h2 = self.replace_head(hash, Some(&c2), &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
652 +
        let h2 = self
653 +
            .replace_head(
654 +
                hash,
655 +
                Some(&c2),
656 +
                &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")),
657 +
            )?
658 +
            .unwrap();
467 659
        self.add_to_bookmark(h1)?;
468 660
        Ok((h1, h2))
469 661
    }
…
8 unchanged lines
478 670
        let st = self.load_state(&heads)?;
479 671
        let heads = st.maximal(&heads);
480 672
        let mut view = op.view.clone();
481 -
        view.bookmarks.insert(bookmark, self.store.put_state(&StateObject::new(heads.clone()))?);
673 +
        view.bookmarks.insert(
674 +
            bookmark,
675 +
            self.store.put_state(&StateObject::new(heads.clone()))?,
676 +
        );
482 677
        let (_, wc) = self.working_copy()?;
483 678
        let mut base = wc.deps.clone();
484 679
        base.push(h);
485 680
        let wc_base = self.load_state(&base)?.maximal(&base);
486 681
        let fresh = self.new_working_copy(wc_base, &wc.message);
487 -
        view.workspaces.insert(self.workspace, self.store.put_change(&fresh)?);
682 +
        view.workspaces
683 +
            .insert(self.workspace, self.store.put_change(&fresh)?);
488 684
        self.record_op(&[op_ref], "oxy split (heads)", view)?;
489 685
        self.drop_manifest();
490 686
        Ok(())
…
3 unchanged lines
494 690
    /// under `paths` become a new change, the rest keeps the ChangeId with
495 691
    /// every remaining atom at its old index, so dependents are rewritten
496 692
    /// exactly. Refused when a dependent touches a split-off node.
497 -
    fn split_by_node(&self, hash: &ChangeHash, paths: &[String]) -> Result<(ChangeHash, ChangeHash)> {
693 +
    fn split_by_node(
694 +
        &self,
695 +
        hash: &ChangeHash,
696 +
        paths: &[String],
697 +
    ) -> Result<(ChangeHash, ChangeHash)> {
498 698
        let old = self.store.get_change(hash)?;
499 699
        if old.kind == ChangeKind::WorkingCopy {
500 700
            return fail("that is the working copy; commit it instead");
501 701
        }
502 -
        let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
702 +
        let selected = |p: &str| {
703 +
            paths
704 +
                .iter()
705 +
                .any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))))
706 +
        };
503 707
        let beneath = self.load_state(&old.deps)?;
504 708
        let full = self.load_state(&[*hash])?;
505 709
        let mut removed: BTreeSet<NodeId> = BTreeSet::new();
…
27 unchanged lines
533 737
                continue;
534 738
            }
535 739
            let d = self.store.get_change(h)?;
536 -
            if d.tree_ops.iter().any(|op| removed.contains(&op.node())) || d.edits.iter().any(|e| removed.contains(&e.node())) {
537 -
                return fail(format!("change {} depends on the split-off paths of {}; split it first", d.change_id.short(8), old.change_id.short(8)));
740 +
            if d.tree_ops.iter().any(|op| removed.contains(&op.node()))
741 +
                || d.edits.iter().any(|e| removed.contains(&e.node()))
742 +
            {
743 +
                return fail(format!(
744 +
                    "change {} depends on the split-off paths of {}; split it first",
745 +
                    d.change_id.short(8),
746 +
                    old.change_id.short(8)
747 +
                ));
538 748
            }
539 749
        }
540 750
        let mut c1 = Change {
…
1 unchanged lines
542 752
            deps: old.deps.clone(),
543 753
            author: old.author,
544 754
            hlc: Hlc::now(),
545 -
            message: format!("{} (part 1: {})", old.message.lines().next().unwrap_or(""), paths.join(", ")),
546 -
            tree_ops: old.tree_ops.iter().filter(|op| removed.contains(&op.node())).cloned().collect(),
547 -
            edits: old.edits.iter().filter(|e| removed.contains(&e.node())).cloned().collect(),
755 +
            message: format!(
756 +
                "{} (part 1: {})",
757 +
                old.message.lines().next().unwrap_or(""),
758 +
                paths.join(", ")
759 +
            ),
760 +
            tree_ops: old
761 +
                .tree_ops
762 +
                .iter()
763 +
                .filter(|op| removed.contains(&op.node()))
764 +
                .cloned()
765 +
                .collect(),
766 +
            edits: old
767 +
                .edits
768 +
                .iter()
769 +
                .filter(|e| removed.contains(&e.node()))
770 +
                .cloned()
771 +
                .collect(),
548 772
            kind: ChangeKind::Normal,
549 773
        };
550 774
        let mut c2 = old.clone();
…
12 unchanged lines
563 787
            return fail("the named paths are not touched by that change");
564 788
        }
565 789
        if c2.tree_ops.is_empty() && c2.edits.is_empty() {
566 -
            return fail("everything in that change is under the named paths; use `oxy abandon` instead");
790 +
            return fail(
791 +
                "everything in that change is under the named paths; use `oxy abandon` instead",
792 +
            );
567 793
        }
568 794
        let mut d1: BTreeSet<ChangeHash> = referenced_changes(&c1, &beneath);
569 795
        d1.extend(old.deps.iter().copied());
…
3 unchanged lines
573 799
        d2.extend(old.deps.iter().copied());
574 800
        c2.deps = d2.into_iter().collect();
575 801
        c2.normalize();
576 -
        let corr = Correspondence { atoms: Correspondence::identity(&old).atoms, ops: ops_map };
577 -
        let h2 = self.rewrite_change(hash, Some(&c2), &corr, &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")))?.unwrap();
802 +
        let corr = Correspondence {
803 +
            atoms: Correspondence::identity(&old).atoms,
804 +
            ops: ops_map,
805 +
        };
806 +
        let h2 = self
807 +
            .rewrite_change(
808 +
                hash,
809 +
                Some(&c2),
810 +
                &corr,
811 +
                &format!("oxy split {} {}", old.change_id.short(8), paths.join(" ")),
812 +
            )?
813 +
            .unwrap();
578 814
        let h1 = self.store_signed(&c1)?;
579 815
        self.add_to_bookmark(h1)?;
580 816
        Ok((h1, h2))
…
17 unchanged lines
598 834
            return fail("cannot squash into the working copy");
599 835
        }
600 836
        let closure = self.load_state(&[*into])?;
601 -
        let outside: Vec<String> = src.deps.iter().filter(|d| *d != into && !closure.contains(d)).map(|d| d.short(12)).collect();
837 +
        let outside: Vec<String> = src
838 +
            .deps
839 +
            .iter()
840 +
            .filter(|d| *d != into && !closure.contains(d))
841 +
            .map(|d| d.short(12))
842 +
            .collect();
602 843
        if !outside.is_empty() {
603 844
            return fail(format!("{} depends on {} which #{} does not; squash those first or squash into a later change", src.change_id.short(8), outside.join(", "), into.short(12)));
604 845
        }
605 846
        let ok_change = |h: &ChangeHash| *h == *into || closure.contains(h);
606 847
        let ok_node = |n: &NodeId| n.is_root() || closure.tree().introducer(n).is_some();
607 -
        let too_new = |what: &str| RepoError::Msg(format!("{what} was introduced after change #{}; squash into a later change instead", into.short(12)));
608 -
        let src_added: BTreeSet<NodeId> = src.tree_ops.iter().filter_map(|o| if let TreeOp::AddNode { node, .. } = o { Some(*node) } else { None }).collect();
848 +
        let too_new = |what: &str| {
849 +
            RepoError::Msg(format!(
850 +
                "{what} was introduced after change #{}; squash into a later change instead",
851 +
                into.short(12)
852 +
            ))
853 +
        };
854 +
        let src_added: BTreeSet<NodeId> = src
855 +
            .tree_ops
856 +
            .iter()
857 +
            .filter_map(|o| {
858 +
                if let TreeOp::AddNode { node, .. } = o {
859 +
                    Some(*node)
860 +
                } else {
861 +
                    None
862 +
                }
863 +
            })
864 +
            .collect();
609 865
        for op in &src.tree_ops {
610 866
            match op {
611 867
                TreeOp::AddNode { parent, .. } => {
…
6 unchanged lines
618 874
                        return Err(too_new("a removed file"));
619 875
                    }
620 876
                }
621 -
                TreeOp::Move { node, old, parent, .. } => {
622 -
                    if !ok_node(node) || !ok_change(&old.change) || (!ok_node(parent) && !src_added.contains(parent)) {
877 +
                TreeOp::Move {
878 +
                    node, old, parent, ..
879 +
                } => {
880 +
                    if !ok_node(node)
881 +
                        || !ok_change(&old.change)
882 +
                        || (!ok_node(parent) && !src_added.contains(parent))
883 +
                    {
623 884
                        return Err(too_new("a moved file"));
624 885
                    }
625 886
                }
…
10 unchanged lines
636 897
                return Err(too_new("an edited file"));
637 898
            }
638 899
            let refs: Vec<ChangeHash> = match e {
639 -
                Edit::Insert { after, before, .. } => [after, before].iter().filter_map(|r| if let AtomRef::Atom { change, .. } = r { Some(*change) } else { None }).collect(),
900 +
                Edit::Insert { after, before, .. } => [after, before]
901 +
                    .iter()
902 +
                    .filter_map(|r| {
903 +
                        if let AtomRef::Atom { change, .. } = r {
904 +
                            Some(*change)
905 +
                        } else {
906 +
                            None
907 +
                        }
908 +
                    })
909 +
                    .collect(),
640 910
                Edit::Delete { atoms, .. } => atoms.iter().map(|r| r.change).collect(),
641 911
                Edit::Replace { .. } => vec![],
642 912
            };
…
12 unchanged lines
655 925
        let op_offset = dst.tree_ops.len() as u32;
656 926
        let fold_op = |r: OpRef| -> OpRef {
657 927
            if r.change == *into {
658 -
                OpRef { change: ObjectRef::ZERO, index: r.index }
928 +
                OpRef {
929 +
                    change: ObjectRef::ZERO,
930 +
                    index: r.index,
931 +
                }
659 932
            } else if r.change.is_zero() {
660 -
                OpRef { change: ObjectRef::ZERO, index: r.index + op_offset }
933 +
                OpRef {
934 +
                    change: ObjectRef::ZERO,
935 +
                    index: r.index + op_offset,
936 +
                }
661 937
            } else {
662 938
                r
663 939
            }
…
1 unchanged lines
665 941
        let mut tree_ops = dst.tree_ops.clone();
666 942
        for op in &src.tree_ops {
667 943
            tree_ops.push(match op {
668 -
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: fold_op(*placement) },
669 -
                TreeOp::Move { node, old, parent, name } => TreeOp::Move { node: *node, old: fold_op(*old), parent: *parent, name: name.clone() },
670 -
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: fold_op(*old), mode: *mode },
944 +
                TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode {
945 +
                    node: *node,
946 +
                    placement: fold_op(*placement),
947 +
                },
948 +
                TreeOp::Move {
949 +
                    node,
950 +
                    old,
951 +
                    parent,
952 +
                    name,
953 +
                } => TreeOp::Move {
954 +
                    node: *node,
955 +
                    old: fold_op(*old),
956 +
                    parent: *parent,
957 +
                    name: name.clone(),
958 +
                },
959 +
                TreeOp::SetMode { node, old, mode } => TreeOp::SetMode {
960 +
                    node: *node,
961 +
                    old: fold_op(*old),
962 +
                    mode: *mode,
963 +
                },
671 964
                add => add.clone(),
672 965
            });
673 966
        }
674 967
        let mut edits = dst.edits.clone();
675 968
        for e in &src.edits {
676 969
            edits.push(match e {
677 -
                Edit::Insert { node, after, before, lines } => Edit::Insert {
970 +
                Edit::Insert {
971 +
                    node,
972 +
                    after,
973 +
                    before,
974 +
                    lines,
975 +
                } => Edit::Insert {
678 976
                    node: *node,
679 -
                    after: fold_atom(*after, into, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
680 -
                    before: fold_atom(*before, into, &per_node, node).ok_or_else(|| too_new("an anchor line"))?,
977 +
                    after: fold_atom(*after, into, &per_node, node)
978 +
                        .ok_or_else(|| too_new("an anchor line"))?,
979 +
                    before: fold_atom(*before, into, &per_node, node)
980 +
                        .ok_or_else(|| too_new("an anchor line"))?,
681 981
                    lines: lines.clone(),
682 982
                },
683 983
                Edit::Delete { node, atoms } => Edit::Delete {
…
2 unchanged lines
686 986
                        .iter()
687 987
                        .map(|r| {
688 988
                            if r.change == *into {
689 -
                                AtomRange { change: ObjectRef::ZERO, from: r.from, to: r.to }
989 +
                                AtomRange {
990 +
                                    change: ObjectRef::ZERO,
991 +
                                    from: r.from,
992 +
                                    to: r.to,
993 +
                                }
690 994
                            } else if r.change.is_zero() {
691 995
                                let off = per_node.get(node).copied().unwrap_or(0);
692 -
                                AtomRange { change: ObjectRef::ZERO, from: r.from + off, to: r.to + off }
996 +
                                AtomRange {
997 +
                                    change: ObjectRef::ZERO,
998 +
                                    from: r.from + off,
999 +
                                    to: r.to + off,
1000 +
                                }
693 1001
                            } else {
694 1002
                                *r
695 1003
                            }
…
11 unchanged lines
707 1015
            message: dst.message.clone(),
708 1016
            tree_ops,
709 1017
            edits,
710 -
            kind: if dst.kind == ChangeKind::Resolution || src.kind == ChangeKind::Resolution { ChangeKind::Resolution } else { ChangeKind::Normal },
1018 +
            kind: if dst.kind == ChangeKind::Resolution || src.kind == ChangeKind::Resolution {
1019 +
                ChangeKind::Resolution
1020 +
            } else {
1021 +
                ChangeKind::Normal
1022 +
            },
711 1023
        };
712 1024
        let beneath = self.load_state(&dst.deps)?;
713 1025
        let mut deps: BTreeSet<ChangeHash> = dst.deps.iter().copied().collect();
714 -
        deps.extend(referenced_changes(&new, &beneath).into_iter().filter(|h| *h != *into && *h != *c));
1026 +
        deps.extend(
1027 +
            referenced_changes(&new, &beneath)
1028 +
                .into_iter()
1029 +
                .filter(|h| *h != *into && *h != *c),
1030 +
        );
715 1031
        deps.extend(src.deps.iter().filter(|d| **d != *into).copied());
716 1032
        new.deps = deps.into_iter().collect();
717 1033
        new.normalize();
718 1034
719 1035
        // Dependents of dst keep their indices; dependents of src follow its
720 1036
        // atoms and operations to their shifted places in the new change.
721 -
        let cmd = format!("oxy squash {} --into {}", src.change_id.short(8), dst.change_id.short(8));
722 -
        let dst_new = self.rewrite_change(into, Some(&new), &Correspondence::identity(&dst), &cmd)?.unwrap();
1037 +
        let cmd = format!(
1038 +
            "oxy squash {} --into {}",
1039 +
            src.change_id.short(8),
1040 +
            dst.change_id.short(8)
1041 +
        );
1042 +
        let dst_new = self
1043 +
            .rewrite_change(into, Some(&new), &Correspondence::identity(&dst), &cmd)?
1044 +
            .unwrap();
723 1045
        // src was rewritten along with the other dependents of dst; find it by ChangeId.
724 1046
        let (wc_hash, _) = self.working_copy()?;
725 1047
        let mut all = self.bookmark_heads()?;
726 1048
        all.push(wc_hash);
727 1049
        let state = self.load_state(&all)?;
728 -
        let src_now = state.changes().find(|(_, m)| m.change_id == src.change_id).map(|(h, _)| *h).unwrap_or(*c);
1050 +
        let src_now = state
1051 +
            .changes()
1052 +
            .find(|(_, m)| m.change_id == src.change_id)
1053 +
            .map(|(h, _)| *h)
1054 +
            .unwrap_or(*c);
729 1055
        let src_change = self.store.get_change(&src_now)?;
730 -
        let mut corr = Correspondence { atoms: HashMap::new(), ops: HashMap::new() };
1056 +
        let mut corr = Correspondence {
1057 +
            atoms: HashMap::new(),
1058 +
            ops: HashMap::new(),
1059 +
        };
731 1060
        let mut counts: HashMap<NodeId, u32> = HashMap::new();
732 1061
        for e in &src_change.edits {
733 1062
            if let Edit::Insert { node, lines, .. } = e {
…
23 unchanged lines
757 1086
        }
758 1087
        let target = self.load_state(onto)?;
759 1088
        if target.contains(c) {
760 -
            return fail(format!("#{} is already part of the target state", c.short(12)));
1089 +
            return fail(format!(
1090 +
                "#{} is already part of the target state",
1091 +
                c.short(12)
1092 +
            ));
761 1093
        }
762 1094
        let onto = target.maximal(onto);
763 1095
        let target_snapshot = target.materialize();
…
2 unchanged lines
766 1098
        // shows is what the re-derived change must produce.
767 1099
        let mut with_c = target.clone();
768 1100
        if with_c.add(old.clone()).is_err() {
769 -
            return fail(format!("the target state lacks dependencies of #{}; rebase onto a state that has them", c.short(12)));
1101 +
            return fail(format!(
1102 +
                "the target state lacks dependencies of #{}; rebase onto a state that has them",
1103 +
                c.short(12)
1104 +
            ));
770 1105
        }
771 1106
        let after = with_c.materialize();
772 1107
        // The paths whose file differs between the target and the target with c.
773 1108
        let mut touched: BTreeSet<String> = BTreeSet::new();
774 1109
        for p in target_snapshot.files.keys().chain(after.files.keys()) {
775 1110
            match (target_snapshot.files.get(p), after.files.get(p)) {
776 -
                (Some(a), Some(b)) if a.kind == b.kind && a.mode == b.mode && a.content == b.content => {}
1111 +
                (Some(a), Some(b))
1112 +
                    if a.kind == b.kind && a.mode == b.mode && a.content == b.content => {}
777 1113
                _ => {
778 1114
                    touched.insert(p.clone());
779 1115
                }
780 1116
            }
781 1117
        }
782 1118
        for p in &touched {
783 -
            if target_snapshot.files.get(p).is_some_and(|f| f.is_conflicted()) || after.files.get(p).is_some_and(|f| f.is_conflicted()) {
1119 +
            if target_snapshot
1120 +
                .files
1121 +
                .get(p)
1122 +
                .is_some_and(|f| f.is_conflicted())
1123 +
                || after.files.get(p).is_some_and(|f| f.is_conflicted())
1124 +
            {
784 1125
                return fail(format!("{p} would be conflicted; resolve the target state or the conflict with #{} first", c.short(12)));
785 1126
            }
786 1127
        }
…
30 unchanged lines
817 1158
        }
818 1159
        let built = self.build_from_entries(&target, &target_snapshot, reuse, &entries)?;
819 1160
        if built.tree_ops.is_empty() && built.edits.is_empty() {
820 -
            return fail("rebasing would leave the change empty; the target already has its effect");
1161 +
            return fail(
1162 +
                "rebasing would leave the change empty; the target already has its effect",
1163 +
            );
821 1164
        }
822 1165
        let mut new = Change {
823 1166
            change_id: old.change_id,
…
3 unchanged lines
827 1170
            message: old.message.clone(),
828 1171
            tree_ops: built.tree_ops,
829 1172
            edits: built.edits,
830 -
            kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
1173 +
            kind: if built.resolves {
1174 +
                ChangeKind::Resolution
1175 +
            } else {
1176 +
                ChangeKind::Normal
1177 +
            },
831 1178
        };
832 1179
        let mut deps: BTreeSet<ChangeHash> = onto.iter().copied().collect();
833 1180
        deps.extend(referenced_changes(&new, &target));
…
1 unchanged lines
835 1182
        new.deps = deps.into_iter().collect();
836 1183
        new.normalize();
837 1184
        let corr = Correspondence::by_content(&old, &new);
838 -
        let cmd = format!("oxy rebase {} --onto {}", old.change_id.short(8), onto.iter().map(|h| h.short(12)).collect::<Vec<_>>().join(" "));
1185 +
        let cmd = format!(
1186 +
            "oxy rebase {} --onto {}",
1187 +
            old.change_id.short(8),
1188 +
            onto.iter()
1189 +
                .map(|h| h.short(12))
1190 +
                .collect::<Vec<_>>()
1191 +
                .join(" ")
1192 +
        );
839 1193
        Ok(self.rewrite_change(c, Some(&new), &corr, &cmd)?.unwrap())
840 1194
    }
841 1195
}
842 -
modified crates/oxy-core/src/repo/hooks.rs +33 −6
…
46 unchanged lines
47 47
    pub fn run_hook(&self, event: &str, input: &str) -> Result<HookOutcome> {
48 48
        let path = self.hook_path(event);
49 49
        if !path.is_file() {
50 -
            return Ok(HookOutcome { ran: false, stdout: String::new() });
50 +
            return Ok(HookOutcome {
51 +
                ran: false,
52 +
                stdout: String::new(),
53 +
            });
51 54
        }
52 55
        let mut child = Command::new(&path)
53 56
            .current_dir(&self.root)
…
11 unchanged lines
65 68
        let stdout = String::from_utf8_lossy(&out.stdout).to_string();
66 69
        if !out.status.success() {
67 70
            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
68 -
            return fail(format!("hook {event} refused: {}", if stderr.is_empty() { "(no message)".to_string() } else { stderr }));
71 +
            return fail(format!(
72 +
                "hook {event} refused: {}",
73 +
                if stderr.is_empty() {
74 +
                    "(no message)".to_string()
75 +
                } else {
76 +
                    stderr
77 +
                }
78 +
            ));
69 79
        }
70 80
        Ok(HookOutcome { ran: true, stdout })
71 81
    }
72 82
73 83
    /// Extra dependencies from the `semantic-deps` hook (spec §8.1, §20).
74 -
    pub(crate) fn semantic_deps(&self, change_id: &ChangeId, touched: &[String], candidates: &[(ChangeId, ChangeHash, Vec<String>)]) -> Result<Vec<ChangeHash>> {
84 +
    pub(crate) fn semantic_deps(
85 +
        &self,
86 +
        change_id: &ChangeId,
87 +
        touched: &[String],
88 +
        candidates: &[(ChangeId, ChangeHash, Vec<String>)],
89 +
    ) -> Result<Vec<ChangeHash>> {
75 90
        if !self.config.semantic_deps {
76 91
            return Ok(vec![]);
77 92
        }
…
1 unchanged lines
79 94
            "{{\"change_id\":{},\"touched_paths\":{},\"candidate_deps\":{}}}",
80 95
            json_str(&change_id.to_hex()),
81 96
            json_array(touched.iter().map(|p| json_str(p))),
82 -
            json_array(candidates.iter().map(|(id, h, paths)| format!("{{\"change_id\":{},\"hash\":{},\"paths\":{}}}", json_str(&id.to_hex()), json_str(&h.to_hex()), json_array(paths.iter().map(|p| json_str(p))))))
97 +
            json_array(candidates.iter().map(|(id, h, paths)| format!(
98 +
                "{{\"change_id\":{},\"hash\":{},\"paths\":{}}}",
99 +
                json_str(&id.to_hex()),
100 +
                json_str(&h.to_hex()),
101 +
                json_array(paths.iter().map(|p| json_str(p)))
102 +
            )))
83 103
        );
84 104
        let out = self.run_hook("semantic-deps", &input)?;
85 105
        if !out.ran {
86 106
            return Ok(vec![]);
87 107
        }
88 108
        // Accept every 64-hex token after "deps"; any other shape is ignored.
89 -
        let body = out.stdout.split_once("\"deps\"").map(|(_, rest)| rest.to_string()).unwrap_or_default();
109 +
        let body = out
110 +
            .stdout
111 +
            .split_once("\"deps\"")
112 +
            .map(|(_, rest)| rest.to_string())
113 +
            .unwrap_or_default();
90 114
        let mut deps = Vec::new();
91 115
        let bytes = body.as_bytes();
92 116
        let mut i = 0;
93 117
        while i + 64 <= bytes.len() {
94 -
            if bytes[i..i + 64].iter().all(|b| b.is_ascii_hexdigit()) && (i == 0 || !bytes[i - 1].is_ascii_hexdigit()) && (i + 64 == bytes.len() || !bytes[i + 64].is_ascii_hexdigit()) {
118 +
            if bytes[i..i + 64].iter().all(|b| b.is_ascii_hexdigit())
119 +
                && (i == 0 || !bytes[i - 1].is_ascii_hexdigit())
120 +
                && (i + 64 == bytes.len() || !bytes[i + 64].is_ascii_hexdigit())
121 +
            {
95 122
                if let Some(h) = ObjectRef::from_hex(&body[i..i + 64]) {
96 123
                    deps.push(h);
97 124
                }
…
8 unchanged lines
modified crates/oxy-core/src/repo/identity.rs +139 −28
…
7 unchanged lines
8 8
    Unsigned,
9 9
    /// `revoked` marks a signature that verifies but whose key has since
10 10
    /// been revoked by policy or by a revocation this repository recorded.
11 -
    Valid { key: KeyId, name: String, email: String, sealed: bool, revoked: bool },
11 +
    Valid {
12 +
        key: KeyId,
13 +
        name: String,
14 +
        email: String,
15 +
        sealed: bool,
16 +
        revoked: bool,
17 +
    },
12 18
    Invalid(String),
13 19
}
14 20
…
13 unchanged lines
28 34
29 35
    /// Generate a composite key pair, store its identity object, keep the
30 36
    /// secret in the key directory, and record the author at `scope`.
31 -
    pub fn create_identity(&mut self, name: &str, email: &str, scope: IdentityScope) -> Result<(KeyId, Identity)> {
37 +
    pub fn create_identity(
38 +
        &mut self,
39 +
        name: &str,
40 +
        email: &str,
41 +
        scope: IdentityScope,
42 +
    ) -> Result<(KeyId, Identity)> {
32 43
        self.create_identity_with(name, email, scope, None)
33 44
    }
34 45
35 46
    /// `create_identity` with an expiry (milliseconds since the epoch).
36 -
    pub fn create_identity_with(&mut self, name: &str, email: &str, scope: IdentityScope, expires: Option<u64>) -> Result<(KeyId, Identity)> {
47 +
    pub fn create_identity_with(
48 +
        &mut self,
49 +
        name: &str,
50 +
        email: &str,
51 +
        scope: IdentityScope,
52 +
        expires: Option<u64>,
53 +
    ) -> Result<(KeyId, Identity)> {
37 54
        let sk = SecretKey::generate();
38 55
        let mut id = sk.identity(name, email, Hlc::now().wall_ms);
39 56
        id.expires = expires;
…
9 unchanged lines
49 66
        fs::create_dir_all(&self.keys_dir)?;
50 67
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
51 68
        write_atomic(&path, &sk.encode(&key))?;
52 -
        write_atomic(&self.keys_dir.join(format!("{}.identity", key.to_hex())), &id.encode())?;
69 +
        write_atomic(
70 +
            &self.keys_dir.join(format!("{}.identity", key.to_hex())),
71 +
            &id.encode(),
72 +
        )?;
53 73
        #[cfg(unix)]
54 74
        {
55 75
            use std::os::unix::fs::PermissionsExt;
…
22 unchanged lines
78 98
79 99
    /// The scope that holds the configured author key.
80 100
    fn author_scope(&self) -> IdentityScope {
81 -
        if self.config.repo_layer().contains_key("author.key") { IdentityScope::Repo } else { IdentityScope::User }
101 +
        if self.config.repo_layer().contains_key("author.key") {
102 +
            IdentityScope::Repo
103 +
        } else {
104 +
            IdentityScope::User
105 +
        }
82 106
    }
83 107
84 108
    /// Replace the configured identity by `new` signed with `sk`: store it,
85 109
    /// record a `rotate` attestation signed by the old key, move the old
86 110
    /// key's policy grants to the new key, and switch the author config.
87 -
    fn replace_identity(&mut self, new: Identity, sk: SecretKey, command: &str) -> Result<Rotation> {
111 +
    fn replace_identity(
112 +
        &mut self,
113 +
        new: Identity,
114 +
        sk: SecretKey,
115 +
        command: &str,
116 +
    ) -> Result<Rotation> {
88 117
        let Some((old, old_sk)) = self.secret_key()? else {
89 118
            return fail("no secret key: run `oxy identity create` first");
90 119
        };
…
1 unchanged lines
92 121
            return fail("the successor identity must name the current key as previous");
93 122
        }
94 123
        let new_key = self.install_key(&new, &sk)?;
95 -
        let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Rotate { new: new_key } };
124 +
        let attest = Attest {
125 +
            repo: self.id()?,
126 +
            hlc: Hlc::now(),
127 +
            body: AttestBody::Rotate { new: new_key },
128 +
        };
96 129
        let r = self.store.put_attest(&attest)?;
97 -
        self.store.put_signature(&r, &old_sk.sign(Domain::Attest, &old, &r))?;
130 +
        self.store
131 +
            .put_signature(&r, &old_sk.sign(Domain::Attest, &old, &r))?;
98 132
        let policy = match self.current_policy()? {
99 133
            Some((_, sp)) if sp.policy.mentions(&old) => {
100 134
                let mut p = self.next_policy()?;
…
3 unchanged lines
104 138
            _ => None,
105 139
        };
106 140
        self.set_author(&new, &new_key, self.author_scope())?;
107 -
        Ok(Rotation { old, new: new_key, identity: new, policy })
141 +
        Ok(Rotation {
142 +
            old,
143 +
            new: new_key,
144 +
            identity: new,
145 +
            policy,
146 +
        })
108 147
    }
109 148
110 149
    /// Rotate to a fresh key pair; the new identity names the old one as
…
38 unchanged lines
149 188
        let mut delegates = cur.delegates.clone();
150 189
        delegates.push(dk);
151 190
        let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms);
152 -
        self.replace_identity(id, sk, &format!("oxy identity delegate add {}", dk.short(12)))
191 +
        self.replace_identity(
192 +
            id,
193 +
            sk,
194 +
            &format!("oxy identity delegate add {}", dk.short(12)),
195 +
        )
153 196
    }
154 197
155 198
    /// Withdraw a delegation; re-issues the identity with the same keys.
…
5 unchanged lines
161 204
        if !cur.delegates.contains(delegate) {
162 205
            return fail(format!("{} is not a delegate", delegate.short(12)));
163 206
        }
164 -
        let delegates = cur.delegates.iter().copied().filter(|d| d != delegate).collect();
207 +
        let delegates = cur
208 +
            .delegates
209 +
            .iter()
210 +
            .copied()
211 +
            .filter(|d| d != delegate)
212 +
            .collect();
165 213
        let id = cur.successor(delegates, cur.expires, Hlc::now().wall_ms);
166 -
        self.replace_identity(id, sk, &format!("oxy identity delegate remove {}", delegate.short(12)))
214 +
        self.replace_identity(
215 +
            id,
216 +
            sk,
217 +
            &format!("oxy identity delegate remove {}", delegate.short(12)),
218 +
        )
167 219
    }
168 220
169 221
    /// Revoke `key` (your own, or any key when you hold a policy root):
…
8 unchanged lines
178 230
        if *key != own && !is_root {
179 231
            return fail("only the key's holder or a policy root can revoke it");
180 232
        }
181 -
        let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Revoke { key: *key, reason: reason.into() } };
233 +
        let attest = Attest {
234 +
            repo: self.id()?,
235 +
            hlc: Hlc::now(),
236 +
            body: AttestBody::Revoke {
237 +
                key: *key,
238 +
                reason: reason.into(),
239 +
            },
240 +
        };
182 241
        let r = self.store.put_attest(&attest)?;
183 -
        self.store.put_signature(&r, &sk.sign(Domain::Attest, &own, &r))?;
242 +
        self.store
243 +
            .put_signature(&r, &sk.sign(Domain::Attest, &own, &r))?;
184 244
        let dir = self.oxy.join("revoked");
185 245
        fs::create_dir_all(&dir)?;
186 246
        write_atomic(&dir.join(key.to_hex()), r.to_hex().as_bytes())?;
…
8 unchanged lines
195 255
        if self.oxy.join("revoked").join(key.to_hex()).exists() {
196 256
            return Ok(true);
197 257
        }
198 -
        Ok(self.current_policy()?.is_some_and(|(_, sp)| sp.policy.is_revoked(key)))
258 +
        Ok(self
259 +
            .current_policy()?
260 +
            .is_some_and(|(_, sp)| sp.policy.is_revoked(key)))
199 261
    }
200 262
201 263
    /// The secret key for `author.key`, if configured and present on disk.
202 264
    /// The identity object is kept beside the secret so a repository that has
203 265
    /// never seen it (a fresh clone) can store it when it first signs.
204 266
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
205 -
        let Some(key) = self.config.author_key else { return Ok(None) };
267 +
        let Some(key) = self.config.author_key else {
268 +
            return Ok(None);
269 +
        };
206 270
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
207 271
        match fs::read(&path) {
208 272
            Ok(b) => {
209 273
                let (k, sk) = SecretKey::decode(&b)?;
210 274
                if k != key {
211 -
                    return fail(format!("{} holds a different key than its name", path.display()));
275 +
                    return fail(format!(
276 +
                        "{} holds a different key than its name",
277 +
                        path.display()
278 +
                    ));
212 279
                }
213 280
                if !self.store.has(&key) {
214 281
                    let id_path = self.keys_dir.join(format!("{}.identity", key.to_hex()));
…
35 unchanged lines
250 317
        let mut last_err = String::new();
251 318
        for sig in &sigs {
252 319
            match self.check_signer(&change, sig, Domain::Change, hash) {
253 -
                Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false, revoked: self.is_revoked(&sig.key)? }),
320 +
                Ok(id) => {
321 +
                    return Ok(SignatureStatus::Valid {
322 +
                        key: sig.key,
323 +
                        name: id.name,
324 +
                        email: id.email,
325 +
                        sealed: false,
326 +
                        revoked: self.is_revoked(&sig.key)?,
327 +
                    })
328 +
                }
254 329
                Err(e) => last_err = e,
255 330
            }
256 331
        }
…
1 unchanged lines
258 333
    }
259 334
260 335
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
261 -
    fn check_signer(&self, change: &Change, sig: &Signature, domain: Domain, target: &ObjectRef) -> std::result::Result<Identity, String> {
262 -
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
263 -
        let authorised =
264 -
            sig.key == change.author || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
336 +
    fn check_signer(
337 +
        &self,
338 +
        change: &Change,
339 +
        sig: &Signature,
340 +
        domain: Domain,
341 +
        target: &ObjectRef,
342 +
    ) -> std::result::Result<Identity, String> {
343 +
        let id = self
344 +
            .store
345 +
            .get_identity(&sig.key)
346 +
            .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
347 +
        let authorised = sig.key == change.author
348 +
            || self
349 +
                .store
350 +
                .get_identity(&change.author)
351 +
                .map(|a| a.delegates.contains(&sig.key))
352 +
                .unwrap_or(false);
265 353
        if !authorised {
266 -
            return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12)));
354 +
            return Err(format!(
355 +
                "key {} is neither the author nor a delegate",
356 +
                sig.key.short(12)
357 +
            ));
267 358
        }
268 359
        if id.is_expired_at(change.hlc.wall_ms) {
269 -
            return Err(format!("key {} had expired when the change was made", sig.key.short(12)));
360 +
            return Err(format!(
361 +
                "key {} had expired when the change was made",
362 +
                sig.key.short(12)
363 +
            ));
270 364
        }
271 365
        verify_signature(&id, sig, domain, target).map_err(|e| e.to_string())?;
272 366
        Ok(id)
…
13 unchanged lines
286 380
        };
287 381
        for seal_ref in self.store.seals_for(hash)? {
288 382
            let attest = self.store.get_attest(&seal_ref)?;
289 -
            let AttestBody::Seal { hashes } = &attest.body else { continue };
383 +
            let AttestBody::Seal { hashes } = &attest.body else {
384 +
                continue;
385 +
            };
290 386
            if !hashes.contains(hash) {
291 387
                continue;
292 388
            }
293 389
            for sig in self.store.signatures(&seal_ref)? {
294 390
                match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) {
295 -
                    Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true, revoked: self.is_revoked(&sig.key)? }),
391 +
                    Ok(id) => {
392 +
                        return Ok(SignatureStatus::Valid {
393 +
                            key: sig.key,
394 +
                            name: id.name,
395 +
                            email: id.email,
396 +
                            sealed: true,
397 +
                            revoked: self.is_revoked(&sig.key)?,
398 +
                        })
399 +
                    }
296 400
                    Err(e) => last_err = e,
297 401
                }
298 402
            }
299 403
        }
300 -
        if last_err.is_empty() { Ok(SignatureStatus::Unsigned) } else { Ok(SignatureStatus::Invalid(last_err)) }
404 +
        if last_err.is_empty() {
405 +
            Ok(SignatureStatus::Unsigned)
406 +
        } else {
407 +
            Ok(SignatureStatus::Invalid(last_err))
408 +
        }
301 409
    }
302 410
303 411
    /// Store and sign an attestation with the configured key.
…
9 unchanged lines
313 421
                self.store.index_seal(h, &r)?;
314 422
            }
315 423
        }
316 -
        Ok(SignedAttest { attest: attest.clone(), sigs: vec![sig] })
424 +
        Ok(SignedAttest {
425 +
            attest: attest.clone(),
426 +
            sigs: vec![sig],
427 +
        })
317 428
    }
318 429
}
modified crates/oxy-core/src/repo/inspect.rs +116 −27
…
21 unchanged lines
22 22
    /// More `good`/`bad` marks are needed before the search can start.
23 23
    NeedMarks { good: usize, bad: usize },
24 24
    /// A state is checked out for testing.
25 -
    Testing { heads: Vec<ChangeHash>, remaining: usize, steps_left: u32 },
25 +
    Testing {
26 +
        heads: Vec<ChangeHash>,
27 +
        remaining: usize,
28 +
        steps_left: u32,
29 +
    },
26 30
    /// The first change whose inclusion makes the state bad.
27 31
    Found { culprit: ChangeHash },
28 32
    /// Every remaining candidate was skipped.
…
17 unchanged lines
46 50
    v.iter().map(|h| h.to_hex()).collect::<Vec<_>>().join(" ")
47 51
}
48 52
fn hashes_from(s: &str) -> Vec<ChangeHash> {
49 -
    s.split_whitespace().filter_map(ObjectRef::from_hex).collect()
53 +
    s.split_whitespace()
54 +
        .filter_map(ObjectRef::from_hex)
55 +
        .collect()
50 56
}
51 57
52 58
impl Repo {
…
1 unchanged lines
54 60
    /// the working copy's base state (spec §27).
55 61
    pub fn blame(&self, path: &str) -> Result<Vec<BlameLine>> {
56 62
        let snap = self.snapshot("oxy blame")?;
57 -
        self.blame_state(path, &snap.base, &snap.base_snapshot, "the working copy's base state")
63 +
        self.blame_state(
64 +
            path,
65 +
            &snap.base,
66 +
            &snap.base_snapshot,
67 +
            "the working copy's base state",
68 +
        )
58 69
    }
59 70
60 71
    /// Blame `path` in the state with these heads (hosted repositories).
…
3 unchanged lines
64 75
        self.blame_state(path, &state, &snapshot, "that state")
65 76
    }
66 77
67 -
    fn blame_state(&self, path: &str, state: &crate::state::State, snapshot: &crate::state::Snapshot, what: &str) -> Result<Vec<BlameLine>> {
68 -
        let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in {what}")) };
78 +
    fn blame_state(
79 +
        &self,
80 +
        path: &str,
81 +
        state: &crate::state::State,
82 +
        snapshot: &crate::state::Snapshot,
83 +
        what: &str,
84 +
    ) -> Result<Vec<BlameLine>> {
85 +
        let Some(f) = snapshot.files.get(path) else {
86 +
            return fail(format!("{path} is not in {what}"));
87 +
        };
69 88
        if !matches!(f.content, FileContent::Text { .. }) {
70 89
            return fail(format!("{path} is not a text file"));
71 90
        }
…
1 unchanged lines
73 92
        for (i, ((hash, index), text)) in state.live_lines(&f.node).into_iter().enumerate() {
74 93
            let meta = state.get(&hash);
75 94
            let author = match meta {
76 -
                Some(m) if !m.author.is_zero() => self.store.get_identity(&m.author).map(|id| id.name).unwrap_or_else(|_| m.author.short(12)),
77 -
                Some(m) => m.message.lines().rev().find_map(|l| l.strip_prefix("Git-Author: ")).map(|a| a.split('<').next().unwrap_or(a).trim().to_string()).unwrap_or_else(|| "unknown".into()),
95 +
                Some(m) if !m.author.is_zero() => self
96 +
                    .store
97 +
                    .get_identity(&m.author)
98 +
                    .map(|id| id.name)
99 +
                    .unwrap_or_else(|_| m.author.short(12)),
100 +
                Some(m) => m
101 +
                    .message
102 +
                    .lines()
103 +
                    .rev()
104 +
                    .find_map(|l| l.strip_prefix("Git-Author: "))
105 +
                    .map(|a| a.split('<').next().unwrap_or(a).trim().to_string())
106 +
                    .unwrap_or_else(|| "unknown".into()),
78 107
                None => "unknown".into(),
79 108
            };
80 109
            out.push(BlameLine {
…
21 unchanged lines
102 131
            return Ok(None);
103 132
        }
104 133
        let read = |name: &str| fs::read_to_string(dir.join(name)).unwrap_or_default();
105 -
        let nums: Vec<usize> = read("range").split_whitespace().filter_map(|n| n.parse().ok()).collect();
134 +
        let nums: Vec<usize> = read("range")
135 +
            .split_whitespace()
136 +
            .filter_map(|n| n.parse().ok())
137 +
            .collect();
106 138
        Ok(Some(BisectState {
107 139
            start: hashes_from(&read("start")),
108 140
            good: hashes_from(&read("good")),
…
2 unchanged lines
111 143
            lo: nums.first().copied().unwrap_or(0),
112 144
            hi: nums.get(1).copied().unwrap_or(0),
113 145
            testing: nums.get(2).copied().filter(|_| nums.len() > 2),
114 -
            skipped: read("skipped").split_whitespace().filter_map(|n| n.parse().ok()).collect(),
146 +
            skipped: read("skipped")
147 +
                .split_whitespace()
148 +
                .filter_map(|n| n.parse().ok())
149 +
                .collect(),
115 150
        }))
116 151
    }
117 152
…
3 unchanged lines
121 156
        write_atomic(&dir.join("start"), hashes_line(&st.start).as_bytes())?;
122 157
        write_atomic(&dir.join("good"), hashes_line(&st.good).as_bytes())?;
123 158
        write_atomic(&dir.join("bad"), hashes_line(&st.bad).as_bytes())?;
124 -
        write_atomic(&dir.join("candidates"), hashes_line(&st.candidates).as_bytes())?;
159 +
        write_atomic(
160 +
            &dir.join("candidates"),
161 +
            hashes_line(&st.candidates).as_bytes(),
162 +
        )?;
125 163
        let mut range = format!("{} {}", st.lo, st.hi);
126 164
        if let Some(t) = st.testing {
127 165
            range.push_str(&format!(" {t}"));
128 166
        }
129 167
        write_atomic(&dir.join("range"), range.as_bytes())?;
130 -
        write_atomic(&dir.join("skipped"), st.skipped.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(" ").as_bytes())?;
168 +
        write_atomic(
169 +
            &dir.join("skipped"),
170 +
            st.skipped
171 +
                .iter()
172 +
                .map(|n| n.to_string())
173 +
                .collect::<Vec<_>>()
174 +
                .join(" ")
175 +
                .as_bytes(),
176 +
        )?;
131 177
        Ok(())
132 178
    }
133 179
…
4 unchanged lines
138 184
        }
139 185
        let (_, wc) = self.working_copy()?;
140 186
        let start = self.load_state(&wc.deps)?.maximal(&wc.deps);
141 -
        self.bisect_save(&BisectState { start, ..Default::default() })
187 +
        self.bisect_save(&BisectState {
188 +
            start,
189 +
            ..Default::default()
190 +
        })
142 191
    }
143 192
144 193
    pub fn bisect_reset(&self) -> Result<()> {
145 -
        let Some(st) = self.bisect_load()? else { return fail("no bisect in progress") };
194 +
        let Some(st) = self.bisect_load()? else {
195 +
            return fail("no bisect in progress");
196 +
        };
146 197
        self.checkout(&st.start, false, "oxy bisect reset")?;
147 198
        fs::remove_dir_all(self.bisect_dir())?;
148 199
        Ok(())
149 200
    }
150 201
151 202
    pub fn bisect_status(&self) -> Result<Option<BisectStep>> {
152 -
        let Some(st) = self.bisect_load()? else { return Ok(None) };
203 +
        let Some(st) = self.bisect_load()? else {
204 +
            return Ok(None);
205 +
        };
153 206
        Ok(Some(self.bisect_step(&st)))
154 207
    }
155 208
156 209
    fn bisect_step(&self, st: &BisectState) -> BisectStep {
157 210
        if st.candidates.is_empty() {
158 -
            return BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len() };
211 +
            return BisectStep::NeedMarks {
212 +
                good: st.good.len(),
213 +
                bad: st.bad.len(),
214 +
            };
159 215
        }
160 216
        if st.hi <= st.lo + 1 {
161 -
            return BisectStep::Found { culprit: st.candidates[st.lo] };
217 +
            return BisectStep::Found {
218 +
                culprit: st.candidates[st.lo],
219 +
            };
162 220
        }
163 221
        match st.testing {
164 222
            Some(_) => {
165 223
                let remaining = st.hi - st.lo;
166 -
                BisectStep::Testing { heads: self.bisect_heads(st, st.testing.unwrap()), remaining, steps_left: (remaining as f64).log2().ceil() as u32 }
224 +
                BisectStep::Testing {
225 +
                    heads: self.bisect_heads(st, st.testing.unwrap()),
226 +
                    remaining,
227 +
                    steps_left: (remaining as f64).log2().ceil() as u32,
228 +
                }
167 229
            }
168 -
            None => BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec() },
230 +
            None => BisectStep::Inconclusive {
231 +
                candidates: st.candidates[st.lo..st.hi].to_vec(),
232 +
            },
169 233
        }
170 234
    }
171 235
…
1 unchanged lines
173 237
    fn bisect_heads(&self, st: &BisectState, k: usize) -> Vec<ChangeHash> {
174 238
        let mut all = st.good.clone();
175 239
        all.extend(st.candidates[..k].iter().copied());
176 -
        self.load_state(&all).map(|s| s.maximal(&all)).unwrap_or(all)
240 +
        self.load_state(&all)
241 +
            .map(|s| s.maximal(&all))
242 +
            .unwrap_or(all)
177 243
    }
178 244
179 245
    /// Check out the midpoint of the open range, skipping indices marked
…
2 unchanged lines
182 248
        if st.hi <= st.lo + 1 {
183 249
            st.testing = None;
184 250
            self.bisect_save(st)?;
185 -
            return Ok(BisectStep::Found { culprit: st.candidates[st.lo] });
251 +
            return Ok(BisectStep::Found {
252 +
                culprit: st.candidates[st.lo],
253 +
            });
186 254
        }
187 255
        let mid = st.lo + (st.hi - st.lo) / 2;
188 256
        // Try mid, then the nearest untried indices on either side.
…
9 unchanged lines
198 266
        let Some(k) = order.into_iter().find(|k| !st.skipped.contains(k)) else {
199 267
            st.testing = None;
200 268
            self.bisect_save(st)?;
201 -
            return Ok(BisectStep::Inconclusive { candidates: st.candidates[st.lo..st.hi].to_vec() });
269 +
            return Ok(BisectStep::Inconclusive {
270 +
                candidates: st.candidates[st.lo..st.hi].to_vec(),
271 +
            });
202 272
        };
203 273
        let heads = self.bisect_heads(st, k);
204 -
        self.checkout(&heads, false, &format!("oxy bisect (testing {k} of {})", st.candidates.len()))?;
274 +
        self.checkout(
275 +
            &heads,
276 +
            false,
277 +
            &format!("oxy bisect (testing {k} of {})", st.candidates.len()),
278 +
        )?;
205 279
        st.testing = Some(k);
206 280
        self.bisect_save(st)?;
207 281
        let remaining = st.hi - st.lo;
208 -
        Ok(BisectStep::Testing { heads, remaining, steps_left: (remaining as f64).log2().ceil() as u32 })
282 +
        Ok(BisectStep::Testing {
283 +
            heads,
284 +
            remaining,
285 +
            steps_left: (remaining as f64).log2().ceil() as u32,
286 +
        })
209 287
    }
210 288
211 289
    /// Mark states good or bad. With `changes`, before the search starts,
212 290
    /// they name known-good or known-bad states; without, the state under
213 291
    /// test gets the verdict. `None` for `good` means skip (untestable).
214 292
    pub fn bisect_mark(&self, good: Option<bool>, changes: &[ChangeHash]) -> Result<BisectStep> {
215 -
        let Some(mut st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`") };
293 +
        let Some(mut st) = self.bisect_load()? else {
294 +
            return fail("no bisect in progress; run `oxy bisect start`");
295 +
        };
216 296
        if !changes.is_empty() {
217 297
            if !st.candidates.is_empty() {
218 298
                return fail("the search has started; mark the state under test with plain `oxy bisect good|bad`");
…
5 unchanged lines
224 304
            }
225 305
            if st.good.is_empty() || st.bad.is_empty() {
226 306
                self.bisect_save(&st)?;
227 -
                return Ok(BisectStep::NeedMarks { good: st.good.len(), bad: st.bad.len() });
307 +
                return Ok(BisectStep::NeedMarks {
308 +
                    good: st.good.len(),
309 +
                    bad: st.bad.len(),
310 +
                });
228 311
            }
229 312
            let good_heads = self.load_state(&st.good)?.maximal(&st.good);
230 313
            let bad_heads = self.load_state(&st.bad)?.maximal(&st.bad);
…
21 unchanged lines
252 335
    /// Automate with a command run in the working copy: exit 0 is good, 125
253 336
    /// skips, anything else is bad.
254 337
    pub fn bisect_run(&self, command: &[String]) -> Result<BisectStep> {
255 -
        let Some(st) = self.bisect_load()? else { return fail("no bisect in progress; run `oxy bisect start`") };
338 +
        let Some(st) = self.bisect_load()? else {
339 +
            return fail("no bisect in progress; run `oxy bisect start`");
340 +
        };
256 341
        if st.testing.is_none() {
257 342
            return Ok(self.bisect_step(&st));
258 343
        }
259 344
        loop {
260 -
            let status = std::process::Command::new(&command[0]).args(&command[1..]).current_dir(&self.root).status().map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?;
345 +
            let status = std::process::Command::new(&command[0])
346 +
                .args(&command[1..])
347 +
                .current_dir(&self.root)
348 +
                .status()
349 +
                .map_err(|e| RepoError::Msg(format!("running {}: {e}", command[0])))?;
261 350
            let verdict = match status.code() {
262 351
                Some(0) => Some(true),
263 352
                Some(125) => None,
…
9 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +347 −71
…
8 unchanged lines
9 9
10 10
use crate::attest::{Attest, AttestBody};
11 11
use crate::diff::{ranges, split_lines, text_edits};
12 -
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RefsResponse, RemoteOptions, RemoteRef, SignedAttest, SignedPolicy};
13 -
#[allow(unused_imports)]
14 -
use crate::subrepo::Pin;
15 12
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
16 13
use crate::identity::{verify as verify_signature, Domain, Identity, SecretKey, Signature};
17 14
use crate::ids::{ChangeId, NodeId, WorkspaceId};
18 -
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp};
15 +
use crate::objects::{
16 +
    Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp,
17 +
};
19 18
use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX};
19 +
use crate::protocol::{
20 +
    open_remote, Bundle, RefResult, RefUpdate, RefsResponse, RemoteOptions, RemoteRef,
21 +
    SignedAttest, SignedPolicy,
22 +
};
20 23
use crate::state::{FileContent, Snapshot, State};
21 24
use crate::store::{Store, StoreError};
25 +
#[allow(unused_imports)]
26 +
use crate::subrepo::Pin;
22 27
use crate::tree::OpKey;
23 28
use rand::RngCore;
24 29
use std::collections::{BTreeMap, BTreeSet, HashMap};
…
30 unchanged lines
55 60
56 61
mod bookmarks;
57 62
mod checkout;
58 -
mod subrepos;
59 63
mod checkpoint;
60 64
mod config;
61 65
mod gc;
62 66
mod git;
63 67
mod history;
64 68
mod hooks;
69 +
mod identity;
65 70
mod inspect;
66 -
mod identity;
67 71
mod policy;
68 72
mod prompt;
69 73
mod snapshot;
74 +
mod subrepos;
70 75
mod sync;
71 76
mod tags;
72 -
mod workspace;
73 77
#[cfg(test)]
74 78
mod tests;
79 +
mod workspace;
75 80
76 81
pub use bookmarks::{BookmarkInfo, MergeReport};
77 -
pub use subrepos::{SubrepoInfo, SubrepoSync};
78 82
pub use checkout::CheckoutReport;
79 83
pub use config::{parse_size, read_config_file, Config};
80 84
pub use gc::GcReport;
81 -
pub use hooks::{json_array, json_str, HookOutcome};
82 -
pub use inspect::{BisectStep, BlameLine};
83 -
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
84 85
pub use git::{GitExportReport, GitImportReport};
86 +
pub use hooks::{json_array, json_str, HookOutcome};
85 87
pub use identity::{Rotation, SignatureStatus};
88 +
pub use inspect::{BisectStep, BlameLine};
86 89
pub use policy::PolicyUpdate;
87 -
pub use snapshot::{added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes, render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus, Selection, SnapshotResult};
90 +
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
91 +
pub use snapshot::{
92 +
    added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes,
93 +
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus,
94 +
    Selection, SnapshotResult,
95 +
};
96 +
pub use subrepos::{SubrepoInfo, SubrepoSync};
88 97
pub use sync::{FetchReport, PullReport};
89 98
pub use tags::TagInfo;
90 99
pub use workspace::{SnapshotLock, WorkspaceInfo};
…
34 unchanged lines
125 134
126 135
impl FsEntry {
127 136
    pub(crate) fn new(kind: NodeKind, exec: bool, content: Vec<u8>) -> FsEntry {
128 -
        FsEntry { kind, exec, content, blob: None, content_hash: None }
137 +
        FsEntry {
138 +
            kind,
139 +
            exec,
140 +
            content,
141 +
            blob: None,
142 +
            content_hash: None,
143 +
        }
129 144
    }
130 145
}
131 146
…
13 unchanged lines
145 160
146 161
/// Marker file of a bare repository, inside `.oxy/`.
147 162
const BARE_MARKER: &str = "bare";
163 +
/// Marker of a hosted repository that has no id yet: the first push binds
164 +
/// it to the pusher's id (spec §16.1).
165 +
const UNBOUND_MARKER: &str = "unbound";
148 166
149 167
/// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else
150 168
/// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`.
…
51 unchanged lines
202 220
        let workspace = WorkspaceId::random();
203 221
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
204 222
        let store = Store::open(oxy.join("objects"))?;
205 -
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
223 +
        let repo = Repo {
224 +
            git_export_skipped: Default::default(),
225 +
            root,
226 +
            oxy,
227 +
            store,
228 +
            workspace,
229 +
            bare: false,
230 +
            keys_dir: default_keys_dir(),
231 +
            user_config: default_user_config(),
232 +
            config,
233 +
        };
206 234
        repo.register_workspace(&workspace, &repo.root)?;
207 235
208 236
        let wc = repo.new_working_copy(vec![], "");
209 237
        let wc_hash = repo.store.put_change(&wc)?;
210 238
        let mut view = View::default();
211 -
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
239 +
        view.bookmarks.insert(
240 +
            repo.config.bookmark.clone(),
241 +
            repo.store.put_state(&StateObject::new(vec![]))?,
242 +
        );
212 243
        view.workspaces.insert(workspace, wc_hash);
213 244
        repo.record_op(&[], "oxy init", view)?;
214 245
        Ok(repo)
…
22 unchanged lines
237 268
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
238 269
        config.save(&oxy.join("config"))?;
239 270
        let store = Store::open(oxy.join("objects"))?;
240 -
        let repo = Repo { git_export_skipped: Default::default(), root, oxy, store, workspace: WorkspaceId::ZERO, bare: true, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
271 +
        let repo = Repo {
272 +
            git_export_skipped: Default::default(),
273 +
            root,
274 +
            oxy,
275 +
            store,
276 +
            workspace: WorkspaceId::ZERO,
277 +
            bare: true,
278 +
            keys_dir: default_keys_dir(),
279 +
            user_config: default_user_config(),
280 +
            config,
281 +
        };
241 282
        let mut view = View::default();
242 -
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
283 +
        view.bookmarks.insert(
284 +
            repo.config.bookmark.clone(),
285 +
            repo.store.put_state(&StateObject::new(vec![]))?,
286 +
        );
243 287
        repo.record_op(&[], "oxy init --bare", view)?;
244 288
        Ok(repo)
245 289
    }
246 290
291 +
    /// A bare repository with no id yet. Its `id()` is all zero until the
292 +
    /// first push binds it (`bind_id`), so an existing repository can be
293 +
    /// pushed to a freshly created hosted one and keep its identity.
294 +
    pub fn init_bare_unbound(root: &Path) -> Result<Repo> {
295 +
        let repo = Self::init_bare_with_id(root, [0; 16])?;
296 +
        fs::remove_file(repo.oxy.join("id"))?;
297 +
        write_atomic(&repo.oxy.join(UNBOUND_MARKER), b"")?;
298 +
        Ok(repo)
299 +
    }
300 +
301 +
    pub fn is_unbound(&self) -> bool {
302 +
        self.oxy.join(UNBOUND_MARKER).is_file()
303 +
    }
304 +
305 +
    /// Give an unbound repository its id.
306 +
    pub fn bind_id(&self, id: [u8; 16]) -> Result<()> {
307 +
        if !self.is_unbound() {
308 +
            return fail("this repository already has an id");
309 +
        }
310 +
        if id == [0; 16] {
311 +
            return fail("a repository id cannot be all zero");
312 +
        }
313 +
        write_atomic(&self.oxy.join("id"), hex::encode(id).as_bytes())?;
314 +
        fs::remove_file(self.oxy.join(UNBOUND_MARKER))?;
315 +
        Ok(())
316 +
    }
317 +
247 318
    /// Whether this repository has no working copy (`init_bare`).
248 319
    pub fn is_bare(&self) -> bool {
249 320
        self.bare
…
17 unchanged lines
267 338
                }
268 339
                let config = Config::load(Some(&default_user_config()), &shared.join("config"))?;
269 340
                let store = Store::open(shared.join("objects"))?;
270 -
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy: shared, store, workspace, bare: false, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
341 +
                let mut repo = Repo {
342 +
                    git_export_skipped: Default::default(),
343 +
                    root: d.to_path_buf(),
344 +
                    oxy: shared,
345 +
                    store,
346 +
                    workspace,
347 +
                    bare: false,
348 +
                    keys_dir: default_keys_dir(),
349 +
                    user_config: default_user_config(),
350 +
                    config,
351 +
                };
271 352
                repo.apply_workspace_bookmark();
272 353
                return Ok(repo);
273 354
            }
…
4 unchanged lines
278 359
                let workspace = if bare {
279 360
                    WorkspaceId::ZERO
280 361
                } else {
281 -
                    WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?
362 +
                    WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
363 +
                        .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?
282 364
                };
283 365
                let store = Store::open(oxy.join("objects"))?;
284 -
                let mut repo = Repo { git_export_skipped: Default::default(), root: d.to_path_buf(), oxy, store, workspace, bare, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
366 +
                let mut repo = Repo {
367 +
                    git_export_skipped: Default::default(),
368 +
                    root: d.to_path_buf(),
369 +
                    oxy,
370 +
                    store,
371 +
                    workspace,
372 +
                    bare,
373 +
                    keys_dir: default_keys_dir(),
374 +
                    user_config: default_user_config(),
375 +
                    config,
376 +
                };
285 377
                if !bare {
286 378
                    repo.apply_workspace_bookmark();
287 379
                }
…
6 unchanged lines
294 386
            }
295 387
            dir = d.parent();
296 388
        }
297 -
        fail(format!("no oxy repository found at or above {}", start.display()))
389 +
        fail(format!(
390 +
            "no oxy repository found at or above {}",
391 +
            start.display()
392 +
        ))
298 393
    }
299 394
300 395
    /// Rewrite a format-1 operation log, whose bookmarks were head lists,
301 396
    /// into format 2, where each bookmark names a `state` object.
302 397
    fn migrate_format1(&self) -> Result<()> {
303 398
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
304 -
        let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
399 +
        let head = ObjectRef::from_hex(hex.trim())
400 +
            .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
305 401
        // Collect the chain root-first.
306 402
        let mut chain: Vec<Format1Op> = Vec::new();
307 403
        let mut cur = Some(head);
…
7 unchanged lines
315 411
        for old in chain {
316 412
            let mut view = View::default();
317 413
            for (name, heads) in &old.view.bookmarks {
318 -
                view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
414 +
                view.bookmarks.insert(
415 +
                    name.clone(),
416 +
                    self.store.put_state(&StateObject::new(heads.clone()))?,
417 +
                );
319 418
            }
320 419
            view.workspaces = old.view.workspaces.clone();
321 -
            let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view };
420 +
            let op = Op {
421 +
                parents: new_parent.into_iter().collect(),
422 +
                hlc: old.hlc,
423 +
                command: old.command,
424 +
                view,
425 +
            };
322 426
            new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?);
323 427
        }
324 -
        write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?;
428 +
        write_atomic(
429 +
            &self.oxy.join("ops").join("head"),
430 +
            new_parent.unwrap().to_hex().as_bytes(),
431 +
        )?;
325 432
        write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?;
326 433
        Ok(())
327 434
    }
…
9 unchanged lines
337 444
338 445
    /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use.
339 446
    pub fn id(&self) -> Result<[u8; 16]> {
447 +
        if self.is_unbound() {
448 +
            return Ok([0; 16]);
449 +
        }
340 450
        let path = self.oxy.join("id");
341 451
        match fs::read_to_string(&path) {
342 -
            Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())),
452 +
            Ok(h) => hex::decode(h.trim())
453 +
                .ok()
454 +
                .and_then(|v| v.try_into().ok())
455 +
                .ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())),
343 456
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
344 457
                let mut id = [0u8; 16];
345 458
                rand::rngs::OsRng.fill_bytes(&mut id);
…
30 unchanged lines
376 489
    /// The workspace's current bookmark (spec §10.1) overrides the
377 490
    /// configured default when `oxy bookmark switch` has recorded one.
378 491
    fn apply_workspace_bookmark(&mut self) {
379 -
        if let Ok(b) = fs::read_to_string(self.oxy.join("workspaces").join(self.workspace.to_hex()).join("bookmark")) {
492 +
        if let Ok(b) = fs::read_to_string(
493 +
            self.oxy
494 +
                .join("workspaces")
495 +
                .join(self.workspace.to_hex())
496 +
                .join("bookmark"),
497 +
        ) {
380 498
            let b = b.trim();
381 499
            if !b.is_empty() {
382 500
                self.config.bookmark = b.to_string();
…
41 unchanged lines
424 542
425 543
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
426 544
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
427 -
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
545 +
        let r = ObjectRef::from_hex(hex.trim())
546 +
            .ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
428 547
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
429 548
        Ok((r, op))
430 549
    }
…
3 unchanged lines
434 553
    }
435 554
436 555
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
437 -
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
556 +
        let op = Op {
557 +
            parents: parents.to_vec(),
558 +
            hlc: Hlc::now(),
559 +
            command: command.into(),
560 +
            view,
561 +
        };
438 562
        let r = self.store.put(ObjectType::Op, &op.encode())?;
439 563
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
440 564
        Ok(r)
…
22 unchanged lines
463 587
    pub fn undo(&self) -> Result<Op> {
464 588
        let (_, mut op) = self.head_op()?;
465 589
        while op.is_snapshot() {
466 -
            let Some(parent) = op.parents.first() else { break };
590 +
            let Some(parent) = op.parents.first() else {
591 +
                break;
592 +
            };
467 593
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
468 594
        }
469 595
        let Some(parent) = op.parents.first() else {
470 596
            return fail("nothing to undo");
471 597
        };
472 598
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
473 -
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
599 +
        write_atomic(
600 +
            &self.oxy.join("ops").join("head"),
601 +
            parent.to_hex().as_bytes(),
602 +
        )?;
474 603
        self.drop_manifest();
475 604
        Ok(parent_op)
476 605
    }
…
2 unchanged lines
479 608
    pub fn last_command(&self) -> Result<Op> {
480 609
        let (_, mut op) = self.head_op()?;
481 610
        while op.is_snapshot() {
482 -
            let Some(parent) = op.parents.first() else { break };
611 +
            let Some(parent) = op.parents.first() else {
612 +
                break;
613 +
            };
483 614
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
484 615
        }
485 616
        Ok(op)
…
55 unchanged lines
541 672
    fn blob_for(content: &[u8]) -> Blob {
542 673
        Blob {
543 674
            length: content.len() as u64,
544 -
            chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(),
675 +
            chunks: crate::chunk::chunks(content)
676 +
                .into_iter()
677 +
                .map(|c| hash_object(ObjectType::Chunk, c))
678 +
                .collect(),
545 679
        }
546 680
    }
547 681
…
5 unchanged lines
553 687
        for c in crate::chunk::chunks(content) {
554 688
            self.store.put_chunk(c)?;
555 689
        }
556 -
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
690 +
        Ok(self
691 +
            .store
692 +
            .put(ObjectType::Blob, &Self::blob_for(content).encode())?)
557 693
    }
558 694
559 695
    /// Read a blob's content, fetching it from the remote that withheld it
…
19 unchanged lines
579 715
            files.push((".oxyattributes".into(), b));
580 716
        }
581 717
        let mut wb = ignore::WalkBuilder::new(&self.root);
582 -
        wb.hidden(false).git_ignore(false).git_global(false).git_exclude(false).require_git(false).follow_links(false).add_custom_ignore_filename(".oxyignore");
718 +
        wb.hidden(false)
719 +
            .git_ignore(false)
720 +
            .git_global(false)
721 +
            .git_exclude(false)
722 +
            .require_git(false)
723 +
            .follow_links(false)
724 +
            .add_custom_ignore_filename(".oxyignore");
583 725
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
584 726
        for entry in wb.build().flatten() {
585 -
            if entry.file_name() == ".oxyattributes" && entry.path() != self.root.join(".oxyattributes") {
727 +
            if entry.file_name() == ".oxyattributes"
728 +
                && entry.path() != self.root.join(".oxyattributes")
729 +
            {
586 730
                if let Ok(b) = fs::read(entry.path()) {
587 -
                    let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
731 +
                    let rel: String = entry
732 +
                        .path()
733 +
                        .strip_prefix(&self.root)
734 +
                        .unwrap()
735 +
                        .components()
736 +
                        .map(|c| c.as_os_str().to_string_lossy().to_string())
737 +
                        .collect::<Vec<_>>()
738 +
                        .join("/");
588 739
                    files.push((rel, b));
589 740
                }
590 741
            }
591 742
        }
592 -
        crate::attributes::Attributes::from_files(files.iter().map(|(p, c)| (p.as_str(), c.as_slice())))
743 +
        crate::attributes::Attributes::from_files(
744 +
            files.iter().map(|(p, c)| (p.as_str(), c.as_slice())),
745 +
        )
593 746
    }
594 747
595 748
    /// Attributes from a materialized state's `.oxyattributes` files.
596 749
    pub fn state_attributes(snapshot: &Snapshot) -> crate::attributes::Attributes {
597 -
        crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| match &f.content {
598 -
            FileContent::Text { bytes, .. } if p == ".oxyattributes" || p.ends_with("/.oxyattributes") => Some((p.as_str(), bytes.as_slice())),
599 -
            _ => None,
750 +
        crate::attributes::Attributes::from_files(snapshot.files.iter().filter_map(|(p, f)| {
751 +
            match &f.content {
752 +
                FileContent::Text { bytes, .. }
753 +
                    if p == ".oxyattributes" || p.ends_with("/.oxyattributes") =>
754 +
                {
755 +
                    Some((p.as_str(), bytes.as_slice()))
756 +
                }
757 +
                _ => None,
758 +
            }
600 759
        }))
601 760
    }
602 761
…
5 unchanged lines
608 767
        for c in crate::chunk::chunks(content) {
609 768
            self.store.put_chunk_nocompress(c)?;
610 769
        }
611 -
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
770 +
        Ok(self
771 +
            .store
772 +
            .put(ObjectType::Blob, &Self::blob_for(content).encode())?)
612 773
    }
613 774
614 775
    fn locks_dir(&self) -> PathBuf {
…
12 unchanged lines
627 788
            return fail(format!("{path} is not marked lockable in .oxyattributes"));
628 789
        }
629 790
        fs::create_dir_all(self.locks_dir())?;
630 -
        write_atomic(&self.locks_dir().join(path.replace('/', "%2F")), std::process::id().to_string().as_bytes())?;
791 +
        write_atomic(
792 +
            &self.locks_dir().join(path.replace('/', "%2F")),
793 +
            std::process::id().to_string().as_bytes(),
794 +
        )?;
631 795
        set_readonly(&self.root.join(path), false)?;
632 796
        Ok(())
633 797
    }
…
9 unchanged lines
643 807
644 808
    /// Whether a repository path is inside the sparse filter (spec §17).
645 809
    pub fn in_scope(&self, path: &str) -> bool {
646 -
        self.config.sparse_paths.is_empty() || self.config.sparse_paths.iter().any(|p| path == p || path.starts_with(&format!("{p}/")))
810 +
        self.config.sparse_paths.is_empty()
811 +
            || self
812 +
                .config
813 +
                .sparse_paths
814 +
                .iter()
815 +
                .any(|p| path == p || path.starts_with(&format!("{p}/")))
647 816
    }
648 817
649 818
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
…
3 unchanged lines
653 822
    /// A walker over the working copy that never enters a nested repository
654 823
    /// (a directory below the root holding `.oxy` or `.git`); the directories
655 824
    /// it skipped are collected into `nested`.
656 -
    pub(crate) fn walker(&self, nested: &std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>) -> ignore::WalkBuilder {
825 +
    pub(crate) fn walker(
826 +
        &self,
827 +
        nested: &std::sync::Arc<std::sync::Mutex<Vec<PathBuf>>>,
828 +
    ) -> ignore::WalkBuilder {
657 829
        let mut wb = ignore::WalkBuilder::new(&self.root);
658 830
        wb.hidden(false)
659 831
            .git_ignore(false)
…
7 unchanged lines
667 839
            if e.file_name() == OXY_DIR {
668 840
                return false;
669 841
            }
670 -
            if e.depth() > 0 && e.file_type().is_some_and(|t| t.is_dir()) && (e.path().join(OXY_DIR).exists() || e.path().join(".git").exists()) {
842 +
            if e.depth() > 0
843 +
                && e.file_type().is_some_and(|t| t.is_dir())
844 +
                && (e.path().join(OXY_DIR).exists() || e.path().join(".git").exists())
845 +
            {
671 846
                nested.lock().unwrap().push(e.path().to_path_buf());
672 847
                return false;
673 848
            }
…
10 unchanged lines
684 859
    /// `.oxyignore` applies to untracked paths only: every path in `tracked`
685 860
    /// (the base state's files) that exists on disk is included even when
686 861
    /// an ignore rule matches it, as in Git.
687 -
    fn walk_working_copy_with(&self, cache: Option<&prompt::Manifest>, tracked: &BTreeMap<String, (NodeKind, Option<ObjectRef>)>) -> Result<BTreeMap<String, FsEntry>> {
862 +
    fn walk_working_copy_with(
863 +
        &self,
864 +
        cache: Option<&prompt::Manifest>,
865 +
        tracked: &BTreeMap<String, (NodeKind, Option<ObjectRef>)>,
866 +
    ) -> Result<BTreeMap<String, FsEntry>> {
688 867
        let nested = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
689 868
        let wb = self.walker(&nested);
690 869
        let mut out = BTreeMap::new();
…
4 unchanged lines
695 874
                continue;
696 875
            }
697 876
            let rel = path.strip_prefix(&self.root).unwrap();
698 -
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
699 -
            let Some(ft) = entry.file_type() else { continue };
877 +
            let rel: String = rel
878 +
                .components()
879 +
                .map(|c| c.as_os_str().to_string_lossy().to_string())
880 +
                .collect::<Vec<_>>()
881 +
                .join("/");
882 +
            let Some(ft) = entry.file_type() else {
883 +
                continue;
884 +
            };
700 885
            if ft.is_dir() || !self.in_scope(&rel) {
701 886
                continue;
702 887
            }
703 888
            if ft.is_symlink() {
704 889
                let target = fs::read_link(path)?;
705 -
                out.insert(rel, FsEntry::new(NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec()));
890 +
                out.insert(
891 +
                    rel,
892 +
                    FsEntry::new(
893 +
                        NodeKind::Symlink,
894 +
                        false,
895 +
                        target.to_string_lossy().as_bytes().to_vec(),
896 +
                    ),
897 +
                );
706 898
            } else if ft.is_file() {
707 -
                let meta = entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?;
899 +
                let meta = entry
900 +
                    .metadata()
901 +
                    .map_err(|e| RepoError::Msg(e.to_string()))?;
708 902
                let e = self.file_entry(&rel, path, &meta, cache)?;
709 903
                out.insert(rel, e);
710 904
            }
…
15 unchanged lines
726 920
                continue;
727 921
            }
728 922
            let registered = modules.entries.get(&rel);
729 -
            if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo) {
923 +
            if registered.is_none() && tracked.get(&rel).map(|(k, _)| *k) != Some(NodeKind::Subrepo)
924 +
            {
730 925
                continue;
731 926
            }
732 927
            let pin = subrepos::read_pin(&dir, registered.and_then(|m| m.bookmark.as_deref()))?;
…
9 unchanged lines
742 937
                if removed.contains(rel) {
743 938
                    continue;
744 939
                }
745 -
                let present = path.is_dir() && (path.join(OXY_DIR).exists() || path.join(".git").exists());
940 +
                let present =
941 +
                    path.is_dir() && (path.join(OXY_DIR).exists() || path.join(".git").exists());
746 942
                if present {
747 -
                    let pin = subrepos::read_pin(&path, modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()))?;
748 -
                    out.insert(rel.clone(), FsEntry::new(NodeKind::Subrepo, false, pin.encode()));
943 +
                    let pin = subrepos::read_pin(
944 +
                        &path,
945 +
                        modules.entries.get(rel).and_then(|m| m.bookmark.as_deref()),
946 +
                    )?;
947 +
                    out.insert(
948 +
                        rel.clone(),
949 +
                        FsEntry::new(NodeKind::Subrepo, false, pin.encode()),
950 +
                    );
749 951
                } else if let Some(b) = blob {
750 952
                    // Not cloned (or emptied): the pin stays as recorded, as a
751 953
                    // Git gitlink does when its directory is absent.
752 -
                    out.insert(rel.clone(), FsEntry { kind: NodeKind::Subrepo, exec: false, content: vec![], blob: Some(*b), content_hash: None });
954 +
                    out.insert(
955 +
                        rel.clone(),
956 +
                        FsEntry {
957 +
                            kind: NodeKind::Subrepo,
958 +
                            exec: false,
959 +
                            content: vec![],
960 +
                            blob: Some(*b),
961 +
                            content_hash: None,
962 +
                        },
963 +
                    );
753 964
                }
754 965
                continue;
755 966
            }
756 -
            let Ok(meta) = fs::symlink_metadata(&path) else { continue };
967 +
            let Ok(meta) = fs::symlink_metadata(&path) else {
968 +
                continue;
969 +
            };
757 970
            let ft = meta.file_type();
758 971
            if ft.is_symlink() {
759 972
                let target = fs::read_link(&path)?;
760 -
                out.insert(rel.clone(), FsEntry::new(NodeKind::Symlink, false, target.to_string_lossy().as_bytes().to_vec()));
973 +
                out.insert(
974 +
                    rel.clone(),
975 +
                    FsEntry::new(
976 +
                        NodeKind::Symlink,
977 +
                        false,
978 +
                        target.to_string_lossy().as_bytes().to_vec(),
979 +
                    ),
980 +
                );
761 981
            } else if ft.is_file() {
762 982
                let e = self.file_entry(rel, &path, &meta, cache)?;
763 983
                out.insert(rel.clone(), e);
…
3 unchanged lines
767 987
    }
768 988
769 989
    /// The entry for one regular file, read or served from the stat cache.
770 -
    fn file_entry(&self, rel: &str, path: &Path, meta: &fs::Metadata, cache: Option<&prompt::Manifest>) -> Result<FsEntry> {
990 +
    fn file_entry(
991 +
        &self,
992 +
        rel: &str,
993 +
        path: &Path,
994 +
        meta: &fs::Metadata,
995 +
        cache: Option<&prompt::Manifest>,
996 +
    ) -> Result<FsEntry> {
771 997
        let exec = is_executable(meta);
772 998
        let cached = cache.and_then(|m| {
773 999
            let e = m.entries.get(rel)?;
…
2 unchanged lines
776 1002
            (e.size == meta.len() && e.mtime_ns == mtime_ns && !racy).then_some(e)
777 1003
        });
778 1004
        Ok(match cached {
779 -
            Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry { kind: NodeKind::File, exec, content: vec![], blob: e.blob, content_hash: Some(e.hash.clone()) },
780 -
            Some(e) => FsEntry { kind: NodeKind::File, exec, content: fs::read(path)?, blob: None, content_hash: Some(e.hash.clone()) },
1005 +
            Some(e) if e.blob.is_some_and(|b| self.store.has(&b)) => FsEntry {
1006 +
                kind: NodeKind::File,
1007 +
                exec,
1008 +
                content: vec![],
1009 +
                blob: e.blob,
1010 +
                content_hash: Some(e.hash.clone()),
1011 +
            },
1012 +
            Some(e) => FsEntry {
1013 +
                kind: NodeKind::File,
1014 +
                exec,
1015 +
                content: fs::read(path)?,
1016 +
                blob: None,
1017 +
                content_hash: Some(e.hash.clone()),
1018 +
            },
781 1019
            None => FsEntry::new(NodeKind::File, exec, fs::read(path)?),
782 1020
        })
783 1021
    }
784 1022
785 1023
    /// The non-directory paths of a snapshot with their kinds, for the
786 1024
    /// walker's `tracked` set.
787 -
    pub(crate) fn tracked_kinds(snapshot: &Snapshot) -> BTreeMap<String, (NodeKind, Option<ObjectRef>)> {
1025 +
    pub(crate) fn tracked_kinds(
1026 +
        snapshot: &Snapshot,
1027 +
    ) -> BTreeMap<String, (NodeKind, Option<ObjectRef>)> {
788 1028
        snapshot
789 1029
            .files
790 1030
            .iter()
…
10 unchanged lines
801 1041
802 1042
    /// A path under the root as a `/`-separated relative string.
803 1043
    pub(crate) fn rel_path(&self, path: &Path) -> String {
804 -
        path.strip_prefix(&self.root).unwrap_or(path).components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/")
1044 +
        path.strip_prefix(&self.root)
1045 +
            .unwrap_or(path)
1046 +
            .components()
1047 +
            .map(|c| c.as_os_str().to_string_lossy().to_string())
1048 +
            .collect::<Vec<_>>()
1049 +
            .join("/")
805 1050
    }
806 1051
}
807 1052
…
11 unchanged lines
819 1064
        let head_set: BTreeSet<ChangeHash> = heads.into_iter().collect();
820 1065
        let mut entries: Vec<LogEntry> = Vec::new();
821 1066
        for (h, meta) in state.changes() {
822 -
            entries.push(LogEntry { hash: *h, change: meta.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) });
1067 +
            entries.push(LogEntry {
1068 +
                hash: *h,
1069 +
                change: meta.clone(),
1070 +
                is_working_copy: *h == wc_hash,
1071 +
                is_head: head_set.contains(h),
1072 +
            });
823 1073
        }
824 -
        entries.sort_by(|a, b| b.is_working_copy.cmp(&a.is_working_copy).then(b.change.hlc.cmp(&a.change.hlc)).then(b.hash.cmp(&a.hash)));
1074 +
        entries.sort_by(|a, b| {
1075 +
            b.is_working_copy
1076 +
                .cmp(&a.is_working_copy)
1077 +
                .then(b.change.hlc.cmp(&a.change.hlc))
1078 +
                .then(b.hash.cmp(&a.hash))
1079 +
        });
825 1080
        Ok(entries)
826 1081
    }
827 1082
…
4 unchanged lines
832 1087
        let head_set: BTreeSet<ChangeHash> = state.heads().into_iter().collect();
833 1088
        let mut entries: Vec<LogEntry> = state
834 1089
            .changes()
835 -
            .map(|(h, meta)| LogEntry { hash: *h, change: meta.clone(), is_working_copy: false, is_head: head_set.contains(h) })
1090 +
            .map(|(h, meta)| LogEntry {
1091 +
                hash: *h,
1092 +
                change: meta.clone(),
1093 +
                is_working_copy: false,
1094 +
                is_head: head_set.contains(h),
1095 +
            })
836 1096
            .collect();
837 1097
        entries.sort_by(|a, b| b.change.hlc.cmp(&a.change.hlc).then(b.hash.cmp(&a.hash)));
838 1098
        Ok(entries)
…
29 unchanged lines
868 1128
        }
869 1129
        let entries = self.log()?;
870 1130
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
871 -
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
1131 +
            entries
1132 +
                .iter()
1133 +
                .filter(|e| e.hash.starts_with_hex(hp))
1134 +
                .collect()
872 1135
        } else {
873 -
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
1136 +
            entries
1137 +
                .iter()
1138 +
                .filter(|e| e.change.change_id.starts_with_hex(spec))
1139 +
                .collect()
874 1140
        };
875 1141
        match matches.len() {
876 1142
            1 => Ok(matches[0].hash),
…
1 unchanged lines
878 1144
            _ => {
879 1145
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
880 1146
                if ids.len() == 1 {
881 -
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
1147 +
                    fail(format!(
1148 +
                        "change {spec} is divergent: {} variants",
1149 +
                        matches.len()
1150 +
                    ))
882 1151
                } else {
883 1152
                    fail(format!("{spec:?} is ambiguous"))
884 1153
                }
…
12 unchanged lines
897 1166
898 1167
fn remove_any(path: &Path) -> std::io::Result<()> {
899 1168
    let m = fs::symlink_metadata(path)?;
900 -
    if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
1169 +
    if m.is_dir() {
1170 +
        fs::remove_dir_all(path)
1171 +
    } else {
1172 +
        fs::remove_file(path)
1173 +
    }
901 1174
}
902 1175
903 1176
fn set_readonly(path: &Path, readonly: bool) -> std::io::Result<()> {
…
30 unchanged lines
934 1207
}
935 1208
936 1209
fn op_ref(k: OpKey) -> OpRef {
937 -
    OpRef { change: k.0, index: k.1 }
1210 +
    OpRef {
1211 +
        change: k.0,
1212 +
        index: k.1,
1213 +
    }
938 1214
}
939 1215
940 1216
fn split_path(path: &str) -> (&str, &str) {
…
5 unchanged lines
modified crates/oxy-core/src/repo/policy.rs +172 −37
…
23 unchanged lines
24 24
25 25
impl PolicyUpdate {
26 26
    pub fn signed(&self) -> SignedPolicy {
27 -
        SignedPolicy { policy: self.policy.clone(), sigs: self.sigs.clone() }
27 +
        SignedPolicy {
28 +
            policy: self.policy.clone(),
29 +
            sigs: self.sigs.clone(),
30 +
        }
28 31
    }
29 32
}
30 33
31 34
impl Repo {
32 35
    /// The current policy and its signatures, if the repository has one.
33 36
    pub fn current_policy(&self) -> Result<Option<(ObjectRef, SignedPolicy)>> {
34 -
        let Some(r) = self.view()?.policy else { return Ok(None) };
37 +
        let Some(r) = self.view()?.policy else {
38 +
            return Ok(None);
39 +
        };
35 40
        let policy = Policy::decode(&self.store.get_typed(&r, ObjectType::Policy)?)?;
36 -
        Ok(Some((r, SignedPolicy { policy, sigs: self.store.signatures(&r)? })))
41 +
        Ok(Some((
42 +
            r,
43 +
            SignedPolicy {
44 +
                policy,
45 +
                sigs: self.store.signatures(&r)?,
46 +
            },
47 +
        )))
37 48
    }
38 49
39 50
    /// Store a policy with its signatures and point the `policy` reference at it.
…
7 unchanged lines
47 58
        }
48 59
        let dir = self.oxy.join("policies");
49 60
        fs::create_dir_all(&dir)?;
50 -
        write_atomic(&dir.join(format!("{:020}", sp.policy.serial)), r.to_hex().as_bytes())?;
61 +
        write_atomic(
62 +
            &dir.join(format!("{:020}", sp.policy.serial)),
63 +
            r.to_hex().as_bytes(),
64 +
        )?;
51 65
        let (op_ref, op) = self.head_op()?;
52 66
        if op.view.policy != Some(r) {
53 67
            let mut view = op.view.clone();
…
10 unchanged lines
64 78
        let mut out: BTreeMap<u64, SignedPolicy> = BTreeMap::new();
65 79
        if let Ok(rd) = fs::read_dir(self.oxy.join("policies")) {
66 80
            for e in rd.flatten() {
67 -
                let Ok(hex) = fs::read_to_string(e.path()) else { continue };
68 -
                let Some(r) = ObjectRef::from_hex(hex.trim()) else { continue };
69 -
                let Ok(bytes) = self.store.get_typed(&r, ObjectType::Policy) else { continue };
81 +
                let Ok(hex) = fs::read_to_string(e.path()) else {
82 +
                    continue;
83 +
                };
84 +
                let Some(r) = ObjectRef::from_hex(hex.trim()) else {
85 +
                    continue;
86 +
                };
87 +
                let Ok(bytes) = self.store.get_typed(&r, ObjectType::Policy) else {
88 +
                    continue;
89 +
                };
70 90
                let policy = Policy::decode(&bytes)?;
71 -
                out.insert(policy.serial, SignedPolicy { policy, sigs: self.store.signatures(&r)? });
91 +
                out.insert(
92 +
                    policy.serial,
93 +
                    SignedPolicy {
94 +
                        policy,
95 +
                        sigs: self.store.signatures(&r)?,
96 +
                    },
97 +
                );
72 98
            }
73 99
        }
74 100
        if let Some((_, sp)) = self.current_policy()? {
…
5 unchanged lines
80 106
    /// Verify and apply a chain of policies ending in `newest`, each
81 107
    /// against the one before it (spec §14.5). `chain` holds earlier
82 108
    /// policies in any order; only those after the current serial are used.
83 -
    pub(crate) fn adopt_policy_chain(&self, chain: &[SignedPolicy], newest: &SignedPolicy, command: &str) -> std::result::Result<(), String> {
84 -
        let mut current = self.current_policy().map_err(|e| e.to_string())?.map(|(_, sp)| sp.policy);
109 +
    pub(crate) fn adopt_policy_chain(
110 +
        &self,
111 +
        chain: &[SignedPolicy],
112 +
        newest: &SignedPolicy,
113 +
        command: &str,
114 +
    ) -> std::result::Result<(), String> {
115 +
        let mut current = self
116 +
            .current_policy()
117 +
            .map_err(|e| e.to_string())?
118 +
            .map(|(_, sp)| sp.policy);
85 119
        let mut steps: Vec<&SignedPolicy> = chain
86 120
            .iter()
87 -
            .filter(|sp| current.as_ref().is_none_or(|c| sp.policy.serial > c.serial) && sp.policy.serial < newest.policy.serial)
121 +
            .filter(|sp| {
122 +
                current.as_ref().is_none_or(|c| sp.policy.serial > c.serial)
123 +
                    && sp.policy.serial < newest.policy.serial
124 +
            })
88 125
            .collect();
89 126
        steps.sort_by_key(|sp| sp.policy.serial);
90 127
        steps.push(newest);
91 128
        for sp in steps {
92 129
            self.check_policy(sp, current.as_ref())?;
93 -
            self.set_policy(sp, &format!("{command} (policy serial {})", sp.policy.serial)).map_err(|e| e.to_string())?;
130 +
            self.set_policy(
131 +
                sp,
132 +
                &format!("{command} (policy serial {})", sp.policy.serial),
133 +
            )
134 +
            .map_err(|e| e.to_string())?;
94 135
            current = Some(sp.policy.clone());
95 136
        }
96 137
        Ok(())
…
1 unchanged lines
98 139
99 140
    /// Validate an incoming policy against the one we trust (`None` when
100 141
    /// bootstrapping: the policy then vouches for itself).
101 -
    pub fn check_policy(&self, sp: &SignedPolicy, trusted: Option<&Policy>) -> std::result::Result<(), String> {
142 +
    pub fn check_policy(
143 +
        &self,
144 +
        sp: &SignedPolicy,
145 +
        trusted: Option<&Policy>,
146 +
    ) -> std::result::Result<(), String> {
102 147
        if sp.policy.repo != self.id().map_err(|e| e.to_string())? {
103 148
            return Err("policy is for a different repository".into());
104 149
        }
105 150
        if let Some(t) = trusted {
106 151
            if sp.policy.serial <= t.serial {
107 -
                return Err(format!("policy serial {} is not greater than the current {}", sp.policy.serial, t.serial));
152 +
                return Err(format!(
153 +
                    "policy serial {} is not greater than the current {}",
154 +
                    sp.policy.serial, t.serial
155 +
                ));
108 156
            }
109 157
        }
110 158
        match trusted {
…
15 unchanged lines
126 174
    /// Sign with every listed key; the first must be a root of the current
127 175
    /// policy (the others may be roots of the new policy only, so that a
128 176
    /// fresh clone can bootstrap from it).
129 -
    pub(crate) fn sign_policy_with(&self, policy: Policy, signers: &[(KeyId, &SecretKey)], command: &str) -> Result<PolicyUpdate> {
130 -
        let trusted = self.current_policy()?.map(|(_, sp)| sp.policy).unwrap_or_else(|| policy.clone());
131 -
        if !signers.first().is_some_and(|(k, _)| trusted.roots.contains(k)) {
177 +
    pub(crate) fn sign_policy_with(
178 +
        &self,
179 +
        policy: Policy,
180 +
        signers: &[(KeyId, &SecretKey)],
181 +
        command: &str,
182 +
    ) -> Result<PolicyUpdate> {
183 +
        let trusted = self
184 +
            .current_policy()?
185 +
            .map(|(_, sp)| sp.policy)
186 +
            .unwrap_or_else(|| policy.clone());
187 +
        if !signers
188 +
            .first()
189 +
            .is_some_and(|(k, _)| trusted.roots.contains(k))
190 +
        {
132 191
            return fail("your key is not a root key of the current policy");
133 192
        }
134 193
        let target = policy.hash();
135 -
        let sigs = signers.iter().map(|(k, sk)| sk.sign(Domain::Policy, k, &target)).collect();
194 +
        let sigs = signers
195 +
            .iter()
196 +
            .map(|(k, sk)| sk.sign(Domain::Policy, k, &target))
197 +
            .collect();
136 198
        self.gather(SignedPolicy { policy, sigs }, &trusted, command)
137 199
    }
138 200
…
3 unchanged lines
142 204
        if applied {
143 205
            self.set_policy(&sp, command)?;
144 206
        }
145 -
        Ok(PolicyUpdate { policy: sp.policy, sigs: sp.sigs, applied })
207 +
        Ok(PolicyUpdate {
208 +
            policy: sp.policy,
209 +
            sigs: sp.sigs,
210 +
            applied,
211 +
        })
146 212
    }
147 213
148 214
    /// Add your signature to a proposal made by another root holder; the
…
9 unchanged lines
158 224
            return fail("the proposal is for a different repository");
159 225
        }
160 226
        if proposal.policy.serial <= current.policy.serial {
161 -
            return fail(format!("the proposal's serial {} is not greater than the current {}", proposal.policy.serial, current.policy.serial));
227 +
            return fail(format!(
228 +
                "the proposal's serial {} is not greater than the current {}",
229 +
                proposal.policy.serial, current.policy.serial
230 +
            ));
162 231
        }
163 232
        if !current.policy.roots.contains(&key) {
164 233
            return fail("your key is not a root key of the current policy");
165 234
        }
166 235
        let mut sigs: Vec<Signature> = proposal.sigs.into_iter().filter(|s| s.key != key).collect();
167 236
        sigs.push(sk.sign(Domain::Policy, &key, &proposal.policy.hash()));
168 -
        self.gather(SignedPolicy { policy: proposal.policy, sigs }, &current.policy, "oxy policy sign")
237 +
        self.gather(
238 +
            SignedPolicy {
239 +
                policy: proposal.policy,
240 +
                sigs,
241 +
            },
242 +
            &current.policy,
243 +
            "oxy policy sign",
244 +
        )
169 245
    }
170 246
171 247
    /// Create the initial policy with the configured identity as sole root.
…
5 unchanged lines
177 253
            return fail("no secret key: run `oxy identity create` first");
178 254
        };
179 255
        let identity = self.identity(&key)?;
180 -
        let policy = Policy::single_root(self.id()?, identity, Hlc::now().wall_ms + POLICY_LIFETIME_MS);
256 +
        let policy = Policy::single_root(
257 +
            self.id()?,
258 +
            identity,
259 +
            Hlc::now().wall_ms + POLICY_LIFETIME_MS,
260 +
        );
181 261
        self.sign_policy(policy, "oxy policy init")
182 262
    }
183 263
…
21 unchanged lines
205 285
        }
206 286
        p.roots.retain(|k| k != key);
207 287
        if (p.roots.len() as u32) < p.root_threshold {
208 -
            return fail(format!("removing it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold));
288 +
            return fail(format!(
289 +
                "removing it would leave {} root(s) for a threshold of {}",
290 +
                p.roots.len(),
291 +
                p.root_threshold
292 +
            ));
209 293
        }
210 294
        self.sign_policy(p, &format!("oxy policy root remove {}", key.short(12)))
211 295
    }
…
2 unchanged lines
214 298
    pub fn policy_set_threshold(&self, n: u32) -> Result<PolicyUpdate> {
215 299
        let mut p = self.next_policy()?;
216 300
        if n == 0 || n as usize > p.roots.len() {
217 -
            return fail(format!("the threshold must be between 1 and the number of roots ({})", p.roots.len()));
301 +
            return fail(format!(
302 +
                "the threshold must be between 1 and the number of roots ({})",
303 +
                p.roots.len()
304 +
            ));
218 305
        }
219 306
        p.root_threshold = n;
220 307
        self.sign_policy(p, &format!("oxy policy root threshold {n}"))
…
22 unchanged lines
243 330
                    rule.keys.sort();
244 331
                }
245 332
            }
246 -
            None => p.rules.push(Rule { pattern: pattern.into(), keys: vec![key], threshold: 1, require: Require::Any, allow_conflicts: true }),
333 +
            None => p.rules.push(Rule {
334 +
                pattern: pattern.into(),
335 +
                keys: vec![key],
336 +
                threshold: 1,
337 +
                require: Require::Any,
338 +
                allow_conflicts: true,
339 +
            }),
247 340
        }
248 341
        if p.identity(&key).is_none() {
249 342
            p.identities.push(identity);
…
13 unchanged lines
263 356
            }
264 357
            p.roots.retain(|k| k != key);
265 358
            if (p.roots.len() as u32) < p.root_threshold {
266 -
                return fail(format!("revoking it would leave {} root(s) for a threshold of {}", p.roots.len(), p.root_threshold));
359 +
                return fail(format!(
360 +
                    "revoking it would leave {} root(s) for a threshold of {}",
361 +
                    p.roots.len(),
362 +
                    p.root_threshold
363 +
                ));
267 364
            }
268 365
        }
269 366
        for r in &mut p.rules {
…
12 unchanged lines
282 379
    /// rule lists, using the authenticator kind the rule requires. A rule key
283 380
    /// covers its rotation predecessors, and a delegate's signature counts
284 381
    /// for the author it acts for.
285 -
    pub fn change_allowed(&self, hash: &ChangeHash, policy: &Policy, reference: &str) -> std::result::Result<(), String> {
286 -
        let rule = policy.rule_for(reference).ok_or_else(|| format!("no policy rule matches {reference:?}"))?;
382 +
    pub fn change_allowed(
383 +
        &self,
384 +
        hash: &ChangeHash,
385 +
        policy: &Policy,
386 +
        reference: &str,
387 +
    ) -> std::result::Result<(), String> {
388 +
        let rule = policy
389 +
            .rule_for(reference)
390 +
            .ok_or_else(|| format!("no policy rule matches {reference:?}"))?;
287 391
        match self.authenticate_change(hash).map_err(|e| e.to_string())? {
288 392
            SignatureStatus::Valid { key, sealed, .. } => {
289 393
                if policy.is_revoked(&key) {
290 -
                    return Err(format!("change {} is signed by revoked key {}", hash.short(12), key.short(12)));
394 +
                    return Err(format!(
395 +
                        "change {} is signed by revoked key {}",
396 +
                        hash.short(12),
397 +
                        key.short(12)
398 +
                    ));
291 399
                }
292 -
                let author = self.store.get_change(hash).map(|c| c.author).map_err(|e| e.to_string())?;
400 +
                let author = self
401 +
                    .store
402 +
                    .get_change(hash)
403 +
                    .map(|c| c.author)
404 +
                    .map_err(|e| e.to_string())?;
293 405
                let principal = if author.is_zero() { key } else { author };
294 -
                let covered = rule.keys.iter().any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal));
406 +
                let covered = rule
407 +
                    .keys
408 +
                    .iter()
409 +
                    .any(|k| policy.succeeds(k, &key) || policy.succeeds(k, &principal));
295 410
                if !covered {
296 -
                    return Err(format!("change {} is signed by {}, which may not update {reference:?}", hash.short(12), key.short(12)));
411 +
                    return Err(format!(
412 +
                        "change {} is signed by {}, which may not update {reference:?}",
413 +
                        hash.short(12),
414 +
                        key.short(12)
415 +
                    ));
297 416
                }
298 417
                match (rule.require, sealed) {
299 -
                    (Require::Signed, true) => Err(format!("{reference:?} requires individually signed changes; {} is only sealed", hash.short(12))),
300 -
                    (Require::Sealed, false) => Err(format!("{reference:?} requires sealed changes; {} is individually signed", hash.short(12))),
418 +
                    (Require::Signed, true) => Err(format!(
419 +
                        "{reference:?} requires individually signed changes; {} is only sealed",
420 +
                        hash.short(12)
421 +
                    )),
422 +
                    (Require::Sealed, false) => Err(format!(
423 +
                        "{reference:?} requires sealed changes; {} is individually signed",
424 +
                        hash.short(12)
425 +
                    )),
301 426
                    _ => Ok(()),
302 427
                }
303 428
            }
304 -
            SignatureStatus::Unsigned => Err(format!("change {} is not signed or sealed", hash.short(12))),
429 +
            SignatureStatus::Unsigned => {
430 +
                Err(format!("change {} is not signed or sealed", hash.short(12)))
431 +
            }
305 432
            SignatureStatus::Invalid(e) => Err(format!("change {}: {e}", hash.short(12))),
306 433
        }
307 434
    }
308 435
309 436
    /// Check every change that `new` adds over `old` for `reference`.
310 -
    pub(crate) fn state_allowed(&self, old_heads: &[ChangeHash], new_heads: &[ChangeHash], policy: &Policy, reference: &str) -> std::result::Result<(), String> {
311 -
        let added = self.negotiate(old_heads, new_heads).map_err(|e| e.to_string())?;
437 +
    pub(crate) fn state_allowed(
438 +
        &self,
439 +
        old_heads: &[ChangeHash],
440 +
        new_heads: &[ChangeHash],
441 +
        policy: &Policy,
442 +
        reference: &str,
443 +
    ) -> std::result::Result<(), String> {
444 +
        let added = self
445 +
            .negotiate(old_heads, new_heads)
446 +
            .map_err(|e| e.to_string())?;
312 447
        for h in &added {
313 448
            self.change_allowed(h, policy, reference)?;
314 449
        }
…
11 unchanged lines
modified crates/oxy-core/src/repo/prompt.rs +114 −23
…
49 unchanged lines
50 50
    /// `format` wrapped in the state colour. `guard` is `("\\[", "\\]")` for
51 51
    /// bash prompts, `("%{", "%}")` for zsh, or empty for raw output.
52 52
    pub fn format_colored(&self, fmt: &str, guard: (&str, &str)) -> String {
53 -
        format!("{}\x1b[{}m{}{}{}\x1b[0m{}", guard.0, self.state_color(), guard.1, self.format(fmt), guard.0, guard.1)
53 +
        format!(
54 +
            "{}\x1b[{}m{}{}{}\x1b[0m{}",
55 +
            guard.0,
56 +
            self.state_color(),
57 +
            guard.1,
58 +
            self.format(fmt),
59 +
            guard.0,
60 +
            guard.1
61 +
        )
54 62
    }
55 63
56 64
    /// Expand `%b` bookmark, `%c` change id, `%h` heads, `%d` dirty marker
…
78 unchanged lines
135 143
}
136 144
137 145
pub(crate) fn mtime_ns(m: &fs::Metadata) -> u128 {
138 -
    m.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map(|d| d.as_nanos()).unwrap_or(0)
146 +
    m.modified()
147 +
        .ok()
148 +
        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
149 +
        .map(|d| d.as_nanos())
150 +
        .unwrap_or(0)
139 151
}
140 152
141 153
pub const DEFAULT_PROMPT_FORMAT: &str = "%b %c%d%x%A%D";
…
4 unchanged lines
146 158
147 159
/// One manifest line per file: size, mtime in nanoseconds, hash, blob ref
148 160
/// or `-`, path.
149 -
fn manifest_line(path: &str, size: u64, mtime_ns: u128, hash: &str, blob: Option<ObjectRef>) -> String {
150 -
    format!("{size}\t{mtime_ns}\t{hash}\t{}\t{path}\n", blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into()))
161 +
fn manifest_line(
162 +
    path: &str,
163 +
    size: u64,
164 +
    mtime_ns: u128,
165 +
    hash: &str,
166 +
    blob: Option<ObjectRef>,
167 +
) -> String {
168 +
    format!(
169 +
        "{size}\t{mtime_ns}\t{hash}\t{}\t{path}\n",
170 +
        blob.map(|b| b.to_hex()).unwrap_or_else(|| "-".into())
171 +
    )
151 172
}
152 173
153 174
impl Repo {
154 175
    fn manifest_path(&self) -> PathBuf {
155 -
        self.oxy.join("workspaces").join(self.workspace.to_hex()).join("manifest")
176 +
        self.oxy
177 +
            .join("workspaces")
178 +
            .join(self.workspace.to_hex())
179 +
            .join("manifest")
156 180
    }
157 181
158 182
    /// Record the working copy as it is now (after a snapshot, commit, or
…
6 unchanged lines
165 189
166 190
    /// Write the manifest from entries already walked; `blobs` names the
167 191
    /// stored blob of each opaque path so the next walk can skip reading it.
168 -
    pub(crate) fn write_manifest_entries(&self, conflicted: bool, uncommitted: bool, entries: &BTreeMap<String, FsEntry>, blobs: &HashMap<String, ObjectRef>) -> Result<()> {
169 -
        let written = std::time::SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
170 -
        let mut text = format!("# conflicted={}\n# uncommitted={}\n# written={written}\n", conflicted as u8, uncommitted as u8);
192 +
    pub(crate) fn write_manifest_entries(
193 +
        &self,
194 +
        conflicted: bool,
195 +
        uncommitted: bool,
196 +
        entries: &BTreeMap<String, FsEntry>,
197 +
        blobs: &HashMap<String, ObjectRef>,
198 +
    ) -> Result<()> {
199 +
        let written = std::time::SystemTime::now()
200 +
            .duration_since(UNIX_EPOCH)
201 +
            .map(|d| d.as_nanos())
202 +
            .unwrap_or(0);
203 +
        let mut text = format!(
204 +
            "# conflicted={}\n# uncommitted={}\n# written={written}\n",
205 +
            conflicted as u8, uncommitted as u8
206 +
        );
171 207
        for (path, e) in entries {
172 208
            let full = self.root.join(path);
173 209
            let (size, mtime_ns) = match fs::symlink_metadata(&full) {
…
67 unchanged lines
241 277
            if f.len() == 5 {
242 278
                out.entries.insert(
243 279
                    f[4].to_string(),
244 -
                    ManifestEntry { size: f[0].parse().unwrap_or(0), mtime_ns: f[1].parse().unwrap_or(0), hash: f[2].to_string(), blob: ObjectRef::from_hex(f[3]) },
280 +
                    ManifestEntry {
281 +
                        size: f[0].parse().unwrap_or(0),
282 +
                        mtime_ns: f[1].parse().unwrap_or(0),
283 +
                        hash: f[2].to_string(),
284 +
                        blob: ObjectRef::from_hex(f[3]),
285 +
                    },
245 286
                );
246 287
            }
247 288
        }
…
3 unchanged lines
251 292
    /// Whether the working copy differs from the last manifest. Without a
252 293
    /// manifest the answer is `None`.
253 294
    pub fn quick_dirty(&self) -> Result<Option<bool>> {
254 -
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else { return Ok(None) };
295 +
        let Some((_, uncommitted, manifest)) = self.read_manifest()? else {
296 +
            return Ok(None);
297 +
        };
255 298
        if uncommitted {
256 299
            return Ok(Some(true));
257 300
        }
…
2 unchanged lines
260 303
        let wb = self.walker(&nested);
261 304
        for entry in wb.build() {
262 305
            let Ok(entry) = entry else { continue };
263 -
            let Some(ft) = entry.file_type() else { continue };
306 +
            let Some(ft) = entry.file_type() else {
307 +
                continue;
308 +
            };
264 309
            if ft.is_dir() {
265 310
                continue;
266 311
            }
267 -
            let rel: String = entry.path().strip_prefix(&self.root).unwrap().components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
312 +
            let rel: String = entry
313 +
                .path()
314 +
                .strip_prefix(&self.root)
315 +
                .unwrap()
316 +
                .components()
317 +
                .map(|c| c.as_os_str().to_string_lossy().to_string())
318 +
                .collect::<Vec<_>>()
319 +
                .join("/");
268 320
            if !self.in_scope(&rel) {
269 321
                continue;
270 322
            }
271 -
            let Some(me) = manifest.entries.get(&rel) else { return Ok(Some(true)) };
323 +
            let Some(me) = manifest.entries.get(&rel) else {
324 +
                return Ok(Some(true));
325 +
            };
272 326
            seen += 1;
273 327
            let m = fs::symlink_metadata(entry.path())?;
274 328
            let now_mtime = mtime_ns(&m);
275 329
            if ft.is_symlink() {
276 330
                let target = fs::read_link(entry.path())?;
277 -
                if blake3::hash(target.to_string_lossy().as_bytes()).to_hex().as_str() != me.hash {
331 +
                if blake3::hash(target.to_string_lossy().as_bytes())
332 +
                    .to_hex()
333 +
                    .as_str()
334 +
                    != me.hash
335 +
                {
278 336
                    return Ok(Some(true));
279 337
                }
280 338
                continue;
…
1 unchanged lines
282 340
            if m.len() != me.size {
283 341
                return Ok(Some(true));
284 342
            }
285 -
            if now_mtime != me.mtime_ns && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != me.hash {
343 +
            if now_mtime != me.mtime_ns
344 +
                && blake3::hash(&fs::read(entry.path())?).to_hex().as_str() != me.hash
345 +
            {
286 346
                return Ok(Some(true));
287 347
            }
288 348
        }
…
4 unchanged lines
293 353
        // hide. They count as present when still on disk and unchanged.
294 354
        for (rel, me) in &manifest.entries {
295 355
            let path = self.root.join(rel);
296 -
            let Ok(m) = fs::symlink_metadata(&path) else { return Ok(Some(true)) };
356 +
            let Ok(m) = fs::symlink_metadata(&path) else {
357 +
                return Ok(Some(true));
358 +
            };
297 359
            if m.file_type().is_symlink() {
298 360
                continue;
299 361
            }
300 -
            if m.len() != me.size || (mtime_ns(&m) != me.mtime_ns && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash) {
362 +
            if m.len() != me.size
363 +
                || (mtime_ns(&m) != me.mtime_ns
364 +
                    && blake3::hash(&fs::read(&path)?).to_hex().as_str() != me.hash)
365 +
            {
301 366
                return Ok(Some(true));
302 367
            }
303 368
        }
…
24 unchanged lines
328 393
            }
329 394
            None => (None, None),
330 395
        };
331 -
        Ok(PromptInfo { bookmark, change_id, heads: heads.len(), dirty, conflicted, ahead, behind })
396 +
        Ok(PromptInfo {
397 +
            bookmark,
398 +
            change_id,
399 +
            heads: heads.len(),
400 +
            dirty,
401 +
            conflicted,
402 +
            ahead,
403 +
            behind,
404 +
        })
332 405
    }
333 406
}
334 407
…
3 unchanged lines
338 411
339 412
    #[test]
340 413
    fn placeholders_and_glyphs() {
341 -
        let clean = PromptInfo { bookmark: "main".into(), change_id: "658990aa".into(), heads: 1, dirty: false, conflicted: false, ahead: Some(0), behind: None };
414 +
        let clean = PromptInfo {
415 +
            bookmark: "main".into(),
416 +
            change_id: "658990aa".into(),
417 +
            heads: 1,
418 +
            dirty: false,
419 +
            conflicted: false,
420 +
            ahead: Some(0),
421 +
            behind: None,
422 +
        };
342 423
        assert_eq!(clean.format(DEFAULT_PROMPT_FORMAT), "main 658990aa");
343 424
        assert_eq!(clean.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✔");
344 -
        let busy = PromptInfo { dirty: true, ahead: Some(2), behind: Some(3), ..clean.clone() };
425 +
        let busy = PromptInfo {
426 +
            dirty: true,
427 +
            ahead: Some(2),
428 +
            behind: Some(3),
429 +
            ..clean.clone()
430 +
        };
345 431
        assert_eq!(busy.format(DEFAULT_PROMPT_FORMAT), "main 658990aa*+2-3");
346 432
        assert_eq!(busy.format(GLYPH_PROMPT_FORMAT), "⚛ main 658990aa ✎↑2↓3");
347 -
        let bad = PromptInfo { conflicted: true, ..busy.clone() };
433 +
        let bad = PromptInfo {
434 +
            conflicted: true,
435 +
            ..busy.clone()
436 +
        };
348 437
        assert_eq!(bad.format("%s%x%d %h %a/%B %%"), "‼!* 1 2/3 %");
349 438
        assert_eq!(bad.state_color(), 31);
350 -
        assert_eq!(busy.format_colored("%b", ("\\[", "\\]")), "\\[\x1b[33m\\]main\\[\x1b[0m\\]");
439 +
        assert_eq!(
440 +
            busy.format_colored("%b", ("\\[", "\\]")),
441 +
            "\\[\x1b[33m\\]main\\[\x1b[0m\\]"
442 +
        );
351 443
        assert_eq!(clean.format_colored("%b", ("", "")), "\x1b[36mmain\x1b[0m");
352 444
        assert_eq!(clean.format("%q"), "%q");
353 445
    }
354 446
}
355 -
modified crates/oxy-core/src/repo/snapshot.rs +706 −133
…
19 unchanged lines
20 20
/// Lines that mark a conflict region in a materialized file (spec §9.5).
21 21
pub fn has_conflict_markers(content: &[u8]) -> bool {
22 22
    split_lines(content).iter().any(|l| {
23 -
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
23 +
        l.starts_with(b"<<<<<<< oxy conflict")
24 +
            || l.starts_with(b"======= oxy conflict")
25 +
            || l.starts_with(b">>>>>>> oxy conflict")
24 26
    })
25 27
}
26 28
…
38 unchanged lines
65 67
    /// Diff the files on disk against `base` and build the operations of a
66 68
    /// change from `base` to the working directory. `reuse` maps paths to the
67 69
    /// NodeIds an earlier version of the same change assigned to new paths.
68 -
    pub(crate) fn build_against(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Result<BuiltChange> {
70 +
    pub(crate) fn build_against(
71 +
        &self,
72 +
        base: &State,
73 +
        base_snapshot: &Snapshot,
74 +
        reuse: HashMap<String, (NodeId, NodeKind)>,
75 +
    ) -> Result<BuiltChange> {
69 76
        let fs_entries = self.walk_working_copy_with(None, &Self::tracked_kinds(base_snapshot))?;
70 77
        self.build_from_entries(base, base_snapshot, reuse, &fs_entries)
71 78
    }
72 79
73 80
    /// Like `build_against`, for files given in memory (imports).
74 -
    pub(crate) fn build_from_entries(&self, base: &State, base_snapshot: &Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>, fs_entries: &BTreeMap<String, FsEntry>) -> Result<BuiltChange> {
81 +
    pub(crate) fn build_from_entries(
82 +
        &self,
83 +
        base: &State,
84 +
        base_snapshot: &Snapshot,
85 +
        reuse: HashMap<String, (NodeId, NodeKind)>,
86 +
        fs_entries: &BTreeMap<String, FsEntry>,
87 +
    ) -> Result<BuiltChange> {
75 88
        // Attributes come from the files being committed, so a new
76 89
        // `.oxyattributes` applies to the same change that adds it.
77 90
        let attrs = crate::attributes::Attributes::from_files(
78 -
            fs_entries.iter().filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes")).map(|(p, e)| (p.as_str(), e.content.as_slice())),
91 +
            fs_entries
92 +
                .iter()
93 +
                .filter(|(p, _)| p.as_str() == ".oxyattributes" || p.ends_with("/.oxyattributes"))
94 +
                .map(|(p, e)| (p.as_str(), e.content.as_slice())),
79 95
        );
80 96
        let mut b = ChangeBuilder::new(base, base_snapshot, reuse).with_attributes(attrs);
81 97
        // Rename detection (spec §7.1, §23): a removed file and an added file
82 98
        // of the same kind whose contents are at least half alike become one
83 99
        // move of the same node, so identity survives across the rename.
84 100
        let renames = detect_renames(base_snapshot, fs_entries, |p| self.in_scope(p));
85 -
        let renamed_from: HashMap<&str, &str> = renames.iter().map(|(old, new)| (new.as_str(), old.as_str())).collect();
86 -
        let renamed_to: HashMap<&str, &str> = renames.iter().map(|(old, new)| (old.as_str(), new.as_str())).collect();
101 +
        let renamed_from: HashMap<&str, &str> = renames
102 +
            .iter()
103 +
            .map(|(old, new)| (new.as_str(), old.as_str()))
104 +
            .collect();
105 +
        let renamed_to: HashMap<&str, &str> = renames
106 +
            .iter()
107 +
            .map(|(old, new)| (old.as_str(), new.as_str()))
108 +
            .collect();
87 109
        for (path, fs_entry) in fs_entries {
88 110
            match base_snapshot.files.get(path) {
89 111
                None => match renamed_from.get(path.as_str()) {
90 -
                    Some(old) => b.move_path(self, old, path, fs_entry, &base_snapshot.files[*old])?,
112 +
                    Some(old) => {
113 +
                        b.move_path(self, old, path, fs_entry, &base_snapshot.files[*old])?
114 +
                    }
91 115
                    None => b.add_path(self, path, fs_entry)?,
92 116
                },
93 117
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
94 118
            }
95 119
        }
96 120
        for (path, expected) in &base_snapshot.files {
97 -
            if fs_entries.contains_key(path) || !self.in_scope(path) || renamed_to.contains_key(path.as_str()) {
121 +
            if fs_entries.contains_key(path)
122 +
                || !self.in_scope(path)
123 +
                || renamed_to.contains_key(path.as_str())
124 +
            {
98 125
                continue;
99 126
            }
100 127
            // A directory stays while anything under it exists or is out of scope.
101 128
            let still_needed = expected.kind == NodeKind::Dir
102 -
                && (fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")))
103 -
                    || base_snapshot.files.keys().any(|p| p.starts_with(&format!("{path}/")) && !self.in_scope(p)));
129 +
                && (fs_entries
130 +
                    .keys()
131 +
                    .any(|p| p.starts_with(&format!("{path}/")))
132 +
                    || base_snapshot
133 +
                        .files
134 +
                        .keys()
135 +
                        .any(|p| p.starts_with(&format!("{path}/")) && !self.in_scope(p)));
104 136
            if !still_needed {
105 137
                b.remove_path(path, expected);
106 138
            }
107 139
        }
108 140
        let mut statuses = b.statuses;
109 141
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
110 -
        Ok(BuiltChange { tree_ops: b.tree_ops, edits: b.edits, contributors: b.contributors.into_iter().collect(), resolves: b.resolves, statuses, blobs: b.blobs })
142 +
        Ok(BuiltChange {
143 +
            tree_ops: b.tree_ops,
144 +
            edits: b.edits,
145 +
            contributors: b.contributors.into_iter().collect(),
146 +
            resolves: b.resolves,
147 +
            statuses,
148 +
            blobs: b.blobs,
149 +
        })
111 150
    }
112 151
113 152
    /// Store a change and, when `core.sign = change` and we are its author,
…
31 unchanged lines
145 184
        let reuse = added_node_paths(&old_wc, &base_snapshot);
146 185
        // The last manifest lets unchanged opaque files go unread.
147 186
        let manifest = self.read_manifest()?.map(|(_, _, m)| m);
148 -
        let fs_entries = self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?;
187 +
        let fs_entries =
188 +
            self.walk_working_copy_with(manifest.as_ref(), &Self::tracked_kinds(&base_snapshot))?;
149 189
        let built = self.build_from_entries(&base, &base_snapshot, reuse, &fs_entries)?;
150 190
151 191
        let mut wc = Change {
…
25 unchanged lines
177 217
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
178 218
        }
179 219
        // The prompt compares against this without recording anything.
180 -
        self.write_manifest_entries(base_snapshot.is_conflicted(), !wc.tree_ops.is_empty() || !wc.edits.is_empty(), &fs_entries, &built.blobs)?;
181 -
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
220 +
        self.write_manifest_entries(
221 +
            base_snapshot.is_conflicted(),
222 +
            !wc.tree_ops.is_empty() || !wc.edits.is_empty(),
223 +
            &fs_entries,
224 +
            &built.blobs,
225 +
        )?;
226 +
        Ok(SnapshotResult {
227 +
            wc_hash: new_hash,
228 +
            wc,
229 +
            base,
230 +
            base_snapshot,
231 +
            statuses,
232 +
            changed,
233 +
            resolves,
234 +
            contributors,
235 +
        })
182 236
    }
183 237
184 238
    /// `oxy status --json` (spec §9.5): the changed paths and every conflict
…
4 unchanged lines
189 243
        let base_snapshot = &snap.base_snapshot;
190 244
        let by_node = base_snapshot.tree.by_node();
191 245
        let path_of = |n: &NodeId| by_node.get(n).map(|e| e.path.clone());
192 -
        let id_of = |h: &ChangeHash| snap.base.get(h).map(|m| m.change_id.to_hex()).unwrap_or_else(|| h.to_hex());
193 -
        let paths = json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))));
246 +
        let id_of = |h: &ChangeHash| {
247 +
            snap.base
248 +
                .get(h)
249 +
                .map(|m| m.change_id.to_hex())
250 +
                .unwrap_or_else(|| h.to_hex())
251 +
        };
252 +
        let paths = json_array(snap.statuses.iter().map(|s| {
253 +
            format!(
254 +
                "{{\"path\":{},\"status\":{}}}",
255 +
                json_str(&s.path),
256 +
                json_str(&format!("{:?}", s.status))
257 +
            )
258 +
        }));
194 259
        let mut conflicts: Vec<String> = Vec::new();
195 260
        for (p, f) in &base_snapshot.files {
196 261
            if !f.is_conflicted() {
197 262
                continue;
198 263
            }
199 264
            let (kind, changes) = match &f.content {
200 -
                FileContent::Text { .. } => ("text", snap.base.conflict_regions(&f.node).contributors),
201 -
                FileContent::Opaque { blobs } => ("replace", snap.base.blob_contributors(&f.node, blobs)),
265 +
                FileContent::Text { .. } => {
266 +
                    ("text", snap.base.conflict_regions(&f.node).contributors)
267 +
                }
268 +
                FileContent::Opaque { blobs } => {
269 +
                    ("replace", snap.base.blob_contributors(&f.node, blobs))
270 +
                }
202 271
                FileContent::Dir => continue,
203 272
            };
204 -
            let resolved = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
273 +
            let resolved = match snap
274 +
                .statuses
275 +
                .iter()
276 +
                .find(|s| s.path == *p)
277 +
                .map(|s| s.status)
278 +
            {
205 279
                Some(ChangeStatus::Resolved) => "true",
206 280
                Some(ChangeStatus::ConflictEdited) => "\"markers\"",
207 281
                _ => "false",
…
9 unchanged lines
217 291
        for c in &base_snapshot.tree.conflicts {
218 292
            let (kind, nodes): (&str, Vec<NodeId>) = match c {
219 293
                crate::tree::TreeConflict::Name { nodes, .. } => ("name", nodes.clone()),
220 -
                crate::tree::TreeConflict::MultiPlacement { node, .. } => ("multi-placement", vec![*node]),
294 +
                crate::tree::TreeConflict::MultiPlacement { node, .. } => {
295 +
                    ("multi-placement", vec![*node])
296 +
                }
221 297
                crate::tree::TreeConflict::Orphan { node, .. } => ("orphan", vec![*node]),
222 298
                crate::tree::TreeConflict::Cycle { node } => ("cycle", vec![*node]),
223 299
                crate::tree::TreeConflict::Mode { node, .. } => ("mode", vec![*node]),
224 300
            };
225 301
            let detail = match c {
226 302
                crate::tree::TreeConflict::Name { name, parent, .. } => {
227 -
                    let dir = if *parent == NodeId::ROOT { String::new() } else { path_of(parent).unwrap_or_default() };
228 -
                    format!(",\"path\":{}", json_str(&if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") }))
303 +
                    let dir = if *parent == NodeId::ROOT {
304 +
                        String::new()
305 +
                    } else {
306 +
                        path_of(parent).unwrap_or_default()
307 +
                    };
308 +
                    format!(
309 +
                        ",\"path\":{}",
310 +
                        json_str(&if dir.is_empty() {
311 +
                            name.clone()
312 +
                        } else {
313 +
                            format!("{dir}/{name}")
314 +
                        })
315 +
                    )
229 316
                }
230 -
                _ => nodes.first().and_then(path_of).map(|p| format!(",\"path\":{}", json_str(&p))).unwrap_or_default(),
317 +
                _ => nodes
318 +
                    .first()
319 +
                    .and_then(path_of)
320 +
                    .map(|p| format!(",\"path\":{}", json_str(&p)))
321 +
                    .unwrap_or_default(),
231 322
            };
232 323
            conflicts.push(format!(
233 324
                "{{\"kind\":{},\"nodes\":{}{detail}}}",
…
29 unchanged lines
263 354
    /// stays on disk as uncommitted work: the next snapshot rebuilds it
264 355
    /// against the new base, so the working copy always describes the
265 356
    /// directory.
266 -
    pub fn commit_selection(&self, message: &str, extra_deps: &[ChangeHash], selection: &Selection) -> Result<ChangeHash> {
357 +
    pub fn commit_selection(
358 +
        &self,
359 +
        message: &str,
360 +
        extra_deps: &[ChangeHash],
361 +
        selection: &Selection,
362 +
    ) -> Result<ChangeHash> {
267 363
        let snap = self.snapshot("oxy commit")?;
268 364
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
269 365
            return fail("nothing to commit: the working copy matches its base");
270 366
        }
271 -
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
367 +
        if let Some(s) = snap
368 +
            .statuses
369 +
            .iter()
370 +
            .find(|s| s.status == ChangeStatus::ConflictEdited)
371 +
        {
272 372
            return fail(format!("{} still contains conflict markers", s.path));
273 373
        }
274 374
        if message.trim().is_empty() {
…
24 unchanged lines
299 399
            &format!(
300 400
                "{{\"event\":\"pre-commit\",\"message\":{},\"paths\":{}}}",
301 401
                json_str(message),
302 -
                json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))))
402 +
                json_array(snap.statuses.iter().map(|s| format!(
403 +
                    "{{\"path\":{},\"status\":{}}}",
404 +
                    json_str(&s.path),
405 +
                    json_str(&format!("{:?}", s.status))
406 +
                )))
303 407
            ),
304 408
        )?;
305 409
        let (op_ref, op) = self.head_op()?;
…
10 unchanged lines
316 420
                }
317 421
                let c = self.store.get_change(h)?;
318 422
                let mut paths: BTreeSet<String> = BTreeSet::new();
319 -
                for n in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|e| e.node())) {
423 +
                for n in c
424 +
                    .tree_ops
425 +
                    .iter()
426 +
                    .map(|o| o.node())
427 +
                    .chain(c.edits.iter().map(|e| e.node()))
428 +
                {
320 429
                    if let Some(e) = by_node.get(&n) {
321 430
                        paths.insert(e.path.clone());
322 431
                    }
…
12 unchanged lines
335 444
        deps.extend(snap.contributors.iter().copied());
336 445
        for d in extra_deps {
337 446
            if !snap.base.contains(d) {
338 -
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
447 +
                return fail(format!(
448 +
                    "--depends-on {d}: not in the working copy's base state"
449 +
                ));
339 450
            }
340 451
            deps.insert(*d);
341 452
        }
342 453
        let mut change = Change {
343 -
            change_id: if partial { ChangeId::random() } else { snap.wc.change_id },
454 +
            change_id: if partial {
455 +
                ChangeId::random()
456 +
            } else {
457 +
                snap.wc.change_id
458 +
            },
344 459
            deps: vec![],
345 460
            author: self.author(),
346 461
            hlc: Hlc::now(),
347 462
            message: message.trim_end().to_string(),
348 463
            tree_ops,
349 464
            edits,
350 -
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
465 +
            kind: if snap.resolves {
466 +
                ChangeKind::Resolution
467 +
            } else {
468 +
                ChangeKind::Normal
469 +
            },
351 470
        };
352 471
        if partial {
353 472
            // Dependencies come from what the selected part references, not
…
22 unchanged lines
376 495
        with_new.push(hash);
377 496
        let new_heads = st.maximal(&with_new);
378 497
        // Automatic checkpoint once enough changes accumulated (spec §11).
379 -
        let since = self.latest_checkpoint(&self.config.bookmark)?.map(|(_, cp)| cp.count).unwrap_or(0);
380 -
        if self.config.checkpoint_interval > 0 && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval {
498 +
        let since = self
499 +
            .latest_checkpoint(&self.config.bookmark)?
500 +
            .map(|(_, cp)| cp.count)
501 +
            .unwrap_or(0);
502 +
        if self.config.checkpoint_interval > 0
503 +
            && (st.len() as u64).saturating_sub(since) >= self.config.checkpoint_interval
504 +
        {
381 505
            let cp_state = self.load_state_full(&new_heads)?;
382 506
            self.write_checkpoint(&self.config.bookmark, &cp_state, &new_heads)?;
383 507
        }
…
4 unchanged lines
388 512
        let wc = self.new_working_copy(wc_base, "");
389 513
        let wc_hash = self.store.put_change(&wc)?;
390 514
        let mut view = op.view.clone();
391 -
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
515 +
        view.bookmarks.insert(
516 +
            self.config.bookmark.clone(),
517 +
            self.store.put_state(&StateObject::new(new_heads))?,
518 +
        );
392 519
        view.workspaces.insert(self.workspace, wc_hash);
393 520
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
394 521
        if partial {
395 522
            // The rest is rebuilt from disk by the next snapshot.
396 523
            self.drop_manifest();
397 524
        } else {
398 -
            self.rewrite_manifest_flags(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
525 +
            self.rewrite_manifest_flags(
526 +
                snap.base_snapshot.is_conflicted() && !snap.resolves,
527 +
                false,
528 +
            )?;
399 529
        }
400 530
        self.maybe_autopack()?;
401 -
        let _ = self.run_hook("post-commit", &format!("{{\"event\":\"post-commit\",\"change_id\":{},\"hash\":{},\"message\":{}}}", json_str(&snap.wc.change_id.to_hex()), json_str(&hash.to_hex()), json_str(message)));
531 +
        let _ = self.run_hook(
532 +
            "post-commit",
533 +
            &format!(
534 +
                "{{\"event\":\"post-commit\",\"change_id\":{},\"hash\":{},\"message\":{}}}",
535 +
                json_str(&snap.wc.change_id.to_hex()),
536 +
                json_str(&hash.to_hex()),
537 +
                json_str(message)
538 +
            ),
539 +
        );
402 540
        Ok(hash)
403 541
    }
404 542
}
…
19 unchanged lines
424 562
425 563
/// The tree operations and edits on nodes under `paths`, plus the
426 564
/// additions of any new parent directories they need.
427 -
fn select_paths(wc: &Change, base_snapshot: &Snapshot, paths: &[String]) -> (Vec<TreeOp>, Vec<Edit>) {
428 -
    let selected = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
565 +
fn select_paths(
566 +
    wc: &Change,
567 +
    base_snapshot: &Snapshot,
568 +
    paths: &[String],
569 +
) -> (Vec<TreeOp>, Vec<Edit>) {
570 +
    let selected = |p: &str| {
571 +
        paths
572 +
            .iter()
573 +
            .any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))))
574 +
    };
429 575
    let by_node = base_snapshot.tree.by_node();
430 576
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
431 577
    for op in &wc.tree_ops {
432 -
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
578 +
        if let TreeOp::AddNode {
579 +
            node,
580 +
            parent,
581 +
            name,
582 +
            kind,
583 +
            ..
584 +
        } = op
585 +
        {
433 586
            new_names.insert(*node, (*parent, name.clone(), *kind));
434 587
        }
435 588
    }
436 589
    let mut nodes: BTreeSet<NodeId> = BTreeSet::new();
437 -
    for n in wc.tree_ops.iter().map(|o| o.node()).chain(wc.edits.iter().map(|e| e.node())) {
590 +
    for n in wc
591 +
        .tree_ops
592 +
        .iter()
593 +
        .map(|o| o.node())
594 +
        .chain(wc.edits.iter().map(|e| e.node()))
595 +
    {
438 596
        if path_of_node(&n, &by_node, &new_names).is_some_and(|p| selected(&p)) {
439 597
            nodes.insert(n);
440 598
        }
…
8 unchanged lines
449 607
            }
450 608
        }
451 609
    }
452 -
    let ops = wc.tree_ops.iter().filter(|o| nodes.contains(&o.node())).cloned().collect();
453 -
    let edits = wc.edits.iter().filter(|e| nodes.contains(&e.node())).cloned().collect();
610 +
    let ops = wc
611 +
        .tree_ops
612 +
        .iter()
613 +
        .filter(|o| nodes.contains(&o.node()))
614 +
        .cloned()
615 +
        .collect();
616 +
    let edits = wc
617 +
        .edits
618 +
        .iter()
619 +
        .filter(|e| nodes.contains(&e.node()))
620 +
        .cloned()
621 +
        .collect();
454 622
    (ops, edits)
455 623
}
456 624
…
25 unchanged lines
482 650
            _ => None,
483 651
        });
484 652
    }
485 -
    let add_index: HashMap<NodeId, usize> = wc.tree_ops.iter().enumerate().filter_map(|(i, o)| if let TreeOp::AddNode { node, .. } = o { Some((*node, i)) } else { None }).collect();
486 -
    let insert_for = |node: &NodeId, index: u32| -> Option<usize> { insert_ranges.iter().position(|r| matches!(r, Some((n, a, b)) if n == node && *a <= index && index < *b)) };
653 +
    let add_index: HashMap<NodeId, usize> = wc
654 +
        .tree_ops
655 +
        .iter()
656 +
        .enumerate()
657 +
        .filter_map(|(i, o)| {
658 +
            if let TreeOp::AddNode { node, .. } = o {
659 +
                Some((*node, i))
660 +
            } else {
661 +
                None
662 +
            }
663 +
        })
664 +
        .collect();
665 +
    let insert_for = |node: &NodeId, index: u32| -> Option<usize> {
666 +
        insert_ranges
667 +
            .iter()
668 +
            .position(|r| matches!(r, Some((n, a, b)) if n == node && *a <= index && index < *b))
669 +
    };
487 670
    // Closure.
488 671
    let mut changed = true;
489 672
    while changed {
…
30 unchanged lines
520 703
                    changed = true;
521 704
                }
522 705
            }
523 -
            if let Edit::Insert { node, after, before, .. } = e {
706 +
            if let Edit::Insert {
707 +
                node,
708 +
                after,
709 +
                before,
710 +
                ..
711 +
            } = e
712 +
            {
524 713
                for r in [after, before] {
525 714
                    if let AtomRef::Atom { change, index } = r {
526 715
                        if change.is_zero() {
…
23 unchanged lines
550 739
    }
551 740
    let map_ref = |r: AtomRef, node: &NodeId| -> AtomRef {
552 741
        match r {
553 -
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom { change, index: remap.get(&(*node, index)).copied().unwrap_or(index) },
742 +
            AtomRef::Atom { change, index } if change.is_zero() => AtomRef::Atom {
743 +
                change,
744 +
                index: remap.get(&(*node, index)).copied().unwrap_or(index),
745 +
            },
554 746
            other => other,
555 747
        }
556 748
    };
557 -
    let ops = wc.tree_ops.iter().enumerate().filter(|(i, _)| ops_sel[*i]).map(|(_, o)| o.clone()).collect();
749 +
    let ops = wc
750 +
        .tree_ops
751 +
        .iter()
752 +
        .enumerate()
753 +
        .filter(|(i, _)| ops_sel[*i])
754 +
        .map(|(_, o)| o.clone())
755 +
        .collect();
558 756
    let edits = wc
559 757
        .edits
560 758
        .iter()
561 759
        .enumerate()
562 760
        .filter(|(j, _)| edits_sel[*j])
563 761
        .map(|(_, e)| match e {
564 -
            Edit::Insert { node, after, before, lines } => Edit::Insert { node: *node, after: map_ref(*after, node), before: map_ref(*before, node), lines: lines.clone() },
762 +
            Edit::Insert {
763 +
                node,
764 +
                after,
765 +
                before,
766 +
                lines,
767 +
            } => Edit::Insert {
768 +
                node: *node,
769 +
                after: map_ref(*after, node),
770 +
                before: map_ref(*before, node),
771 +
                lines: lines.clone(),
772 +
            },
565 773
            other => other.clone(),
566 774
        })
567 775
        .collect();
…
5 unchanged lines
573 781
    let by_node = base_snapshot.tree.by_node();
574 782
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
575 783
    for op in &change.tree_ops {
576 -
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
784 +
        if let TreeOp::AddNode {
785 +
            node,
786 +
            parent,
787 +
            name,
788 +
            kind,
789 +
            ..
790 +
        } = op
791 +
        {
577 792
            new_names.insert(*node, (*parent, name.clone(), *kind));
578 793
        }
579 794
    }
580 795
    let mut out = Vec::new();
581 796
    for (i, op) in change.tree_ops.iter().enumerate() {
582 -
        let one = Change { tree_ops: vec![op.clone()], edits: vec![], ..change.clone() };
797 +
        let one = Change {
798 +
            tree_ops: vec![op.clone()],
799 +
            edits: vec![],
800 +
            ..change.clone()
801 +
        };
583 802
        let path = path_of_node(&op.node(), &by_node, &new_names).unwrap_or_default();
584 -
        out.push(Hunk { index: i, path, text: render_change(&one, base, base_snapshot) });
803 +
        out.push(Hunk {
804 +
            index: i,
805 +
            path,
806 +
            text: render_change(&one, base, base_snapshot),
807 +
        });
585 808
    }
586 809
    for (j, e) in change.edits.iter().enumerate() {
587 -
        let one = Change { tree_ops: vec![], edits: vec![e.clone()], ..change.clone() };
810 +
        let one = Change {
811 +
            tree_ops: vec![],
812 +
            edits: vec![e.clone()],
813 +
            ..change.clone()
814 +
        };
588 815
        let path = path_of_node(&e.node(), &by_node, &new_names).unwrap_or_default();
589 -
        out.push(Hunk { index: change.tree_ops.len() + j, path, text: render_change(&one, base, base_snapshot) });
816 +
        out.push(Hunk {
817 +
            index: change.tree_ops.len() + j,
818 +
            path,
819 +
            text: render_change(&one, base, base_snapshot),
820 +
        });
590 821
    }
591 822
    out
592 823
}
…
28 unchanged lines
621 852
                self.load_state(&wc.deps)?.materialize()
622 853
            }
623 854
        };
624 -
        let Some(f) = snapshot.files.get(path) else { return fail(format!("{path} is not in that state")) };
855 +
        let Some(f) = snapshot.files.get(path) else {
856 +
            return fail(format!("{path} is not in that state"));
857 +
        };
625 858
        match &f.content {
626 859
            FileContent::Dir => fail(format!("{path} is a directory")),
627 860
            FileContent::Text { bytes, .. } => Ok(bytes.clone()),
…
9 unchanged lines
637 870
    /// number of paths touched.
638 871
    pub fn discard(&self, paths: &[String]) -> Result<usize> {
639 872
        let snap = self.snapshot("oxy discard")?;
640 -
        let under = |p: &str| paths.iter().any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))));
873 +
        let under = |p: &str| {
874 +
            paths
875 +
                .iter()
876 +
                .any(|x| p == x || p.starts_with(&format!("{}/", x.trim_end_matches('/'))))
877 +
        };
641 878
        let attrs = Repo::state_attributes(&snap.base_snapshot);
642 879
        let mut n = 0;
643 880
        for s in &snap.statuses {
…
15 unchanged lines
659 896
                    match &f.content {
660 897
                        FileContent::Dir => fs::create_dir_all(self.root.join(&s.path))?,
661 898
                        FileContent::Text { bytes, .. } => {
662 -
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&s.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
899 +
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&s.path).eol
900 +
                                == Some(crate::attributes::Eol::Crlf)
901 +
                            {
902 +
                                crate::attributes::to_crlf(bytes).into()
903 +
                            } else {
904 +
                                bytes.as_slice().into()
905 +
                            };
663 906
                            self.write_file(&s.path, &on_disk, f.mode.exec)?;
664 907
                        }
665 908
                        FileContent::Opaque { blobs } => {
…
14 unchanged lines
680 923
        }
681 924
        // A rename's old name is not in the statuses; put it back too.
682 925
        for (p, f) in &snap.base_snapshot.files {
683 -
            if under(p) && !self.root.join(p).exists() && f.kind != NodeKind::Dir && f.kind != NodeKind::Subrepo && !snap.statuses.iter().any(|s| s.path == *p) {
926 +
            if under(p)
927 +
                && !self.root.join(p).exists()
928 +
                && f.kind != NodeKind::Dir
929 +
                && f.kind != NodeKind::Subrepo
930 +
                && !snap.statuses.iter().any(|s| s.path == *p)
931 +
            {
684 932
                let bytes = match &f.content {
685 933
                    FileContent::Text { bytes, .. } => bytes.clone(),
686 -
                    FileContent::Opaque { blobs } => blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default(),
934 +
                    FileContent::Opaque { blobs } => blobs
935 +
                        .first()
936 +
                        .map(|b| self.read_blob(b))
937 +
                        .transpose()?
938 +
                        .unwrap_or_default(),
687 939
                    FileContent::Dir => continue,
688 940
                };
689 -
                if f.kind == NodeKind::Symlink { self.write_symlink(p, &bytes)? } else { self.write_file(p, &bytes, f.mode.exec)? }
941 +
                if f.kind == NodeKind::Symlink {
942 +
                    self.write_symlink(p, &bytes)?
943 +
                } else {
944 +
                    self.write_file(p, &bytes, f.mode.exec)?
945 +
                }
690 946
                n += 1;
691 947
            }
692 948
        }
…
6 unchanged lines
699 955
    /// the remaining hunks.
700 956
    pub fn discard_hunks(&self, indices: &[usize]) -> Result<usize> {
701 957
        let (snap, hunks) = self.hunks()?;
702 -
        let keep: Vec<usize> = hunks.iter().map(|h| h.index).filter(|i| !indices.contains(i)).collect();
958 +
        let keep: Vec<usize> = hunks
959 +
            .iter()
960 +
            .map(|h| h.index)
961 +
            .filter(|i| !indices.contains(i))
962 +
            .collect();
703 963
        let (ops, edits) = select_hunks(&snap.wc, &keep);
704 964
        let dropped_nodes: BTreeSet<NodeId> = hunks
705 965
            .iter()
706 966
            .filter(|h| indices.contains(&h.index))
707 -
            .map(|h| if h.index < snap.wc.tree_ops.len() { snap.wc.tree_ops[h.index].node() } else { snap.wc.edits[h.index - snap.wc.tree_ops.len()].node() })
967 +
            .map(|h| {
968 +
                if h.index < snap.wc.tree_ops.len() {
969 +
                    snap.wc.tree_ops[h.index].node()
970 +
                } else {
971 +
                    snap.wc.edits[h.index - snap.wc.tree_ops.len()].node()
972 +
                }
973 +
            })
708 974
            .collect();
709 975
        let mut partial = snap.wc.clone();
710 976
        partial.tree_ops = ops;
…
13 unchanged lines
724 990
                    match &f.content {
725 991
                        FileContent::Dir => {}
726 992
                        FileContent::Text { bytes, .. } => {
727 -
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&e.path).eol == Some(crate::attributes::Eol::Crlf) { crate::attributes::to_crlf(bytes).into() } else { bytes.as_slice().into() };
993 +
                            let on_disk: std::borrow::Cow<[u8]> = if attrs.lookup(&e.path).eol
994 +
                                == Some(crate::attributes::Eol::Crlf)
995 +
                            {
996 +
                                crate::attributes::to_crlf(bytes).into()
997 +
                            } else {
998 +
                                bytes.as_slice().into()
999 +
                            };
728 1000
                            self.write_file(&e.path, &on_disk, f.mode.exec)?;
729 1001
                        }
730 1002
                        FileContent::Opaque { blobs } => {
731 -
                            let bytes = blobs.first().map(|b| self.read_blob(b)).transpose()?.unwrap_or_default();
732 -
                            if f.kind == NodeKind::Symlink { self.write_symlink(&e.path, &bytes)? } else { self.write_file(&e.path, &bytes, f.mode.exec)? }
1003 +
                            let bytes = blobs
1004 +
                                .first()
1005 +
                                .map(|b| self.read_blob(b))
1006 +
                                .transpose()?
1007 +
                                .unwrap_or_default();
1008 +
                            if f.kind == NodeKind::Symlink {
1009 +
                                self.write_symlink(&e.path, &bytes)?
1010 +
                            } else {
1011 +
                                self.write_file(&e.path, &bytes, f.mode.exec)?
1012 +
                            }
733 1013
                        }
734 1014
                    }
735 1015
                    // A discarded rename: the new name goes.
736 -
                    if let Some(old_path) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Renamed && !target.files.contains_key(&s.path)).map(|s| s.path.clone()) {
1016 +
                    if let Some(old_path) = snap
1017 +
                        .statuses
1018 +
                        .iter()
1019 +
                        .find(|s| {
1020 +
                            s.status == ChangeStatus::Renamed && !target.files.contains_key(&s.path)
1021 +
                        })
1022 +
                        .map(|s| s.path.clone())
1023 +
                    {
737 1024
                        let _ = fs::remove_file(self.root.join(old_path));
738 1025
                    }
739 1026
                    n += 1;
740 1027
                }
741 1028
                None => {
742 1029
                    // The node exists in neither target nor base with a live path: an addition to remove.
743 -
                    if let Some(added) = snap.statuses.iter().find(|s| s.status == ChangeStatus::Added && !target.files.contains_key(&s.path) && !by_node_base.contains_key(&node)) {
1030 +
                    if let Some(added) = snap.statuses.iter().find(|s| {
1031 +
                        s.status == ChangeStatus::Added
1032 +
                            && !target.files.contains_key(&s.path)
1033 +
                            && !by_node_base.contains_key(&node)
1034 +
                    }) {
744 1035
                        let full = self.root.join(&added.path);
745 -
                        if full.is_dir() { let _ = fs::remove_dir_all(&full); } else { let _ = fs::remove_file(&full); }
1036 +
                        if full.is_dir() {
1037 +
                            let _ = fs::remove_dir_all(&full);
1038 +
                        } else {
1039 +
                            let _ = fs::remove_file(&full);
1040 +
                        }
746 1041
                        n += 1;
747 1042
                    }
748 1043
                }
…
32 unchanged lines
781 1076
        let (_, wc) = self.working_copy()?;
782 1077
        let base_snapshot = self.load_state(&wc.deps)?.materialize();
783 1078
        let state_snapshot = self.load_state(&heads)?.materialize();
784 -
        let node = base_snapshot.files.get(path).or_else(|| state_snapshot.files.get(path)).map(|f| f.node);
785 -
        let Some(node) = node else { return fail(format!("{path} is not tracked")) };
1079 +
        let node = base_snapshot
1080 +
            .files
1081 +
            .get(path)
1082 +
            .or_else(|| state_snapshot.files.get(path))
1083 +
            .map(|f| f.node);
1084 +
        let Some(node) = node else {
1085 +
            return fail(format!("{path} is not tracked"));
1086 +
        };
786 1087
        Ok(self.entries_touching(entries, node))
787 1088
    }
788 1089
…
2 unchanged lines
791 1092
    pub fn log_for_path_in(&self, path: &str, heads: &[ChangeHash]) -> Result<Vec<LogEntry>> {
792 1093
        let entries = self.log_state(heads)?;
793 1094
        let snapshot = self.load_state(heads)?.materialize();
794 -
        let Some(node) = snapshot.files.get(path).map(|f| f.node) else { return fail(format!("{path} is not in that state")) };
1095 +
        let Some(node) = snapshot.files.get(path).map(|f| f.node) else {
1096 +
            return fail(format!("{path} is not in that state"));
1097 +
        };
795 1098
        Ok(self.entries_touching(entries, node))
796 1099
    }
797 1100
798 1101
    fn entries_touching(&self, entries: Vec<LogEntry>, node: NodeId) -> Vec<LogEntry> {
799 1102
        let mut out = Vec::new();
800 1103
        for e in entries {
801 -
            let Ok(c) = self.store.get_change(&e.hash) else { continue };
802 -
            if c.tree_ops.iter().any(|o| o.node() == node) || c.edits.iter().any(|x| x.node() == node) {
1104 +
            let Ok(c) = self.store.get_change(&e.hash) else {
1105 +
                continue;
1106 +
            };
1107 +
            if c.tree_ops.iter().any(|o| o.node() == node)
1108 +
                || c.edits.iter().any(|x| x.node() == node)
1109 +
            {
803 1110
                out.push(e);
804 1111
            }
805 1112
        }
…
6 unchanged lines
812 1119
    let by_node = base_snapshot.tree.by_node();
813 1120
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
814 1121
    for op in &change.tree_ops {
815 -
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1122 +
        if let TreeOp::AddNode {
1123 +
            node,
1124 +
            parent,
1125 +
            name,
1126 +
            kind,
1127 +
            ..
1128 +
        } = op
1129 +
        {
816 1130
            new_names.insert(*node, (*parent, name.clone(), *kind));
817 1131
        }
818 1132
    }
819 -
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
1133 +
    let path_of = |n: &NodeId| -> String {
1134 +
        path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8)))
1135 +
    };
820 1136
    let mut files: BTreeMap<NodeId, FileDiff> = BTreeMap::new();
821 1137
    let mut order: Vec<NodeId> = Vec::new();
822 1138
    let mut entry = |files: &mut BTreeMap<NodeId, FileDiff>, n: NodeId, kind: &str| {
823 1139
        if let std::collections::btree_map::Entry::Vacant(v) = files.entry(n) {
824 1140
            order.push(n);
825 -
            v.insert(FileDiff { path: path_of(&n), kind: kind.to_string(), hunks: vec![], from: None });
1141 +
            v.insert(FileDiff {
1142 +
                path: path_of(&n),
1143 +
                kind: kind.to_string(),
1144 +
                hunks: vec![],
1145 +
                from: None,
1146 +
            });
826 1147
        }
827 1148
    };
828 1149
    for op in &change.tree_ops {
…
9 unchanged lines
838 1159
                    files.get_mut(node).unwrap().path = e.path.clone();
839 1160
                }
840 1161
            }
841 -
            TreeOp::Move { node, parent, name, .. } => {
1162 +
            TreeOp::Move {
1163 +
                node, parent, name, ..
1164 +
            } => {
842 1165
                entry(&mut files, *node, "renamed");
843 1166
                let f = files.get_mut(node).unwrap();
844 1167
                f.from = by_node.get(node).map(|e| e.path.clone());
845 -
                let dir = if parent.is_root() { String::new() } else { path_of_node(parent, &by_node, &new_names).unwrap_or_default() };
846 -
                f.path = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") };
1168 +
                let dir = if parent.is_root() {
1169 +
                    String::new()
1170 +
                } else {
1171 +
                    path_of_node(parent, &by_node, &new_names).unwrap_or_default()
1172 +
                };
1173 +
                f.path = if dir.is_empty() {
1174 +
                    name.clone()
1175 +
                } else {
1176 +
                    format!("{dir}/{name}")
1177 +
                };
847 1178
            }
848 1179
            TreeOp::SetMode { node, .. } => entry(&mut files, *node, "mode"),
849 1180
        }
…
1 unchanged lines
851 1182
    // Base line numbers per node, 1-based.
852 1183
    let mut line_of: HashMap<NodeId, HashMap<(ChangeHash, u32), u32>> = HashMap::new();
853 1184
    let mut lines_for = |n: &NodeId| -> HashMap<(ChangeHash, u32), u32> {
854 -
        line_of.entry(*n).or_insert_with(|| base.live_lines(n).into_iter().enumerate().map(|(i, (k, _))| (k, i as u32 + 1)).collect()).clone()
1185 +
        line_of
1186 +
            .entry(*n)
1187 +
            .or_insert_with(|| {
1188 +
                base.live_lines(n)
1189 +
                    .into_iter()
1190 +
                    .enumerate()
1191 +
                    .map(|(i, (k, _))| (k, i as u32 + 1))
1192 +
                    .collect()
1193 +
            })
1194 +
            .clone()
855 1195
    };
856 1196
    for e in &change.edits {
857 1197
        let n = e.node();
…
6 unchanged lines
864 1204
                let base_line = match after {
865 1205
                    AtomRef::Start => 0,
866 1206
                    AtomRef::End => map.len() as u32,
867 -
                    AtomRef::Atom { change, index } if change.is_zero() => f.hunks.last().map(|h| h.base_line).unwrap_or(0),
868 -
                    AtomRef::Atom { change, index } => map.get(&(*change, *index)).copied().unwrap_or(0),
1207 +
                    AtomRef::Atom { change, index } if change.is_zero() => {
1208 +
                        f.hunks.last().map(|h| h.base_line).unwrap_or(0)
1209 +
                    }
1210 +
                    AtomRef::Atom { change, index } => {
1211 +
                        map.get(&(*change, *index)).copied().unwrap_or(0)
1212 +
                    }
869 1213
                };
870 -
                f.hunks.push(DiffHunk { base_line, added: lines.clone(), removed: vec![] });
1214 +
                f.hunks.push(DiffHunk {
1215 +
                    base_line,
1216 +
                    added: lines.clone(),
1217 +
                    removed: vec![],
1218 +
                });
871 1219
            }
872 1220
            Edit::Delete { atoms, .. } => {
873 1221
                let map = lines_for(&n);
…
1 unchanged lines
875 1223
                for r in atoms {
876 1224
                    for i in r.from..r.to {
877 1225
                        let line = map.get(&(r.change, i)).copied().unwrap_or(0);
878 -
                        let text = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"").to_vec();
1226 +
                        let text = base
1227 +
                            .file(&n)
1228 +
                            .and_then(|g| g.content(&(r.change, i)))
1229 +
                            .unwrap_or(b"")
1230 +
                            .to_vec();
879 1231
                        removed.push((line, text));
880 1232
                    }
881 1233
                }
882 -
                let base_line = removed.first().map(|(l, _)| l.saturating_sub(1)).unwrap_or(0);
883 -
                f.hunks.push(DiffHunk { base_line, added: vec![], removed });
1234 +
                let base_line = removed
1235 +
                    .first()
1236 +
                    .map(|(l, _)| l.saturating_sub(1))
1237 +
                    .unwrap_or(0);
1238 +
                f.hunks.push(DiffHunk {
1239 +
                    base_line,
1240 +
                    added: vec![],
1241 +
                    removed,
1242 +
                });
884 1243
            }
885 1244
            Edit::Replace { .. } => {
886 1245
                if !is_new {
…
22 unchanged lines
909 1268
}
910 1269
911 1270
impl<'a> ChangeBuilder<'a> {
912 -
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
1271 +
    fn new(
1272 +
        base: &'a State,
1273 +
        base_snapshot: &'a Snapshot,
1274 +
        reuse: HashMap<String, (NodeId, NodeKind)>,
1275 +
    ) -> Self {
913 1276
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
914 1277
        dir_nodes.insert(String::new(), NodeId::ROOT);
915 1278
        for (p, f) in &base_snapshot.files {
…
1 unchanged lines
917 1280
                dir_nodes.insert(p.clone(), f.node);
918 1281
            }
919 1282
        }
920 -
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false, attrs: crate::attributes::Attributes::default(), blobs: HashMap::new() }
1283 +
        ChangeBuilder {
1284 +
            base,
1285 +
            base_snapshot,
1286 +
            tree_ops: vec![],
1287 +
            edits: vec![],
1288 +
            statuses: vec![],
1289 +
            dir_nodes,
1290 +
            reuse,
1291 +
            contributors: BTreeSet::new(),
1292 +
            resolves: false,
1293 +
            attrs: crate::attributes::Attributes::default(),
1294 +
            blobs: HashMap::new(),
1295 +
        }
921 1296
    }
922 1297
923 1298
    fn with_attributes(mut self, attrs: crate::attributes::Attributes) -> Self {
…
4 unchanged lines
928 1303
    /// Whether a file is opaque: forced by `binary`, forced text by `text`
929 1304
    /// only when it has no NUL bytes, else sniffed.
930 1305
    /// The blob for an opaque entry: the cached one, else stored now.
931 -
    fn blob_of(&mut self, repo: &Repo, path: &str, e: &FsEntry, nocompress: bool) -> Result<ObjectRef> {
1306 +
    fn blob_of(
1307 +
        &mut self,
1308 +
        repo: &Repo,
1309 +
        path: &str,
1310 +
        e: &FsEntry,
1311 +
        nocompress: bool,
1312 +
    ) -> Result<ObjectRef> {
932 1313
        let b = match e.blob {
933 1314
            Some(b) => b,
934 1315
            None => repo.store_blob_for(&e.content, nocompress)?,
…
18 unchanged lines
953 1334
954 1335
    /// Text content as stored: `\r\n` normalized for `eol=` files.
955 1336
    fn stored_text(&self, path: &str, content: &[u8]) -> Vec<u8> {
956 -
        if self.attrs.lookup(path).eol.is_some() { crate::attributes::normalize_eol(content) } else { content.to_vec() }
1337 +
        if self.attrs.lookup(path).eol.is_some() {
1338 +
            crate::attributes::normalize_eol(content)
1339 +
        } else {
1340 +
            content.to_vec()
1341 +
        }
957 1342
    }
958 1343
959 1344
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
960 -
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
1345 +
        self.reuse
1346 +
            .get(path)
1347 +
            .filter(|(_, k)| *k == kind)
1348 +
            .map(|(n, _)| *n)
961 1349
    }
962 1350
963 1351
    fn ensure_dir(&mut self, path: &str) -> NodeId {
…
10 unchanged lines
974 1362
            parent = match self.dir_nodes.get(&prefix) {
975 1363
                Some(n) => *n,
976 1364
                None => {
977 -
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
978 -
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
1365 +
                    let node = self
1366 +
                        .reused_node(&prefix, NodeKind::Dir)
1367 +
                        .unwrap_or_else(NodeId::random);
1368 +
                    self.tree_ops.push(TreeOp::AddNode {
1369 +
                        node,
1370 +
                        kind: NodeKind::Dir,
1371 +
                        mode: Mode::default(),
1372 +
                        parent,
1373 +
                        name: comp.to_string(),
1374 +
                    });
979 1375
                    self.dir_nodes.insert(prefix.clone(), node);
980 1376
                    node
981 1377
                }
…
5 unchanged lines
987 1383
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
988 1384
        let (dir, name) = split_path(path);
989 1385
        let parent = self.ensure_dir(dir);
990 -
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
1386 +
        let node = self
1387 +
            .reused_node(path, e.kind)
1388 +
            .unwrap_or_else(NodeId::random);
991 1389
        let opaque = self.opaque_for(path, e);
992 1390
        let nocompress = self.attrs.lookup(path).nocompress;
993 -
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
1391 +
        self.tree_ops.push(TreeOp::AddNode {
1392 +
            node,
1393 +
            kind: e.kind,
1394 +
            mode: Mode {
1395 +
                exec: e.exec,
1396 +
                opaque,
1397 +
            },
1398 +
            parent,
1399 +
            name: name.to_string(),
1400 +
        });
994 1401
        if opaque {
995 1402
            let blob = self.blob_of(repo, path, e, nocompress)?;
996 -
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1403 +
            self.edits.push(Edit::Replace {
1404 +
                node,
1405 +
                old: None,
1406 +
                new: Some(blob),
1407 +
            });
997 1408
        } else if !e.content.is_empty() {
998 1409
            let content = self.stored_text(path, &e.content);
999 1410
            self.edits.push(Edit::Insert {
…
3 unchanged lines
1003 1414
                lines: split_lines(&content).iter().map(|l| l.to_vec()).collect(),
1004 1415
            });
1005 1416
        }
1006 -
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
1417 +
        self.statuses.push(PathStatus {
1418 +
            path: path.to_string(),
1419 +
            status: ChangeStatus::Added,
1420 +
        });
1007 1421
        Ok(())
1008 1422
    }
1009 1423
1010 1424
    /// Move the node at `old` to `new` and apply `e`'s content to it.
1011 -
    fn move_path(&mut self, repo: &Repo, old: &str, new: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
1425 +
    fn move_path(
1426 +
        &mut self,
1427 +
        repo: &Repo,
1428 +
        old: &str,
1429 +
        new: &str,
1430 +
        e: &FsEntry,
1431 +
        expected: &crate::state::FileState,
1432 +
    ) -> Result<()> {
1012 1433
        let entry = &self.base_snapshot.tree.entries[old];
1013 1434
        let (dir, name) = split_path(new);
1014 1435
        let parent = self.ensure_dir(dir);
1015 -
        self.tree_ops.push(TreeOp::Move { node: expected.node, old: op_ref(entry.placement), parent, name: name.to_string() });
1436 +
        self.tree_ops.push(TreeOp::Move {
1437 +
            node: expected.node,
1438 +
            old: op_ref(entry.placement),
1439 +
            parent,
1440 +
            name: name.to_string(),
1441 +
        });
1016 1442
        self.update_path(repo, old, e, expected)?;
1017 1443
        // update_path reports under the old path; the rename is what the user sees.
1018 1444
        self.statuses.retain(|s| s.path != old);
1019 -
        self.statuses.push(PathStatus { path: new.to_string(), status: ChangeStatus::Renamed });
1445 +
        self.statuses.push(PathStatus {
1446 +
            path: new.to_string(),
1447 +
            status: ChangeStatus::Renamed,
1448 +
        });
1020 1449
        Ok(())
1021 1450
    }
1022 1451
1023 1452
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
1024 1453
        let entry = &self.base_snapshot.tree.entries[path];
1025 -
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
1454 +
        self.tree_ops.push(TreeOp::RemoveNode {
1455 +
            node: expected.node,
1456 +
            placement: op_ref(entry.placement),
1457 +
        });
1026 1458
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
1027 1459
            self.resolves = true;
1028 1460
        }
1029 -
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
1461 +
        self.statuses.push(PathStatus {
1462 +
            path: path.to_string(),
1463 +
            status: ChangeStatus::Removed,
1464 +
        });
1030 1465
    }
1031 1466
1032 -
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
1467 +
    fn update_path(
1468 +
        &mut self,
1469 +
        repo: &Repo,
1470 +
        path: &str,
1471 +
        e: &FsEntry,
1472 +
        expected: &crate::state::FileState,
1473 +
    ) -> Result<()> {
1033 1474
        if expected.kind != e.kind {
1034 1475
            self.remove_path(path, expected);
1035 1476
            self.statuses.pop();
1036 1477
            self.add_path(repo, path, e)?;
1037 1478
            self.statuses.pop();
1038 -
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
1479 +
            self.statuses.push(PathStatus {
1480 +
                path: path.to_string(),
1481 +
                status: ChangeStatus::Modified,
1482 +
            });
1039 1483
            return Ok(());
1040 1484
        }
1041 1485
        let entry = &self.base_snapshot.tree.entries[path];
…
5 unchanged lines
1047 1491
        let disk_text = self.stored_text(path, &e.content);
1048 1492
        match &expected.content {
1049 1493
            FileContent::Dir => {}
1050 -
            FileContent::Text { bytes, materialized } => {
1494 +
            FileContent::Text {
1495 +
                bytes,
1496 +
                materialized,
1497 +
            } => {
1051 1498
                if materialized.is_conflicted() {
1052 1499
                    if bytes == &disk_text {
1053 1500
                        return Ok(());
1054 1501
                    }
1055 1502
                    if has_conflict_markers(&e.content) {
1056 -
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1503 +
                        self.statuses.push(PathStatus {
1504 +
                            path: path.to_string(),
1505 +
                            status: ChangeStatus::ConflictEdited,
1506 +
                        });
1057 1507
                        return Ok(());
1058 1508
                    }
1059 1509
                    // Resolution (spec §9.6): diff against the unconflicted lines
…
3 unchanged lines
1063 1513
                    let regions = self.base.conflict_regions(&node);
1064 1514
                    let mut edits = text_edits(node, &regions.plain, &disk_text);
1065 1515
                    if !regions.region_atoms.is_empty() {
1066 -
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
1516 +
                        edits.insert(
1517 +
                            0,
1518 +
                            Edit::Delete {
1519 +
                                node,
1520 +
                                atoms: ranges(&regions.region_atoms),
1521 +
                            },
1522 +
                        );
1067 1523
                    }
1068 1524
                    self.edits.extend(edits);
1069 1525
                    self.contributors.extend(regions.contributors);
1070 1526
                    self.resolves = true;
1071 1527
                    if e.exec != expected.mode.exec {
1072 1528
                        if let Some(old) = mode_atom {
1073 -
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1529 +
                            self.tree_ops.push(TreeOp::SetMode {
1530 +
                                node,
1531 +
                                old,
1532 +
                                mode: Mode {
1533 +
                                    exec: e.exec,
1534 +
                                    opaque: false,
1535 +
                                },
1536 +
                            });
1074 1537
                        }
1075 1538
                    }
1076 -
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
1539 +
                    self.statuses.push(PathStatus {
1540 +
                        path: path.to_string(),
1541 +
                        status: ChangeStatus::Resolved,
1542 +
                    });
1077 1543
                    return Ok(());
1078 1544
                }
1079 1545
                if wants_opaque {
1080 1546
                    // Text → opaque (spec §7.5), by content or by attribute.
1081 1547
                    if let Some(old) = mode_atom {
1082 -
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1548 +
                        self.tree_ops.push(TreeOp::SetMode {
1549 +
                            node,
1550 +
                            old,
1551 +
                            mode: Mode {
1552 +
                                exec: e.exec,
1553 +
                                opaque: true,
1554 +
                            },
1555 +
                        });
1083 1556
                    }
1084 1557
                    let live: Vec<_> = repo_live_keys(self.base, &node);
1085 1558
                    if !live.is_empty() {
1086 -
                        self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
1559 +
                        self.edits.push(Edit::Delete {
1560 +
                            node,
1561 +
                            atoms: ranges(&live),
1562 +
                        });
1087 1563
                    }
1088 1564
                    let blob = self.blob_of(repo, path, e, nocompress)?;
1089 -
                    self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1565 +
                    self.edits.push(Edit::Replace {
1566 +
                        node,
1567 +
                        old: None,
1568 +
                        new: Some(blob),
1569 +
                    });
1090 1570
                    status = Some(ChangeStatus::Modified);
1091 1571
                } else if bytes != &disk_text {
1092 1572
                    let old = self.base.live_lines(&node);
…
6 unchanged lines
1099 1579
                let switched_to_opaque = wants_opaque;
1100 1580
                if !switched_to_opaque && e.exec != expected.mode.exec {
1101 1581
                    if let Some(old) = mode_atom {
1102 -
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1582 +
                        self.tree_ops.push(TreeOp::SetMode {
1583 +
                            node,
1584 +
                            old,
1585 +
                            mode: Mode {
1586 +
                                exec: e.exec,
1587 +
                                opaque: false,
1588 +
                            },
1589 +
                        });
1103 1590
                        status.get_or_insert(ChangeStatus::ModeChanged);
1104 1591
                    }
1105 1592
                }
…
6 unchanged lines
1112 1599
                }
1113 1600
                if blobs.len() > 1 {
1114 1601
                    if !blobs.contains(&new_ref) {
1115 -
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1602 +
                        self.statuses.push(PathStatus {
1603 +
                            path: path.to_string(),
1604 +
                            status: ChangeStatus::ConflictEdited,
1605 +
                        });
1116 1606
                    }
1117 1607
                    return Ok(());
1118 1608
                }
…
2 unchanged lines
1121 1611
                    if e.kind == NodeKind::File && !wants_opaque {
1122 1612
                        // Opaque → text (spec §7.5).
1123 1613
                        if let Some(old) = mode_atom {
1124 -
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1614 +
                            self.tree_ops.push(TreeOp::SetMode {
1615 +
                                node,
1616 +
                                old,
1617 +
                                mode: Mode {
1618 +
                                    exec: e.exec,
1619 +
                                    opaque: false,
1620 +
                                },
1621 +
                            });
1125 1622
                        }
1126 -
                        self.edits.push(Edit::Replace { node, old: current, new: None });
1623 +
                        self.edits.push(Edit::Replace {
1624 +
                            node,
1625 +
                            old: current,
1626 +
                            new: None,
1627 +
                        });
1127 1628
                        if !e.content.is_empty() {
1128 1629
                            self.edits.push(Edit::Insert {
1129 1630
                                node,
…
4 unchanged lines
1134 1635
                        }
1135 1636
                    } else {
1136 1637
                        let blob = self.blob_of(repo, path, e, nocompress)?;
1137 -
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
1638 +
                        self.edits.push(Edit::Replace {
1639 +
                            node,
1640 +
                            old: current,
1641 +
                            new: Some(blob),
1642 +
                        });
1138 1643
                    }
1139 1644
                    status = Some(ChangeStatus::Modified);
1140 1645
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
1141 1646
                    if let Some(old) = mode_atom {
1142 -
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1647 +
                        self.tree_ops.push(TreeOp::SetMode {
1648 +
                            node,
1649 +
                            old,
1650 +
                            mode: Mode {
1651 +
                                exec: e.exec,
1652 +
                                opaque: true,
1653 +
                            },
1654 +
                        });
1143 1655
                        status = Some(ChangeStatus::ModeChanged);
1144 1656
                    }
1145 1657
                }
1146 1658
            }
1147 1659
        }
1148 1660
        if let Some(s) = status {
1149 -
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
1661 +
            self.statuses.push(PathStatus {
1662 +
                path: path.to_string(),
1663 +
                status: s,
1664 +
            });
1150 1665
        }
1151 1666
        Ok(())
1152 1667
    }
…
20 unchanged lines
1173 1688
                nodes.push(*node);
1174 1689
                opref(placement, &mut out);
1175 1690
            }
1176 -
            TreeOp::Move { node, old, parent, .. } => {
1691 +
            TreeOp::Move {
1692 +
                node, old, parent, ..
1693 +
            } => {
1177 1694
                nodes.push(*node);
1178 1695
                nodes.push(*parent);
1179 1696
                opref(old, &mut out);
…
35 unchanged lines
1215 1732
}
1216 1733
1217 1734
/// Paths of the nodes a change adds on top of `base_snapshot`.
1218 -
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
1735 +
pub fn added_node_paths(
1736 +
    change: &Change,
1737 +
    base_snapshot: &Snapshot,
1738 +
) -> HashMap<String, (NodeId, NodeKind)> {
1219 1739
    let by_node = base_snapshot.tree.by_node();
1220 1740
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1221 1741
    for op in &change.tree_ops {
1222 -
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1742 +
        if let TreeOp::AddNode {
1743 +
            node,
1744 +
            parent,
1745 +
            name,
1746 +
            kind,
1747 +
            ..
1748 +
        } = op
1749 +
        {
1223 1750
            new_names.insert(*node, (*parent, name.clone(), *kind));
1224 1751
        }
1225 1752
    }
…
39 unchanged lines
1265 1792
    let by_node = base_snapshot.tree.by_node();
1266 1793
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1267 1794
    for op in &change.tree_ops {
1268 -
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1795 +
        if let TreeOp::AddNode {
1796 +
            node,
1797 +
            parent,
1798 +
            name,
1799 +
            kind,
1800 +
            ..
1801 +
        } = op
1802 +
        {
1269 1803
            new_names.insert(*node, (*parent, name.clone(), *kind));
1270 1804
        }
1271 1805
    }
1272 -
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
1806 +
    let path_of = |n: &NodeId| -> String {
1807 +
        path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8)))
1808 +
    };
1273 1809
1274 1810
    let mut out = String::new();
1275 1811
    for op in &change.tree_ops {
1276 1812
        match op {
1277 -
            TreeOp::AddNode { node, kind, mode, .. } => {
1278 -
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
1813 +
            TreeOp::AddNode {
1814 +
                node, kind, mode, ..
1815 +
            } => {
1816 +
                out.push_str(&format!(
1817 +
                    "A {} ({:?}{}{})\n",
1818 +
                    path_of(node),
1819 +
                    kind,
1820 +
                    if mode.exec { ", exec" } else { "" },
1821 +
                    if mode.opaque { ", opaque" } else { "" }
1822 +
                ));
1279 1823
            }
1280 1824
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
1281 -
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
1825 +
            TreeOp::Move { node, name, .. } => {
1826 +
                out.push_str(&format!("R {} -> {}\n", path_of(node), name))
1827 +
            }
1282 1828
            TreeOp::SetMode { node, mode, .. } => {
1283 -
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
1829 +
                out.push_str(&format!(
1830 +
                    "M {} mode: exec={} opaque={}\n",
1831 +
                    path_of(node),
1832 +
                    mode.exec,
1833 +
                    mode.opaque
1834 +
                ));
1284 1835
            }
1285 1836
        }
1286 1837
    }
…
17 unchanged lines
1304 1855
            Edit::Delete { atoms, .. } => {
1305 1856
                for r in atoms {
1306 1857
                    for i in r.from..r.to {
1307 -
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
1858 +
                        let content = base
1859 +
                            .file(&n)
1860 +
                            .and_then(|g| g.content(&(r.change, i)))
1861 +
                            .unwrap_or(b"<unknown>\n");
1308 1862
                        out.push('-');
1309 1863
                        out.push_str(&String::from_utf8_lossy(content));
1310 1864
                        if !content.ends_with(b"\n") {
…
20 unchanged lines
1331 1885
const RENAME_THRESHOLD: u32 = 50;
1332 1886
const RENAME_LIMIT: usize = 10_000;
1333 1887
1334 -
fn detect_renames(base_snapshot: &Snapshot, fs_entries: &BTreeMap<String, FsEntry>, in_scope: impl Fn(&str) -> bool) -> Vec<(String, String)> {
1888 +
fn detect_renames(
1889 +
    base_snapshot: &Snapshot,
1890 +
    fs_entries: &BTreeMap<String, FsEntry>,
1891 +
    in_scope: impl Fn(&str) -> bool,
1892 +
) -> Vec<(String, String)> {
1335 1893
    let removed: Vec<(&String, &crate::state::FileState)> = base_snapshot
1336 1894
        .files
1337 1895
        .iter()
1338 -
        .filter(|(p, f)| f.kind != NodeKind::Dir && !fs_entries.contains_key(*p) && in_scope(p) && !f.is_conflicted())
1896 +
        .filter(|(p, f)| {
1897 +
            f.kind != NodeKind::Dir
1898 +
                && !fs_entries.contains_key(*p)
1899 +
                && in_scope(p)
1900 +
                && !f.is_conflicted()
1901 +
        })
1902 +
        .collect();
1903 +
    let added: Vec<(&String, &FsEntry)> = fs_entries
1904 +
        .iter()
1905 +
        .filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p))
1339 1906
        .collect();
1340 -
    let added: Vec<(&String, &FsEntry)> = fs_entries.iter().filter(|(p, e)| e.kind != NodeKind::Dir && !base_snapshot.files.contains_key(*p)).collect();
1341 1907
    if removed.is_empty() || added.is_empty() {
1342 1908
        return vec![];
1343 1909
    }
…
18 unchanged lines
1362 1928
            let score = match (&old, old_blob) {
1363 1929
                (Some(o), _) => {
1364 1930
                    if exact_only {
1365 -
                        if *o == e.content { 100 } else { 0 }
1931 +
                        if *o == e.content {
1932 +
                            100
1933 +
                        } else {
1934 +
                            0
1935 +
                        }
1366 1936
                    } else {
1367 1937
                        similarity(o, &e.content)
1368 1938
                    }
…
6 unchanged lines
1375 1945
            }
1376 1946
        }
1377 1947
    }
1378 -
    scored.sort_by(|a, b| b.0.cmp(&a.0).then(removed[a.1].0.cmp(removed[b.1].0)).then(added[a.2].0.cmp(added[b.2].0)));
1948 +
    scored.sort_by(|a, b| {
1949 +
        b.0.cmp(&a.0)
1950 +
            .then(removed[a.1].0.cmp(removed[b.1].0))
1951 +
            .then(added[a.2].0.cmp(added[b.2].0))
1952 +
    });
1379 1953
    let mut used_r = vec![false; removed.len()];
1380 1954
    let mut used_a = vec![false; added.len()];
1381 1955
    let mut out = Vec::new();
…
32 unchanged lines
1414 1988
    }
1415 1989
    (shared * 100 / a.len().max(b.len())) as u32
1416 1990
}
1417 -
modified crates/oxy-core/src/repo/subrepos.rs +98 −19
…
27 unchanged lines
28 28
}
29 29
30 30
fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
31 -
    let out = Command::new("git").arg("-C").arg(dir).args(args).output().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
31 +
    let out = Command::new("git")
32 +
        .arg("-C")
33 +
        .arg(dir)
34 +
        .args(args)
35 +
        .output()
36 +
        .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
32 37
    if !out.status.success() {
33 -
        return fail(format!("git {} in {}: {}", args.join(" "), dir.display(), String::from_utf8_lossy(&out.stderr).trim()));
38 +
        return fail(format!(
39 +
            "git {} in {}: {}",
40 +
            args.join(" "),
41 +
            dir.display(),
42 +
            String::from_utf8_lossy(&out.stderr).trim()
43 +
        ));
34 44
    }
35 45
    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
36 46
}
…
3 unchanged lines
40 50
pub(crate) fn read_pin(dir: &Path, bookmark_hint: Option<&str>) -> Result<Pin> {
41 51
    if dir.join(OXY_DIR).exists() {
42 52
        let nested = Repo::open(dir)?;
43 -
        return Ok(Pin::Oxy { repo: nested.id()?, heads: nested.bookmark_heads()?, bookmark: nested.config.bookmark.clone() });
53 +
        return Ok(Pin::Oxy {
54 +
            repo: nested.id()?,
55 +
            heads: nested.bookmark_heads()?,
56 +
            bookmark: nested.config.bookmark.clone(),
57 +
        });
44 58
    }
45 59
    if dir.join(".git").exists() {
46 60
        let sha = git_out(dir, &["rev-parse", "HEAD"])?;
47 -
        return Ok(Pin::Git { sha, branch: bookmark_hint.unwrap_or("HEAD").to_string() });
61 +
        return Ok(Pin::Git {
62 +
            sha,
63 +
            branch: bookmark_hint.unwrap_or("HEAD").to_string(),
64 +
        });
48 65
    }
49 66
    fail(format!("{} is not a repository", dir.display()))
50 67
}
…
11 unchanged lines
62 79
/// Whether `url` names an Oxygen repository (else it is taken to be Git).
63 80
fn is_oxy_url(url: &str) -> bool {
64 81
    if url.starts_with("http://") || url.starts_with("https://") {
65 -
        return crate::protocol::open_remote(url, &RemoteOptions::default()).and_then(|r| r.repo_id()).is_ok();
82 +
        return crate::protocol::open_remote(url, &RemoteOptions::default())
83 +
            .and_then(|r| r.repo_id())
84 +
            .is_ok();
66 85
    }
67 86
    let p = Path::new(url.strip_prefix("file://").unwrap_or(url));
68 87
    p.join(OXY_DIR).exists()
…
1 unchanged lines
70 89
71 90
impl Repo {
72 91
    pub(crate) fn modules_on_disk(&self) -> Modules {
73 -
        fs::read_to_string(self.root.join(".oxymodules")).map(|t| Modules::parse(&t)).unwrap_or_default()
92 +
        fs::read_to_string(self.root.join(".oxymodules"))
93 +
            .map(|t| Modules::parse(&t))
94 +
            .unwrap_or_default()
74 95
    }
75 96
76 97
    fn write_modules(&self, m: &Modules) -> Result<()> {
…
15 unchanged lines
92 113
            if !init {
93 114
                return Ok(SubrepoSync::Missing);
94 115
            }
95 -
            let Some(m) = &module else { return Ok(SubrepoSync::Failed("no .oxymodules entry to clone from".into())) };
116 +
            let Some(m) = &module else {
117 +
                return Ok(SubrepoSync::Failed(
118 +
                    "no .oxymodules entry to clone from".into(),
119 +
                ));
120 +
            };
96 121
            if let Some(parent) = dir.parent() {
97 122
                fs::create_dir_all(parent)?;
98 123
            }
…
5 unchanged lines
104 129
                    .arg(&dir)
105 130
                    .status()
106 131
                    .map_err(|e| RepoError::Msg(e.to_string()))
107 -
                    .and_then(|s| if s.success() { Ok(()) } else { fail(format!("git clone {} failed", m.url)) }),
132 +
                    .and_then(|s| {
133 +
                        if s.success() {
134 +
                            Ok(())
135 +
                        } else {
136 +
                            fail(format!("git clone {} failed", m.url))
137 +
                        }
138 +
                    }),
108 139
            };
109 140
            if let Err(e) = r {
110 141
                return Ok(SubrepoSync::Failed(format!("cloning {}: {e}", m.url)));
…
11 unchanged lines
122 153
            _ => false,
123 154
        };
124 155
        if same {
125 -
            return Ok(if cloned { SubrepoSync::Updated } else { SubrepoSync::UpToDate });
156 +
            return Ok(if cloned {
157 +
                SubrepoSync::Updated
158 +
            } else {
159 +
                SubrepoSync::UpToDate
160 +
            });
126 161
        }
127 162
        if nested_dirty(&dir)? {
128 163
            return Ok(SubrepoSync::Dirty);
…
4 unchanged lines
133 168
                if nested.load_state(heads).is_err() {
134 169
                    // The pinned changes are not here yet: fetch from origin.
135 170
                    if let Err(e) = nested.fetch("origin") {
136 -
                        return Ok(SubrepoSync::Failed(format!("fetching the pinned changes: {e}")));
171 +
                        return Ok(SubrepoSync::Failed(format!(
172 +
                            "fetching the pinned changes: {e}"
173 +
                        )));
137 174
                    }
138 175
                }
139 176
                match nested.checkout(heads, false, "oxy submodule update") {
…
28 unchanged lines
168 205
            if f.kind != NodeKind::Subrepo {
169 206
                continue;
170 207
            }
171 -
            let FileContent::Opaque { blobs } = &f.content else { continue };
208 +
            let FileContent::Opaque { blobs } = &f.content else {
209 +
                continue;
210 +
            };
172 211
            let Some(b) = blobs.first() else { continue };
173 212
            let pin = Pin::decode(&self.read_blob(b)?)?;
174 213
            let dir = self.root.join(path);
175 214
            let present = dir.join(OXY_DIR).exists() || dir.join(".git").exists();
176 -
            let actual = if present { read_pin(&dir, modules.entries.get(path).and_then(|m| m.bookmark.as_deref())).ok() } else { None };
215 +
            let actual = if present {
216 +
                read_pin(
217 +
                    &dir,
218 +
                    modules
219 +
                        .entries
220 +
                        .get(path)
221 +
                        .and_then(|m| m.bookmark.as_deref()),
222 +
                )
223 +
                .ok()
224 +
            } else {
225 +
                None
226 +
            };
177 227
            let dirty = present && nested_dirty(&dir).unwrap_or(false);
178 -
            out.push(SubrepoInfo { path: path.clone(), pin, url: modules.entries.get(path).map(|m| m.url.clone()), actual, dirty });
228 +
            out.push(SubrepoInfo {
229 +
                path: path.clone(),
230 +
                pin,
231 +
                url: modules.entries.get(path).map(|m| m.url.clone()),
232 +
                actual,
233 +
                dirty,
234 +
            });
179 235
        }
180 236
        Ok(out)
181 237
    }
…
2 unchanged lines
184 240
    /// pin the next snapshot will record.
185 241
    pub fn submodule_add(&self, url: &str, path: &str, bookmark: Option<&str>) -> Result<Pin> {
186 242
        let path = path.trim_end_matches('/').to_string();
187 -
        if path.is_empty() || path.starts_with('/') || path.split('/').any(|c| c == ".." || c == ".") {
243 +
        if path.is_empty()
244 +
            || path.starts_with('/')
245 +
            || path.split('/').any(|c| c == ".." || c == ".")
246 +
        {
188 247
            return fail(format!("{path:?} is not a valid subrepo path"));
189 248
        }
190 249
        let dir = self.root.join(&path);
…
10 unchanged lines
201 260
            if let Some(b) = bookmark {
202 261
                cmd.args(["--branch", b]);
203 262
            }
204 -
            let status = cmd.arg(url).arg(&dir).status().map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
263 +
            let status = cmd
264 +
                .arg(url)
265 +
                .arg(&dir)
266 +
                .status()
267 +
                .map_err(|e| RepoError::Msg(format!("running git: {e}")))?;
205 268
            if !status.success() {
206 269
                return fail(format!("git clone {url} failed"));
207 270
            }
…
5 unchanged lines
213 276
            }
214 277
        }
215 278
        let mut modules = self.modules_on_disk();
216 -
        modules.entries.insert(path.clone(), Module { url: url.to_string(), bookmark: bookmark.map(str::to_string), git });
279 +
        modules.entries.insert(
280 +
            path.clone(),
281 +
            Module {
282 +
                url: url.to_string(),
283 +
                bookmark: bookmark.map(str::to_string),
284 +
                git,
285 +
            },
286 +
        );
217 287
        self.write_modules(&modules)?;
218 288
        read_pin(&dir, bookmark)
219 289
    }
220 290
221 291
    /// Bring nested repositories to their pins (`paths` empty = all).
222 -
    pub fn submodule_update(&self, init: bool, paths: &[String]) -> Result<Vec<(String, SubrepoSync)>> {
292 +
    pub fn submodule_update(
293 +
        &self,
294 +
        init: bool,
295 +
        paths: &[String],
296 +
    ) -> Result<Vec<(String, SubrepoSync)>> {
223 297
        let mut out = Vec::new();
224 298
        for s in self.subrepos()? {
225 299
            if !paths.is_empty() && !paths.iter().any(|p| p.trim_end_matches('/') == s.path) {
…
13 unchanged lines
239 313
    pub fn submodule_remove(&self, path: &str) -> Result<()> {
240 314
        let path = path.trim_end_matches('/').to_string();
241 315
        let mut modules = self.modules_on_disk();
242 -
        let known = modules.entries.remove(&path).is_some() || self.subrepos()?.iter().any(|s| s.path == path);
316 +
        let known = modules.entries.remove(&path).is_some()
317 +
            || self.subrepos()?.iter().any(|s| s.path == path);
243 318
        if !known {
244 319
            return fail(format!("{path} is not a subrepo"));
245 320
        }
…
20 unchanged lines
266 341
    }
267 342
268 343
    pub(crate) fn clear_removed_subrepo(&self, path: &str) {
269 -
        let _ = fs::remove_file(self.oxy.join("subrepos-removed").join(path.replace('/', "%2F")));
344 +
        let _ = fs::remove_file(
345 +
            self.oxy
346 +
                .join("subrepos-removed")
347 +
                .join(path.replace('/', "%2F")),
348 +
        );
270 349
    }
271 350
}
modified crates/oxy-core/src/repo/sync.rs +500 −113
…
31 unchanged lines
32 32
    pub fn latest_ref_attest(&self, bookmark: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
33 33
        match fs::read_to_string(self.ref_attest_path(bookmark)) {
34 34
            Ok(h) => {
35 -
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?;
36 -
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
35 +
                let r = ObjectRef::from_hex(h.trim())
36 +
                    .ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?;
37 +
                Ok(Some((
38 +
                    r,
39 +
                    SignedAttest {
40 +
                        attest: self.store.get_attest(&r)?,
41 +
                        sigs: self.store.signatures(&r)?,
42 +
                    },
43 +
                )))
37 44
            }
38 45
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
39 46
            Err(e) => Err(e.into()),
…
15 unchanged lines
55 62
                _ => None,
56 63
            });
57 64
            let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state));
58 -
            out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial });
65 +
            out.push(RemoteRef {
66 +
                name: name.clone(),
67 +
                state: *state,
68 +
                heads: self.store.get_state(state)?.heads,
69 +
                attestation,
70 +
                last_serial,
71 +
            });
59 72
        }
60 73
        let policy = self.current_policy()?.map(|(_, sp)| sp);
61 -
        let history = self.policy_history()?.into_iter().filter(|sp| policy.as_ref().is_some_and(|p| sp.policy.serial < p.policy.serial)).collect();
62 -
        Ok(RefsResponse { refs: out, policy, tags: self.tag_refs()?, history })
74 +
        let history = self
75 +
            .policy_history()?
76 +
            .into_iter()
77 +
            .filter(|sp| {
78 +
                policy
79 +
                    .as_ref()
80 +
                    .is_some_and(|p| sp.policy.serial < p.policy.serial)
81 +
            })
82 +
            .collect();
83 +
        Ok(RefsResponse {
84 +
            refs: out,
85 +
            policy,
86 +
            tags: self.tag_refs()?,
87 +
            history,
88 +
        })
63 89
    }
64 90
65 91
    /// Every change reachable from `heads` that is in the store.
…
41 unchanged lines
107 133
108 134
    /// The shallow boundary: dependencies this repository does not hold (spec §17).
109 135
    pub fn boundary(&self) -> BTreeSet<ChangeHash> {
110 -
        fs::read_to_string(self.shallow_path()).map(|t| t.lines().filter_map(ObjectRef::from_hex).collect()).unwrap_or_default()
136 +
        fs::read_to_string(self.shallow_path())
137 +
            .map(|t| t.lines().filter_map(ObjectRef::from_hex).collect())
138 +
            .unwrap_or_default()
111 139
    }
112 140
113 141
    pub fn is_shallow(&self) -> bool {
…
5 unchanged lines
119 147
            let _ = fs::remove_file(self.shallow_path());
120 148
            return Ok(());
121 149
        }
122 -
        let text: String = boundary.iter().map(|h| format!("{}\n", h.to_hex())).collect();
150 +
        let text: String = boundary
151 +
            .iter()
152 +
            .map(|h| format!("{}\n", h.to_hex()))
153 +
            .collect();
123 154
        write_atomic(&self.shallow_path(), text.as_bytes())
124 155
    }
125 156
…
91 unchanged lines
217 248
218 249
    /// Like `bundle_for`, leaving out blobs larger than `max_blob` bytes
219 250
    /// (and their chunks) so the receiver fetches them lazily (spec §13.5).
220 -
    pub fn bundle_for_filtered(&self, changes: &[ChangeHash], max_blob: Option<u64>) -> Result<Bundle> {
251 +
    pub fn bundle_for_filtered(
252 +
        &self,
253 +
        changes: &[ChangeHash],
254 +
        max_blob: Option<u64>,
255 +
    ) -> Result<Bundle> {
221 256
        let mut bundle = Bundle::default();
222 257
        let mut objects: BTreeSet<ObjectRef> = BTreeSet::new();
223 258
        let mut seals: BTreeSet<ObjectRef> = BTreeSet::new();
…
13 unchanged lines
237 272
                if let Edit::Replace { old, new, .. } = e {
238 273
                    for b in [old, new].into_iter().flatten() {
239 274
                        if self.store.has(b) {
240 -
                            if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) {
275 +
                            if let Ok(blob) =
276 +
                                Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?)
277 +
                            {
241 278
                                if max_blob.is_some_and(|m| blob.length > m) {
242 279
                                    continue;
243 280
                                }
…
36 unchanged lines
280 317
        self.import_bundle_inner(bundle, strict, true)
281 318
    }
282 319
283 -
    fn import_bundle_inner(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> {
320 +
    fn import_bundle_inner(
321 +
        &self,
322 +
        bundle: &Bundle,
323 +
        strict: bool,
324 +
        shallow: bool,
325 +
    ) -> Result<Vec<ChangeHash>> {
284 326
        let batch = self.store.begin_batch()?;
285 327
        let result = self.import_bundle_batched(bundle, strict, shallow);
286 328
        batch.finish()?;
287 329
        result
288 330
    }
289 331
290 -
    fn import_bundle_batched(&self, bundle: &Bundle, strict: bool, shallow: bool) -> Result<Vec<ChangeHash>> {
332 +
    fn import_bundle_batched(
333 +
        &self,
334 +
        bundle: &Bundle,
335 +
        strict: bool,
336 +
        shallow: bool,
337 +
    ) -> Result<Vec<ChangeHash>> {
291 338
        for (ty, body) in &bundle.objects {
292 339
            match ty {
293 -
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State | ObjectType::Checkpoint => {
340 +
                ObjectType::Chunk
341 +
                | ObjectType::Blob
342 +
                | ObjectType::Identity
343 +
                | ObjectType::State
344 +
                | ObjectType::Checkpoint => {
294 345
                    self.store.put(*ty, body)?;
295 346
                }
296 347
                other => return fail(format!("bundle may not carry {} objects", other.name())),
…
16 unchanged lines
313 364
        for (c, sigs) in &bundle.changes {
314 365
            let h = c.hash();
315 366
            for d in &c.deps {
316 -
                if !self.store.has(d) && !incoming.contains(d) && !shallow && !boundary.contains(d) {
317 -
                    return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12)));
367 +
                if !self.store.has(d) && !incoming.contains(d) && !shallow && !boundary.contains(d)
368 +
                {
369 +
                    return fail(format!(
370 +
                        "change {} depends on {}, which is neither stored nor in the bundle",
371 +
                        h.short(12),
372 +
                        d.short(12)
373 +
                    ));
318 374
                }
319 375
            }
320 376
            self.store.put_change(c)?;
…
5 unchanged lines
326 382
        if strict {
327 383
            for h in &imported {
328 384
                if !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
329 -
                    return fail(format!("change {} is not authenticated by its author", h.short(12)));
385 +
                    return fail(format!(
386 +
                        "change {} is not authenticated by its author",
387 +
                        h.short(12)
388 +
                    ));
330 389
                }
331 390
            }
332 391
        }
…
3 unchanged lines
336 395
    /// Verify a reference attestation against an update it claims to describe.
337 396
    /// `allow_equal` lets a client re-read the attestation it already has; a
338 397
    /// server receiving a push requires a strictly greater serial.
339 -
    fn check_ref_attest(&self, sa: &SignedAttest, name: &str, old_state: &ObjectRef, new_state: &ObjectRef, last_serial: Option<u64>, allow_equal: bool) -> std::result::Result<u64, String> {
340 -
        let AttestBody::Ref { name: n, old_state: o, new_state: nw, serial } = &sa.attest.body else {
398 +
    fn check_ref_attest(
399 +
        &self,
400 +
        sa: &SignedAttest,
401 +
        name: &str,
402 +
        old_state: &ObjectRef,
403 +
        new_state: &ObjectRef,
404 +
        last_serial: Option<u64>,
405 +
        allow_equal: bool,
406 +
    ) -> std::result::Result<u64, String> {
407 +
        let AttestBody::Ref {
408 +
            name: n,
409 +
            old_state: o,
410 +
            new_state: nw,
411 +
            serial,
412 +
        } = &sa.attest.body
413 +
        else {
341 414
            return Err("attestation is not a ref attestation".into());
342 415
        };
343 416
        if n != name || o != old_state || nw != new_state {
…
4 unchanged lines
348 421
        }
349 422
        if let Some(last) = last_serial {
350 423
            if *serial < last || (*serial == last && !allow_equal) {
351 -
                return Err(format!("attestation serial {serial} is not greater than the last seen {last}"));
424 +
                return Err(format!(
425 +
                    "attestation serial {serial} is not greater than the last seen {last}"
426 +
                ));
352 427
            }
353 428
        }
354 429
        let r = sa.attest.hash();
355 430
        let sig = sa.sigs.first().ok_or("attestation carries no signature")?;
356 -
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
431 +
        let id = self
432 +
            .store
433 +
            .get_identity(&sig.key)
434 +
            .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
357 435
        verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
358 436
        Ok(*serial)
359 437
    }
…
3 unchanged lines
363 441
    /// attestations that are present must still be valid. With a policy,
364 442
    /// every change and update is checked against it. A newer policy in the
365 443
    /// push is applied first when it is signed by the current roots.
366 -
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<crate::protocol::TagRef>) -> Result<Vec<RefResult>> {
444 +
    pub fn receive_push(
445 +
        &self,
446 +
        bundle: Bundle,
447 +
        updates: Vec<RefUpdate>,
448 +
        policy: Option<SignedPolicy>,
449 +
        tags: Vec<crate::protocol::TagRef>,
450 +
    ) -> Result<Vec<RefResult>> {
367 451
        let mut results = Vec::new();
368 452
        if let Some(sp) = policy {
369 453
            match self.adopt_policy_chain(&bundle.policies, &sp, "push received") {
370 454
                Ok(()) => {
371 -
                    results.push(RefResult { name: "policy".into(), ok: true, reason: format!("serial {}", sp.policy.serial) });
455 +
                    results.push(RefResult {
456 +
                        name: "policy".into(),
457 +
                        ok: true,
458 +
                        reason: format!("serial {}", sp.policy.serial),
459 +
                    });
372 460
                }
373 461
                Err(e) => {
374 -
                    results.push(RefResult { name: "policy".into(), ok: false, reason: e });
462 +
                    results.push(RefResult {
463 +
                        name: "policy".into(),
464 +
                        ok: false,
465 +
                        reason: e,
466 +
                    });
375 467
                    for u in updates {
376 -
                        results.push(RefResult { name: u.name, ok: false, reason: "policy update was rejected".into() });
468 +
                        results.push(RefResult {
469 +
                            name: u.name,
470 +
                            ok: false,
471 +
                            reason: "policy update was rejected".into(),
472 +
                        });
377 473
                    }
378 474
                    return Ok(results);
379 475
                }
…
2 unchanged lines
382 478
        let policy = self.current_policy()?.map(|(_, sp)| sp.policy);
383 479
        if let Err(e) = self.import_bundle(&bundle, policy.is_some()) {
384 480
            for u in updates {
385 -
                results.push(RefResult { name: u.name, ok: false, reason: e.to_string() });
481 +
                results.push(RefResult {
482 +
                    name: u.name,
483 +
                    ok: false,
484 +
                    reason: e.to_string(),
485 +
                });
386 486
            }
387 487
            return Ok(results);
388 488
        }
…
9 unchanged lines
398 498
            if ok {
399 499
                names.push(u.name.clone());
400 500
            }
401 -
            results.push(RefResult { name: u.name, ok, reason });
501 +
            results.push(RefResult {
502 +
                name: u.name,
503 +
                ok,
504 +
                reason,
505 +
            });
402 506
        }
403 507
        for t in &tags {
404 508
            let name = format!("tags/{}", t.name);
405 509
            match self.adopt_tag(&mut view, t, policy.as_ref()) {
406 510
                Ok(()) => {
407 511
                    names.push(name.clone());
408 -
                    results.push(RefResult { name, ok: true, reason: String::new() });
512 +
                    results.push(RefResult {
513 +
                        name,
514 +
                        ok: true,
515 +
                        reason: String::new(),
516 +
                    });
409 517
                }
410 -
                Err(e) => results.push(RefResult { name, ok: false, reason: e }),
518 +
                Err(e) => results.push(RefResult {
519 +
                    name,
520 +
                    ok: false,
521 +
                    reason: e,
522 +
                }),
411 523
            }
412 524
        }
413 525
        if !names.is_empty() {
414 -
            self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?;
526 +
            self.record_op(
527 +
                &[op_ref],
528 +
                &format!("push received: {}", names.join(" ")),
529 +
                view,
530 +
            )?;
415 531
        }
416 532
        Ok(results)
417 533
    }
418 534
419 -
    fn apply_update(&self, view: &mut View, u: &RefUpdate, policy: Option<&crate::policy::Policy>) -> std::result::Result<(), String> {
535 +
    fn apply_update(
536 +
        &self,
537 +
        view: &mut View,
538 +
        u: &RefUpdate,
539 +
        policy: Option<&crate::policy::Policy>,
540 +
    ) -> std::result::Result<(), String> {
420 541
        if u.name == "policy" {
421 -
            return Err("the policy reference is updated by sending a policy, not a bookmark update".into());
542 +
            return Err(
543 +
                "the policy reference is updated by sending a policy, not a bookmark update".into(),
544 +
            );
422 545
        }
423 -
        let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?;
546 +
        let empty = self
547 +
            .store
548 +
            .put_state(&StateObject::new(vec![]))
549 +
            .map_err(|e| e.to_string())?;
424 550
        let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty);
425 551
        if current != u.old_state {
426 552
            return Err("stale: the remote bookmark has moved; pull first".into());
427 553
        }
428 -
        let new_state = self.store.get_state(&u.new_state).map_err(|_| "new state object is missing".to_string())?;
554 +
        let new_state = self
555 +
            .store
556 +
            .get_state(&u.new_state)
557 +
            .map_err(|_| "new state object is missing".to_string())?;
429 558
        for h in &new_state.heads {
430 559
            if !self.store.has(h) {
431 560
                return Err(format!("head {} is missing", h.short(12)));
432 561
            }
433 562
        }
434 563
        if !u.force {
435 -
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
436 -
            let closure = self.closure_in_store(&new_state.heads).map_err(|e| e.to_string())?;
564 +
            let old_heads = self
565 +
                .store
566 +
                .get_state(&current)
567 +
                .map_err(|e| e.to_string())?
568 +
                .heads;
569 +
            let closure = self
570 +
                .closure_in_store(&new_state.heads)
571 +
                .map_err(|e| e.to_string())?;
437 572
            if let Some(h) = old_heads.iter().find(|h| !closure.contains(h)) {
438 -
                return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12)));
573 +
                return Err(format!(
574 +
                    "not a fast-forward: current head {} would be dropped (use --force)",
575 +
                    h.short(12)
576 +
                ));
439 577
            }
440 578
        }
441 579
        if let Some(p) = policy {
442 580
            let Some(sa) = &u.attestation else {
443 -
                return Err(format!("{:?} requires a signed reference attestation under this repository's policy", u.name));
581 +
                return Err(format!(
582 +
                    "{:?} requires a signed reference attestation under this repository's policy",
583 +
                    u.name
584 +
                ));
444 585
            };
445 -
            let signer = sa.sigs.first().map(|s| s.key).ok_or("attestation carries no signature")?;
586 +
            let signer = sa
587 +
                .sigs
588 +
                .first()
589 +
                .map(|s| s.key)
590 +
                .ok_or("attestation carries no signature")?;
446 591
            if !p.authorizes(&signer, &u.name) {
447 -
                return Err(format!("key {} may not update {:?} under this repository's policy", signer.short(12), u.name));
592 +
                return Err(format!(
593 +
                    "key {} may not update {:?} under this repository's policy",
594 +
                    signer.short(12),
595 +
                    u.name
596 +
                ));
448 597
            }
449 -
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
598 +
            let old_heads = self
599 +
                .store
600 +
                .get_state(&current)
601 +
                .map_err(|e| e.to_string())?
602 +
                .heads;
450 603
            self.state_allowed(&old_heads, &new_state.heads, p, &u.name)?;
451 604
        }
452 605
        if let Some(sa) = &u.attestation {
453 -
            let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body {
454 -
                AttestBody::Ref { serial, .. } => Some(serial),
455 -
                _ => None,
456 -
            });
606 +
            let last = self
607 +
                .latest_ref_attest(&u.name)
608 +
                .map_err(|e| e.to_string())?
609 +
                .and_then(|(_, a)| match a.attest.body {
610 +
                    AttestBody::Ref { serial, .. } => Some(serial),
611 +
                    _ => None,
612 +
                });
457 613
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last, false)?;
458 -
            let r = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
614 +
            let r = self
615 +
                .store
616 +
                .put_attest(&sa.attest)
617 +
                .map_err(|e| e.to_string())?;
459 618
            for sig in &sa.sigs {
460 -
                self.store.put_signature(&r, sig).map_err(|e| e.to_string())?;
619 +
                self.store
620 +
                    .put_signature(&r, sig)
621 +
                    .map_err(|e| e.to_string())?;
461 622
            }
462 -
            self.set_ref_attest(&u.name, &r).map_err(|e| e.to_string())?;
623 +
            self.set_ref_attest(&u.name, &r)
624 +
                .map_err(|e| e.to_string())?;
463 625
        }
464 626
        view.bookmarks.insert(u.name.clone(), u.new_state);
465 627
        Ok(())
…
2 unchanged lines
468 630
    // ------------------------------------------------------------ client side
469 631
470 632
    fn remote_url(&self, name: &str) -> Result<String> {
471 -
        self.config.remotes.get(name).cloned().ok_or_else(|| RepoError::Msg(format!("no remote named {name:?}; add one with `oxy remote add`")))
633 +
        self.config.remotes.get(name).cloned().ok_or_else(|| {
634 +
            RepoError::Msg(format!(
635 +
                "no remote named {name:?}; add one with `oxy remote add`"
636 +
            ))
637 +
        })
472 638
    }
473 639
474 640
    /// Credentials and trust for a remote: `remote.<name>.token` (else
475 641
    /// `$OXY_TOKEN`) and `remote.<name>.ca` (else `$OXY_CA`).
642 +
    /// Refuse a remote that is another repository. An **unbound** remote
643 +
    /// (id all zero: a hosted repository nobody has pushed to) is accepted
644 +
    /// for pushes, which bind it to this repository's id (spec §16.1).
645 +
    fn check_remote_id(&self, remote: &dyn crate::protocol::Remote, remote_name: &str, allow_unbound: bool) -> Result<()> {
646 +
        let theirs = remote.repo_id()?;
647 +
        if theirs == self.id()? {
648 +
            return Ok(());
649 +
        }
650 +
        if theirs == crate::protocol::UNBOUND_ID {
651 +
            if allow_unbound {
652 +
                return Ok(());
653 +
            }
654 +
            return fail(format!("remote {remote_name:?} is empty and unbound: push to it first"));
655 +
        }
656 +
        fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"))
657 +
    }
658 +
476 659
    fn remote_options(&self, name: &str) -> RemoteOptions {
477 660
        RemoteOptions {
478 -
            token: self.config.remote_tokens.get(name).cloned().or_else(|| std::env::var("OXY_TOKEN").ok()),
479 -
            ca: self.config.remote_cas.get(name).map(PathBuf::from).or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from)),
661 +
            repo_id: self.id().ok(),
662 +
            token: self
663 +
                .config
664 +
                .remote_tokens
665 +
                .get(name)
666 +
                .cloned()
667 +
                .or_else(|| std::env::var("OXY_TOKEN").ok()),
668 +
            ca: self
669 +
                .config
670 +
                .remote_cas
671 +
                .get(name)
672 +
                .map(PathBuf::from)
673 +
                .or_else(|| std::env::var("OXY_CA").ok().map(PathBuf::from)),
480 674
        }
481 675
    }
482 676
…
2 unchanged lines
485 679
    }
486 680
487 681
    fn serial_path(&self, remote: &str, bookmark: &str) -> PathBuf {
488 -
        self.oxy.join("remotes").join(remote).join(format!("{bookmark}.serial"))
682 +
        self.oxy
683 +
            .join("remotes")
684 +
            .join(remote)
685 +
            .join(format!("{bookmark}.serial"))
489 686
    }
490 687
491 688
    fn last_seen_serial(&self, remote: &str, bookmark: &str) -> Option<u64> {
492 -
        fs::read_to_string(self.serial_path(remote, bookmark)).ok().and_then(|s| s.trim().parse().ok())
689 +
        fs::read_to_string(self.serial_path(remote, bookmark))
690 +
            .ok()
691 +
            .and_then(|s| s.trim().parse().ok())
493 692
    }
494 693
495 694
    fn record_serial(&self, remote: &str, bookmark: &str, serial: u64) -> Result<()> {
…
8 unchanged lines
504 703
505 704
    /// The highest policy serial `remote` has ever presented.
506 705
    fn remote_policy_serial(&self, remote: &str) -> Option<u64> {
507 -
        fs::read_to_string(self.policy_serial_path(remote)).ok().and_then(|s| s.trim().parse().ok())
706 +
        fs::read_to_string(self.policy_serial_path(remote))
707 +
            .ok()
708 +
            .and_then(|s| s.trim().parse().ok())
508 709
    }
509 710
510 711
    fn record_policy_serial(&self, remote: &str, serial: u64) -> Result<()> {
…
14 unchanged lines
525 726
526 727
    /// Which remote promised an absent blob, if any.
527 728
    pub fn lazy_remote(&self, blob: &ObjectRef) -> Option<String> {
528 -
        fs::read_to_string(self.lazy_dir().join(blob.to_hex())).ok().map(|s| s.trim().to_string())
729 +
        fs::read_to_string(self.lazy_dir().join(blob.to_hex()))
730 +
            .ok()
731 +
            .map(|s| s.trim().to_string())
529 732
    }
530 733
531 734
    /// Fetch an absent blob (and its chunks) from the remote that promised it.
…
9 unchanged lines
541 744
            }
542 745
        }
543 746
        if !self.store.has(blob) {
544 -
            return fail(format!("{remote_name} did not send blob {}", blob.short(12)));
747 +
            return fail(format!(
748 +
                "{remote_name} did not send blob {}",
749 +
                blob.short(12)
750 +
            ));
545 751
        }
546 752
        let _ = fs::remove_file(self.lazy_dir().join(blob.to_hex()));
547 753
        Ok(())
…
22 unchanged lines
570 776
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
571 777
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
572 778
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
573 -
        for st in view.bookmarks.values().chain(view.remotes.values().flat_map(|b| b.values())) {
779 +
        for st in view
780 +
            .bookmarks
781 +
            .values()
782 +
            .chain(view.remotes.values().flat_map(|b| b.values()))
783 +
        {
574 784
            if let Ok(s) = self.store.get_state(st) {
575 785
                have.extend(s.heads);
576 786
            }
…
5 unchanged lines
582 792
    /// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2).
583 793
    pub fn fetch(&self, remote_name: &str) -> Result<FetchReport> {
584 794
        let remote = self.open_named_remote(remote_name)?;
585 -
        if remote.repo_id()? != self.id()? {
586 -
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
587 -
        }
795 +
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
588 796
        let resp = remote.refs()?;
589 797
        // Trust policy (spec §14.5). Adopt the remote's policy when it is newer
590 798
        // and signed by roots we trust, or on first contact. A remote that
…
12 unchanged lines
603 811
                    Some(l) if sp.policy.serial < l.serial => {}
604 812
                    Some(l) if sp.policy.serial == l.serial => {
605 813
                        if sp.policy.hash() != l.hash() {
606 -
                            return fail(format!("{remote_name} presents a different policy with the same serial {}", l.serial));
814 +
                            return fail(format!(
815 +
                                "{remote_name} presents a different policy with the same serial {}",
816 +
                                l.serial
817 +
                            ));
607 818
                        }
608 819
                    }
609 820
                    _ => {
610 -
                        self.adopt_policy_chain(&resp.history, sp, &format!("oxy fetch {remote_name}")).map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
821 +
                        self.adopt_policy_chain(
822 +
                            &resp.history,
823 +
                            sp,
824 +
                            &format!("oxy fetch {remote_name}"),
825 +
                        )
826 +
                        .map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
611 827
                    }
612 828
                }
613 829
                self.record_policy_serial(remote_name, sp.policy.serial)?;
…
5 unchanged lines
619 835
                unverified = true;
620 836
            }
621 837
        }
622 -
        let policy = if unverified { None } else { self.current_policy()?.map(|(_, sp)| sp.policy) };
838 +
        let policy = if unverified {
839 +
            None
840 +
        } else {
841 +
            self.current_policy()?.map(|(_, sp)| sp.policy)
842 +
        };
623 843
        let (op_ref, op) = self.head_op()?;
624 844
        let mut view = op.view.clone();
625 845
        let have = self.known_heads(&view)?;
…
16 unchanged lines
642 862
            }
643 863
            let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
644 864
            if state != r.state {
645 -
                return fail(format!("remote state {} does not match its heads", r.state.short(12)));
865 +
                return fail(format!(
866 +
                    "remote state {} does not match its heads",
867 +
                    r.state.short(12)
868 +
                ));
646 869
            }
647 870
            if let Some(p) = &policy {
648 871
                let Some(sa) = &r.attestation else {
649 -
                    return fail(format!("{remote_name}/{} has no attestation, which its policy requires", r.name));
872 +
                    return fail(format!(
873 +
                        "{remote_name}/{} has no attestation, which its policy requires",
874 +
                        r.name
875 +
                    ));
650 876
                };
651 -
                let signer = sa.sigs.first().map(|s| s.key).ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
877 +
                let signer = sa
878 +
                    .sigs
879 +
                    .first()
880 +
                    .map(|s| s.key)
881 +
                    .ok_or_else(|| RepoError::Msg("attestation carries no signature".into()))?;
652 882
                if !p.authorizes(&signer, &r.name) {
653 -
                    return fail(format!("{remote_name}/{}: attested by {}, which the policy does not authorize", r.name, signer.short(12)));
883 +
                    return fail(format!(
884 +
                        "{remote_name}/{}: attested by {}, which the policy does not authorize",
885 +
                        r.name,
886 +
                        signer.short(12)
887 +
                    ));
654 888
                }
655 889
            }
656 890
            if let Some(sa) = &r.attestation {
657 -
                let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else {
891 +
                let AttestBody::Ref {
892 +
                    old_state,
893 +
                    new_state,
894 +
                    ..
895 +
                } = &sa.attest.body
896 +
                else {
658 897
                    return fail("remote sent a non-ref attestation for a bookmark");
659 898
                };
660 899
                let (old_state, new_state) = (*old_state, *new_state);
661 900
                let serial = self
662 -
                    .check_ref_attest(sa, &r.name, &old_state, &new_state, self.last_seen_serial(remote_name, &r.name), true)
663 -
                    .map_err(|e| RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name)))?;
901 +
                    .check_ref_attest(
902 +
                        sa,
903 +
                        &r.name,
904 +
                        &old_state,
905 +
                        &new_state,
906 +
                        self.last_seen_serial(remote_name, &r.name),
907 +
                        true,
908 +
                    )
909 +
                    .map_err(|e| {
910 +
                        RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name))
911 +
                    })?;
664 912
                if new_state != r.state {
665 -
                    return fail(format!("attestation for {}/{} does not cover its current state", remote_name, r.name));
913 +
                    return fail(format!(
914 +
                        "attestation for {}/{} does not cover its current state",
915 +
                        remote_name, r.name
916 +
                    ));
666 917
                }
667 918
                let ar = self.store.put_attest(&sa.attest)?;
668 919
                for sig in &sa.sigs {
…
1 unchanged lines
670 921
                }
671 922
                self.record_serial(remote_name, &r.name, serial)?;
672 923
            }
673 -
            view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
924 +
            view.remotes
925 +
                .entry(remote_name.to_string())
926 +
                .or_default()
927 +
                .insert(r.name.clone(), state);
674 928
            report.push((r.name, r.heads, imported.len()));
675 929
        }
676 930
        let mut new_tags = 0;
…
2 unchanged lines
679 933
                continue;
680 934
            }
681 935
            // The tag's changes must be here: fetch them if the bookmarks did not bring them.
682 -
            let missing: Vec<ChangeHash> = t.heads.iter().filter(|h| !self.store.has(h)).copied().collect();
936 +
            let missing: Vec<ChangeHash> = t
937 +
                .heads
938 +
                .iter()
939 +
                .filter(|h| !self.store.has(h))
940 +
                .copied()
941 +
                .collect();
683 942
            if !missing.is_empty() {
684 943
                let needed = remote.negotiate(&self.known_heads(&view)?, &missing)?;
685 944
                let bundle = remote.fetch(&needed, max_blob)?;
686 945
                self.import_bundle(&bundle, false)?;
687 946
            }
688 947
            self.store.put_state(&StateObject::new(t.heads.clone()))?;
689 -
            self.adopt_tag(&mut view, t, policy.as_ref()).map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?;
948 +
            self.adopt_tag(&mut view, t, policy.as_ref())
949 +
                .map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?;
690 950
            new_tags += 1;
691 951
        }
692 952
        if view != op.view {
…
4 unchanged lines
697 957
            &format!(
698 958
                "{{\"event\":\"post-fetch\",\"remote\":{},\"bookmarks\":{}}}",
699 959
                json_str(remote_name),
700 -
                json_array(report.iter().map(|(n, heads, _)| format!("{{\"name\":{},\"heads\":{}}}", json_str(n), json_array(heads.iter().map(|h| json_str(&h.to_hex()))))))
960 +
                json_array(report.iter().map(|(n, heads, _)| format!(
961 +
                    "{{\"name\":{},\"heads\":{}}}",
962 +
                    json_str(n),
963 +
                    json_array(heads.iter().map(|h| json_str(&h.to_hex())))
964 +
                )))
701 965
            ),
702 966
        );
703 -
        Ok(FetchReport { refs: report, unverified, new_tags })
967 +
        Ok(FetchReport {
968 +
            refs: report,
969 +
            unverified,
970 +
            new_tags,
971 +
        })
704 972
    }
705 973
706 974
    /// Shallow fetch of the remote's bookmark: its checkpoint as our base,
707 975
    /// the changes within `depth` steps of its heads, and the boundary below.
708 976
    pub fn fetch_shallow(&self, remote_name: &str, depth: u32) -> Result<usize> {
709 977
        let remote = self.open_named_remote(remote_name)?;
710 -
        if remote.repo_id()? != self.id()? {
711 -
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
712 -
        }
978 +
        self.check_remote_id(remote.as_ref(), remote_name, false)?;
713 979
        let resp = remote.refs()?;
714 980
        let bookmark = self.config.bookmark.clone();
715 981
        if let Some(sp) = &resp.policy {
716 -
            self.adopt_policy_chain(&resp.history, sp, "oxy clone --depth").map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
982 +
            self.adopt_policy_chain(&resp.history, sp, "oxy clone --depth")
983 +
                .map_err(|e| RepoError::Msg(format!("policy from {remote_name}: {e}")))?;
717 984
            self.record_policy_serial(remote_name, sp.policy.serial)?;
718 985
        }
719 986
        let Some(r) = resp.refs.into_iter().find(|r| r.name == bookmark) else {
…
11 unchanged lines
731 998
                cp_ref = Some(put);
732 999
            }
733 1000
        }
734 -
        let Some(cp_ref) = cp_ref else { return fail(format!("{remote_name} sent no checkpoint")) };
1001 +
        let Some(cp_ref) = cp_ref else {
1002 +
            return fail(format!("{remote_name} sent no checkpoint"));
1003 +
        };
735 1004
        self.install_checkpoint(&bookmark, &cp_ref)?;
736 1005
        // History window.
737 1006
        let wanted = remote.shallow(&r.heads, depth)?;
…
15 unchanged lines
753 1022
        let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
754 1023
        let (op_ref, op) = self.head_op()?;
755 1024
        let mut view = op.view.clone();
756 -
        view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
757 -
        self.record_op(&[op_ref], &format!("oxy fetch {remote_name} --depth {depth}"), view)?;
1025 +
        view.remotes
1026 +
            .entry(remote_name.to_string())
1027 +
            .or_default()
1028 +
            .insert(r.name.clone(), state);
1029 +
        self.record_op(
1030 +
            &[op_ref],
1031 +
            &format!("oxy fetch {remote_name} --depth {depth}"),
1032 +
            view,
1033 +
        )?;
758 1034
        Ok(imported.len())
759 1035
    }
760 1036
…
4 unchanged lines
765 1041
        let (op_ref, op) = self.head_op()?;
766 1042
        let bookmark = self.config.bookmark.clone();
767 1043
        let local = self.heads_of(&op.view, &bookmark)?;
768 -
        let Some(remote_state) = op.view.remotes.get(remote_name).and_then(|b| b.get(&bookmark)).copied() else {
769 -
            return Ok(PullReport { heads: local, changed: false, checked_out: false });
1044 +
        let Some(remote_state) = op
1045 +
            .view
1046 +
            .remotes
1047 +
            .get(remote_name)
1048 +
            .and_then(|b| b.get(&bookmark))
1049 +
            .copied()
1050 +
        else {
1051 +
            return Ok(PullReport {
1052 +
                heads: local,
1053 +
                changed: false,
1054 +
                checked_out: false,
1055 +
            });
770 1056
        };
771 1057
        let remote_heads = self.store.get_state(&remote_state)?.heads;
772 1058
        let mut all = local.clone();
…
4 unchanged lines
777 1063
        let mut sorted_local = local.clone();
778 1064
        sorted_local.sort();
779 1065
        if heads == sorted_local {
780 -
            return Ok(PullReport { heads, changed: false, checked_out: false });
1066 +
            return Ok(PullReport {
1067 +
                heads,
1068 +
                changed: false,
1069 +
                checked_out: false,
1070 +
            });
781 1071
        }
782 1072
        let mut view = op.view.clone();
783 -
        view.bookmarks.insert(bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
1073 +
        view.bookmarks.insert(
1074 +
            bookmark.clone(),
1075 +
            self.store.put_state(&StateObject::new(heads.clone()))?,
1076 +
        );
784 1077
        self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?;
785 1078
        // Follow along if the working copy was clean and on the old heads.
786 1079
        let snap = self.snapshot("oxy pull")?;
…
5 unchanged lines
792 1085
            self.checkout(&heads, false, "oxy pull (checkout)")?;
793 1086
            checked_out = true;
794 1087
        }
795 -
        Ok(PullReport { heads, changed: true, checked_out })
1088 +
        Ok(PullReport {
1089 +
            heads,
1090 +
            changed: true,
1091 +
            checked_out,
1092 +
        })
796 1093
    }
797 1094
798 1095
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
…
4 unchanged lines
803 1100
    }
804 1101
805 1102
    /// `push` for a named bookmark (`oxy push -b NAME`).
806 -
    pub fn push_bookmark(&self, remote_name: &str, bookmark: &str, force: bool) -> Result<Vec<RefResult>> {
1103 +
    pub fn push_bookmark(
1104 +
        &self,
1105 +
        remote_name: &str,
1106 +
        bookmark: &str,
1107 +
        force: bool,
1108 +
    ) -> Result<Vec<RefResult>> {
807 1109
        let (op_ref, op) = self.head_op()?;
808 1110
        if !op.view.bookmarks.contains_key(bookmark) {
809 1111
            return fail(format!("no bookmark {bookmark}"));
…
3 unchanged lines
813 1115
        // pre-push hook: may veto before the remote is contacted.
814 1116
        self.run_hook(
815 1117
            "pre-push",
816 -
            &format!("{{\"event\":\"pre-push\",\"remote\":{},\"bookmark\":{},\"heads\":{}}}", json_str(remote_name), json_str(&bookmark), json_array(local_heads.iter().map(|h| json_str(&h.to_hex())))),
1118 +
            &format!(
1119 +
                "{{\"event\":\"pre-push\",\"remote\":{},\"bookmark\":{},\"heads\":{}}}",
1120 +
                json_str(remote_name),
1121 +
                json_str(&bookmark),
1122 +
                json_array(local_heads.iter().map(|h| json_str(&h.to_hex())))
1123 +
            ),
817 1124
        )?;
818 1125
        let remote = self.open_named_remote(remote_name)?;
819 -
        if remote.repo_id()? != self.id()? {
820 -
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
821 -
        }
1126 +
        self.check_remote_id(remote.as_ref(), remote_name, true)?;
822 1127
        let resp = remote.refs()?;
823 1128
        let local_policy = self.current_policy()?.map(|(_, sp)| sp);
824 1129
        let resp_policy_serial = resp.policy.as_ref().map(|p| p.policy.serial);
…
1 unchanged lines
826 1131
            (Some(l), Some(r)) if l.policy.serial > r.policy.serial => Some(l.clone()),
827 1132
            (Some(l), None) => Some(l.clone()),
828 1133
            (Some(l), Some(r)) if l.policy.serial < r.policy.serial => {
829 -
                return fail(format!("{remote_name} has a newer policy (serial {}); fetch first", r.policy.serial));
1134 +
                return fail(format!(
1135 +
                    "{remote_name} has a newer policy (serial {}); fetch first",
1136 +
                    r.policy.serial
1137 +
                ));
830 1138
            }
831 1139
            _ => None,
832 1140
        };
…
2 unchanged lines
835 1143
            return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)"));
836 1144
        }
837 1145
        let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect();
838 -
        let tags_to_send: Vec<crate::protocol::TagRef> = self.tag_refs()?.into_iter().filter(|t| !remote_tags.contains(&t.name)).collect();
1146 +
        let tags_to_send: Vec<crate::protocol::TagRef> = self
1147 +
            .tag_refs()?
1148 +
            .into_iter()
1149 +
            .filter(|t| !remote_tags.contains(&t.name))
1150 +
            .collect();
839 1151
        let refs = resp.refs;
840 1152
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
841 -
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
842 -
            Some(r) => (r.state, r.heads.clone(), r.last_serial),
843 -
            None => (empty, vec![], None),
844 -
        };
845 -
        let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?;
1153 +
        let (remote_state, remote_heads, last_serial) =
1154 +
            match refs.iter().find(|r| r.name == bookmark) {
1155 +
                Some(r) => (r.state, r.heads.clone(), r.last_serial),
1156 +
                None => (empty, vec![], None),
1157 +
            };
1158 +
        let new_state = self
1159 +
            .store
1160 +
            .put_state(&StateObject::new(local_heads.clone()))?;
846 1161
        // A push that carries a policy re-attests the bookmark even when its
847 1162
        // state is unchanged, so the current attestation is always by a key
848 1163
        // the current policy authorizes (a revoked key's attestation would
849 1164
        // otherwise be refused by every later fetch).
850 1165
        if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() {
851 -
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
1166 +
            return Ok(vec![RefResult {
1167 +
                name: bookmark,
1168 +
                ok: true,
1169 +
                reason: "already up to date".into(),
1170 +
            }]);
852 1171
        }
853 1172
        for h in &remote_heads {
854 1173
            if !self.store.has(h) {
855 -
                return fail(format!("the remote has change {} which you do not; pull first", h.short(12)));
1174 +
                return fail(format!(
1175 +
                    "the remote has change {} which you do not; pull first",
1176 +
                    h.short(12)
1177 +
                ));
856 1178
            }
857 1179
        }
858 1180
        if !force {
…
10 unchanged lines
869 1191
            let mut unsigned = Vec::new();
870 1192
            for h in &to_send {
871 1193
                let c = self.store.get_change(h)?;
872 -
                if c.author == key && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
1194 +
                if c.author == key
1195 +
                    && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. })
1196 +
                {
873 1197
                    unsigned.push(*h);
874 1198
                }
875 1199
            }
…
3 unchanged lines
879 1203
            }
880 1204
            bundle = self.bundle_for(&to_send)?;
881 1205
        }
882 -
        bundle.objects.push((ObjectType::State, StateObject::new(local_heads.clone()).encode()));
1206 +
        bundle.objects.push((
1207 +
            ObjectType::State,
1208 +
            StateObject::new(local_heads.clone()).encode(),
1209 +
        ));
883 1210
        let attestation = match key {
884 1211
            Some(_) => Some(self.sign_attest(&Attest {
885 1212
                repo: self.id()?,
886 1213
                hlc: Hlc::now(),
887 -
                body: AttestBody::Ref { name: bookmark.clone(), old_state: remote_state, new_state, serial: last_serial.unwrap_or(0) + 1 },
1214 +
                body: AttestBody::Ref {
1215 +
                    name: bookmark.clone(),
1216 +
                    old_state: remote_state,
1217 +
                    new_state,
1218 +
                    serial: last_serial.unwrap_or(0) + 1,
1219 +
                },
888 1220
            })?),
889 1221
            None => None,
890 1222
        };
…
5 unchanged lines
896 1228
        // Tag states must be present on the remote: bundle their changes too.
897 1229
        for t in &tags_to_send {
898 1230
            let extra = self.negotiate(&remote_heads, &t.heads)?;
899 -
            let extra: Vec<ChangeHash> = extra.into_iter().filter(|h| !to_send.contains(h)).collect();
1231 +
            let extra: Vec<ChangeHash> =
1232 +
                extra.into_iter().filter(|h| !to_send.contains(h)).collect();
900 1233
            if !extra.is_empty() {
901 1234
                let more = self.bundle_for(&extra)?;
902 1235
                bundle.changes.extend(more.changes);
903 1236
                bundle.attests.extend(more.attests);
904 1237
                bundle.objects.extend(more.objects);
905 1238
            }
906 -
            bundle.objects.push((ObjectType::State, StateObject::new(t.heads.clone()).encode()));
1239 +
            bundle.objects.push((
1240 +
                ObjectType::State,
1241 +
                StateObject::new(t.heads.clone()).encode(),
1242 +
            ));
907 1243
        }
908 1244
        if let Some(l) = &policy_to_send {
909 1245
            let remote_serial = resp_policy_serial;
910 -
            bundle.policies = self.policy_history()?.into_iter().filter(|sp| remote_serial.is_none_or(|r| sp.policy.serial > r) && sp.policy.serial < l.policy.serial).collect();
1246 +
            bundle.policies = self
1247 +
                .policy_history()?
1248 +
                .into_iter()
1249 +
                .filter(|sp| {
1250 +
                    remote_serial.is_none_or(|r| sp.policy.serial > r)
1251 +
                        && sp.policy.serial < l.policy.serial
1252 +
                })
1253 +
                .collect();
911 1254
        }
912 -
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send, tags_to_send)?;
913 -
        if let (Some(serial), true) = (sent_serial, results.iter().any(|r| r.ok && r.name == "policy")) {
1255 +
        let results = remote.push(
1256 +
            bundle,
1257 +
            vec![RefUpdate {
1258 +
                name: bookmark.clone(),
1259 +
                old_state: remote_state,
1260 +
                new_state,
1261 +
                force,
1262 +
                attestation,
1263 +
            }],
1264 +
            policy_to_send,
1265 +
            tags_to_send,
1266 +
        )?;
1267 +
        if let (Some(serial), true) = (
1268 +
            sent_serial,
1269 +
            results.iter().any(|r| r.ok && r.name == "policy"),
1270 +
        ) {
914 1271
            self.record_policy_serial(remote_name, serial)?;
915 1272
        }
916 1273
        if results.iter().any(|r| r.ok && r.name == bookmark) {
917 1274
            let mut view = op.view.clone();
918 -
            view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state);
1275 +
            view.remotes
1276 +
                .entry(remote_name.to_string())
1277 +
                .or_default()
1278 +
                .insert(bookmark.clone(), new_state);
919 1279
            self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?;
920 1280
            if let Some(s) = serial {
921 1281
                self.record_serial(remote_name, &bookmark, s)?;
…
11 unchanged lines
933 1293
    /// Clone, leaving blobs above `max_blob` bytes to be fetched on first
934 1294
    /// read, and materializing only `sparse` paths (which implies lazy blobs
935 1295
    /// unless a size is given, so out-of-scope binaries are never fetched).
936 -
    pub fn clone_with(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> {
1296 +
    pub fn clone_with(
1297 +
        url: &str,
1298 +
        dest: &Path,
1299 +
        max_blob: Option<u64>,
1300 +
        sparse: &[String],
1301 +
    ) -> Result<Repo> {
937 1302
        Self::clone_full(url, dest, max_blob, sparse, None)
938 1303
    }
939 1304
940 1305
    /// Shallow clone (spec §17): a checkpoint of the bookmark's heads plus the
941 1306
    /// changes within `depth` steps of them; the rest is the boundary.
942 -
    pub fn clone_shallow(url: &str, dest: &Path, depth: u32, max_blob: Option<u64>, sparse: &[String]) -> Result<Repo> {
1307 +
    pub fn clone_shallow(
1308 +
        url: &str,
1309 +
        dest: &Path,
1310 +
        depth: u32,
1311 +
        max_blob: Option<u64>,
1312 +
        sparse: &[String],
1313 +
    ) -> Result<Repo> {
943 1314
        Self::clone_full(url, dest, max_blob, sparse, Some(depth))
944 1315
    }
945 1316
946 -
    fn clone_full(url: &str, dest: &Path, max_blob: Option<u64>, sparse: &[String], depth: Option<u32>) -> Result<Repo> {
1317 +
    fn clone_full(
1318 +
        url: &str,
1319 +
        dest: &Path,
1320 +
        max_blob: Option<u64>,
1321 +
        sparse: &[String],
1322 +
        depth: Option<u32>,
1323 +
    ) -> Result<Repo> {
947 1324
        let options = RemoteOptions {
948 1325
            token: std::env::var("OXY_TOKEN").ok(),
949 1326
            ca: std::env::var("OXY_CA").ok().map(PathBuf::from),
1327 +
            repo_id: None,
950 1328
        };
951 1329
        let remote = open_remote(url, &options)?;
952 1330
        let id = remote.repo_id()?;
1331 +
        if id == crate::protocol::UNBOUND_ID {
1332 +
            return fail(format!("{url} is empty and unbound: push a repository to it first"));
1333 +
        }
953 1334
        let mut repo = Repo::init_with_id(dest, id)?;
954 1335
        repo.config.set("remote.origin.url", url)?;
955 1336
        if let Some(m) = max_blob {
…
12 unchanged lines
968 1349
        }
969 1350
        let (op_ref, op) = repo.head_op()?;
970 1351
        let bookmark = repo.config.bookmark.clone();
971 -
        if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() {
1352 +
        if let Some(state) = op
1353 +
            .view
1354 +
            .remotes
1355 +
            .get("origin")
1356 +
            .and_then(|b| b.get(&bookmark))
1357 +
            .copied()
1358 +
        {
972 1359
            let heads = repo.store.get_state(&state)?.heads;
973 1360
            let mut view = op.view.clone();
974 1361
            view.bookmarks.insert(bookmark, state);
…
6 unchanged lines
modified crates/oxy-core/src/repo/tags.rs +111 −22
…
23 unchanged lines
24 24
    pub fn tag_attest(&self, name: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
25 25
        match fs::read_to_string(self.tag_attest_path(name)) {
26 26
            Ok(h) => {
27 -
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt tag attestation pointer".into()))?;
28 -
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
27 +
                let r = ObjectRef::from_hex(h.trim())
28 +
                    .ok_or_else(|| RepoError::Msg("corrupt tag attestation pointer".into()))?;
29 +
                Ok(Some((
30 +
                    r,
31 +
                    SignedAttest {
32 +
                        attest: self.store.get_attest(&r)?,
33 +
                        sigs: self.store.signatures(&r)?,
34 +
                    },
35 +
                )))
29 36
            }
30 37
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
31 38
            Err(e) => Err(e.into()),
…
7 unchanged lines
39 46
40 47
    /// Create a tag named `name` for the state of `heads` (spec §10.1).
41 48
    pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> {
42 -
        crate::objects::validate_name(name).map_err(|e| RepoError::Msg(format!("tag name: {e}")))?;
49 +
        crate::objects::validate_name(name)
50 +
            .map_err(|e| RepoError::Msg(format!("tag name: {e}")))?;
43 51
        let (op_ref, op) = self.head_op()?;
44 52
        if op.view.tags.contains_key(name) {
45 53
            return fail(format!("tag {name:?} already exists; tags are immutable"));
…
5 unchanged lines
51 59
        }
52 60
        let state = self.store.put_state(&StateObject::new(heads.clone()))?;
53 61
        let mut signer = None;
54 -
        let attest = Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Tag { name: name.into(), state, message: message.into() } };
62 +
        let attest = Attest {
63 +
            repo: self.id()?,
64 +
            hlc: Hlc::now(),
65 +
            body: AttestBody::Tag {
66 +
                name: name.into(),
67 +
                state,
68 +
                message: message.into(),
69 +
            },
70 +
        };
55 71
        if self.secret_key()?.is_some() {
56 72
            let sa = self.sign_attest(&attest)?;
57 73
            let r = sa.attest.hash();
58 74
            self.set_tag_attest(name, &r)?;
59 -
            signer = sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name);
75 +
            signer = sa
76 +
                .sigs
77 +
                .first()
78 +
                .and_then(|s| self.store.get_identity(&s.key).ok())
79 +
                .map(|i| i.name);
60 80
        } else {
61 81
            // No key: keep the attestation unsigned so the message survives;
62 82
            // it is never sent to remotes (`tag_refs`).
…
3 unchanged lines
66 86
        let mut view = op.view.clone();
67 87
        view.tags.insert(name.into(), state);
68 88
        self.record_op(&[op_ref], &format!("oxy tag create {name}"), view)?;
69 -
        Ok(TagInfo { name: name.into(), state, heads, message: message.into(), signer })
89 +
        Ok(TagInfo {
90 +
            name: name.into(),
91 +
            state,
92 +
            heads,
93 +
            message: message.into(),
94 +
            signer,
95 +
        })
70 96
    }
71 97
72 98
    /// Every tag, by name.
…
8 unchanged lines
81 107
                        AttestBody::Tag { message, .. } => message.clone(),
82 108
                        _ => String::new(),
83 109
                    };
84 -
                    (message, sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name))
110 +
                    (
111 +
                        message,
112 +
                        sa.sigs
113 +
                            .first()
114 +
                            .and_then(|s| self.store.get_identity(&s.key).ok())
115 +
                            .map(|i| i.name),
116 +
                    )
85 117
                }
86 118
                None => (String::new(), None),
87 119
            };
88 -
            out.push(TagInfo { name: name.clone(), state: *state, heads, message, signer });
120 +
            out.push(TagInfo {
121 +
                name: name.clone(),
122 +
                state: *state,
123 +
                heads,
124 +
                message,
125 +
                signer,
126 +
            });
89 127
        }
90 128
        Ok(out)
91 129
    }
…
10 unchanged lines
102 140
    pub(crate) fn tag_refs(&self) -> Result<Vec<crate::protocol::TagRef>> {
103 141
        let mut out = Vec::new();
104 142
        for t in self.tags()? {
105 -
            let attestation = self.tag_attest(&t.name)?.map(|(_, a)| a).filter(|a| !a.sigs.is_empty());
106 -
            out.push(crate::protocol::TagRef { name: t.name.clone(), state: t.state, heads: t.heads, attestation });
143 +
            let attestation = self
144 +
                .tag_attest(&t.name)?
145 +
                .map(|(_, a)| a)
146 +
                .filter(|a| !a.sigs.is_empty());
147 +
            out.push(crate::protocol::TagRef {
148 +
                name: t.name.clone(),
149 +
                state: t.state,
150 +
                heads: t.heads,
151 +
                attestation,
152 +
            });
107 153
        }
108 154
        Ok(out)
109 155
    }
…
1 unchanged lines
111 157
    /// Adopt a tag received from a remote or a push. Verifies the
112 158
    /// attestation when present (and requires one, by an authorized key,
113 159
    /// under a policy). An existing tag with a different state is refused.
114 -
    pub(crate) fn adopt_tag(&self, view: &mut View, t: &crate::protocol::TagRef, policy: Option<&crate::policy::Policy>) -> std::result::Result<(), String> {
160 +
    pub(crate) fn adopt_tag(
161 +
        &self,
162 +
        view: &mut View,
163 +
        t: &crate::protocol::TagRef,
164 +
        policy: Option<&crate::policy::Policy>,
165 +
    ) -> std::result::Result<(), String> {
115 166
        if let Some(existing) = view.tags.get(&t.name) {
116 -
            return if *existing == t.state { Ok(()) } else { Err(format!("tag {:?} already exists with a different state; tags are immutable", t.name)) };
167 +
            return if *existing == t.state {
168 +
                Ok(())
169 +
            } else {
170 +
                Err(format!(
171 +
                    "tag {:?} already exists with a different state; tags are immutable",
172 +
                    t.name
173 +
                ))
174 +
            };
117 175
        }
118 -
        let state = self.store.get_state(&t.state).map_err(|_| "tag state object is missing".to_string())?;
176 +
        let state = self
177 +
            .store
178 +
            .get_state(&t.state)
179 +
            .map_err(|_| "tag state object is missing".to_string())?;
119 180
        for h in &state.heads {
120 181
            if !self.store.has(h) && !self.boundary().contains(h) {
121 -
                return Err(format!("tag {:?} names change {}, which is missing", t.name, h.short(12)));
182 +
                return Err(format!(
183 +
                    "tag {:?} names change {}, which is missing",
184 +
                    t.name,
185 +
                    h.short(12)
186 +
                ));
122 187
            }
123 188
        }
124 189
        if let Some(sa) = &t.attestation {
125 -
            let AttestBody::Tag { name, state: st, .. } = &sa.attest.body else { return Err("tag attestation has the wrong kind".into()) };
190 +
            let AttestBody::Tag {
191 +
                name, state: st, ..
192 +
            } = &sa.attest.body
193 +
            else {
194 +
                return Err("tag attestation has the wrong kind".into());
195 +
            };
126 196
            if *name != t.name || *st != t.state {
127 197
                return Err("tag attestation does not describe this tag".into());
128 198
            }
…
1 unchanged lines
130 200
                return Err("tag attestation is for a different repository".into());
131 201
            }
132 202
            let r = sa.attest.hash();
133 -
            let sig = sa.sigs.first().ok_or("tag attestation carries no signature")?;
134 -
            let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
203 +
            let sig = sa
204 +
                .sigs
205 +
                .first()
206 +
                .ok_or("tag attestation carries no signature")?;
207 +
            let id = self
208 +
                .store
209 +
                .get_identity(&sig.key)
210 +
                .map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
135 211
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
136 212
            if let Some(p) = policy {
137 213
                if !p.authorizes(&sig.key, &format!("tags/{}", t.name)) {
138 -
                    return Err(format!("key {} may not create tags/{} under this repository's policy", sig.key.short(12), t.name));
214 +
                    return Err(format!(
215 +
                        "key {} may not create tags/{} under this repository's policy",
216 +
                        sig.key.short(12),
217 +
                        t.name
218 +
                    ));
139 219
                }
140 220
            }
141 -
            let stored = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
221 +
            let stored = self
222 +
                .store
223 +
                .put_attest(&sa.attest)
224 +
                .map_err(|e| e.to_string())?;
142 225
            for s in &sa.sigs {
143 -
                self.store.put_signature(&stored, s).map_err(|e| e.to_string())?;
226 +
                self.store
227 +
                    .put_signature(&stored, s)
228 +
                    .map_err(|e| e.to_string())?;
144 229
            }
145 -
            self.set_tag_attest(&t.name, &stored).map_err(|e| e.to_string())?;
230 +
            self.set_tag_attest(&t.name, &stored)
231 +
                .map_err(|e| e.to_string())?;
146 232
        } else if policy.is_some() {
147 -
            return Err(format!("tag {:?} needs a signed attestation under this repository's policy", t.name));
233 +
            return Err(format!(
234 +
                "tag {:?} needs a signed attestation under this repository's policy",
235 +
                t.name
236 +
            ));
148 237
        }
149 238
        view.tags.insert(t.name.clone(), t.state);
150 239
        Ok(())
…
2 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +1676 −364
1 1
//! Tests for the repository layer.
2 2
3 3
use super::*;
4 +
use crate::protocol::SignedPolicy;
4 5
use crate::repo::{BisectStep, Selection};
5 -
use crate::protocol::SignedPolicy;
6 6
7 7
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
8 8
/// test reads or writes the developer's real user configuration or keys.
…
14 unchanged lines
23 23
}
24 24
25 25
fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
26 -
    s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
26 +
    s.statuses
27 +
        .iter()
28 +
        .map(|p| (p.path.clone(), p.status))
29 +
        .collect()
27 30
}
28 31
29 32
#[test]
…
44 unchanged lines
74 77
    assert!(repo.commit("nothing", &[]).is_err());
75 78
76 79
    // Modify one file, remove another, add a binary file.
77 -
    write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
80 +
    write(
81 +
        root,
82 +
        "src/main.rs",
83 +
        b"fn main() {\n    println!(\"hi\");\n}\n",
84 +
    );
78 85
    fs::remove_file(root.join("README.md")).unwrap();
79 86
    write(root, "logo.png", b"\x89PNG\0\0\x01");
80 87
    let s = repo.snapshot("oxy status").unwrap();
…
12 unchanged lines
93 100
94 101
    let h2 = repo.commit("Second", &[]).unwrap();
95 102
    let c2 = repo.store().get_change(&h2).unwrap();
96 -
    assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
103 +
    assert_eq!(
104 +
        c2.deps,
105 +
        vec![h1],
106 +
        "everything it touches was introduced by h1"
107 +
    );
97 108
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
98 109
99 110
    // The materialized head state matches the working copy.
100 111
    let st = repo.load_state(&[h2]).unwrap();
101 112
    let snap = st.materialize();
102 113
    assert!(!snap.is_conflicted());
103 -
    assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
104 -
    assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
114 +
    assert_eq!(
115 +
        snap.files.keys().cloned().collect::<Vec<_>>(),
116 +
        [".oxyignore", "logo.png", "src", "src/main.rs"]
117 +
    );
118 +
    assert!(
119 +
        matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n")
120 +
    );
105 121
    assert!(snap.files["logo.png"].mode.opaque);
106 122
    if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
107 123
        assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
…
15 unchanged lines
123 139
124 140
    // Resolution by prefix.
125 141
    assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
126 -
    assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
142 +
    assert_eq!(
143 +
        repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(),
144 +
        h1
145 +
    );
127 146
    assert!(repo.resolve_change("zzzz").is_err());
128 147
129 148
    // Undo the commit: bookmark and working copy go back.
130 149
    repo.undo().unwrap();
131 150
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
132 151
    let s = repo.snapshot("oxy status").unwrap();
133 -
    assert!(!s.changed, "files on disk still match the pre-commit working copy");
152 +
    assert!(
153 +
        !s.changed,
154 +
        "files on disk still match the pre-commit working copy"
155 +
    );
134 156
    assert_eq!(s.statuses.len(), 3);
135 157
}
136 158
…
23 unchanged lines
160 182
    assert!(st.materialize().is_conflicted());
161 183
162 184
    // Checking out main writes markers.
163 -
    let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
185 +
    let r = repo
186 +
        .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
187 +
        .unwrap();
164 188
    assert!(r.conflicted);
165 189
    let marked = fs::read_to_string(root.join("f.txt")).unwrap();
166 -
    assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}");
167 -
    assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}");
190 +
    assert!(
191 +
        marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"),
192 +
        "{marked}"
193 +
    );
194 +
    assert!(
195 +
        marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"),
196 +
        "{marked}"
197 +
    );
168 198
    assert!(has_conflict_markers(marked.as_bytes()));
169 199
    // Untouched markers: clean working copy; commit has nothing to do.
170 200
    let s = repo.snapshot("status").unwrap();
…
3 unchanged lines
174 204
    // Partially edited file that still has markers cannot be committed.
175 205
    write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
176 206
    let s = repo.snapshot("status").unwrap();
177 -
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]);
207 +
    assert_eq!(
208 +
        statuses(&s),
209 +
        vec![("f.txt".into(), ChangeStatus::ConflictEdited)]
210 +
    );
178 211
    assert!(repo.commit("no", &[]).is_err());
179 212
180 213
    // Resolve by keeping one side and adding a line elsewhere.
…
6 unchanged lines
187 220
    assert_eq!(c4.kind, ChangeKind::Resolution);
188 221
    let mut want = vec![h1, h2, h3];
189 222
    want.sort();
190 -
    assert_eq!(c4.deps, want, "a resolution depends on exactly the contributors and what it references");
223 +
    assert_eq!(
224 +
        c4.deps, want,
225 +
        "a resolution depends on exactly the contributors and what it references"
226 +
    );
191 227
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
192 228
    let snap = repo.load_state(&[h4]).unwrap().materialize();
193 229
    assert!(!snap.is_conflicted());
194 -
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
230 +
    assert!(
231 +
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")
232 +
    );
195 233
    assert!(!repo.snapshot("status").unwrap().changed);
196 234
    // A peer holding only h2 and h3 and receiving h4 sees the same result,
197 235
    // whatever order the changes arrive in.
…
2 unchanged lines
200 238
        st.add_unchecked(repo.store().get_change(&h).unwrap());
201 239
    }
202 240
    st.validate().unwrap();
203 -
    assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
241 +
    assert!(
242 +
        matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n")
243 +
    );
204 244
}
205 245
206 246
#[test]
…
1 unchanged lines
208 248
    hermetic();
209 249
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
210 250
    let root = dir.path();
211 -
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
251 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
252 +
        .unwrap();
212 253
    // Keep both sides in a chosen order.
213 254
    write(root, "f.txt", b"a\nX\nB\nc\n");
214 255
    let h = repo.commit("both", &[]).unwrap();
215 256
    let snap = repo.load_state(&[h]).unwrap().materialize();
216 257
    assert!(!snap.is_conflicted());
217 -
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n"));
258 +
    assert!(
259 +
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n")
260 +
    );
218 261
    // Undo and instead drop the whole region.
219 262
    repo.undo().unwrap();
220 263
    write(root, "f.txt", b"a\nc\n");
221 264
    let h = repo.commit("neither", &[]).unwrap();
222 265
    let snap = repo.load_state(&[h]).unwrap().materialize();
223 266
    assert!(!snap.is_conflicted());
224 -
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
267 +
    assert!(
268 +
        matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")
269 +
    );
225 270
}
226 271
227 272
#[test]
…
9 unchanged lines
237 282
    repo.checkout(&[h1], false, "checkout").unwrap();
238 283
    write(root, "new.txt", b"two\n");
239 284
    repo.commit("two", &[]).unwrap();
240 -
    let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
285 +
    let r = repo
286 +
        .checkout(&repo.bookmark_heads().unwrap(), false, "checkout main")
287 +
        .unwrap();
241 288
    assert!(r.conflicted);
242 -
    assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists());
289 +
    assert!(
290 +
        root.join("new.txt.oxy-conflict-1").exists()
291 +
            && root.join("new.txt.oxy-conflict-2").exists()
292 +
    );
243 293
    assert!(!root.join("new.txt").exists());
244 294
    // Candidate numbering follows change-hash order, so pick by content:
245 295
    // keep the "two" candidate under the real name and drop the other.
246 -
    let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" { (1, 2) } else { (2, 1) };
296 +
    let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" {
297 +
        (1, 2)
298 +
    } else {
299 +
        (2, 1)
300 +
    };
247 301
    fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap();
248 -
    fs::rename(root.join(format!("new.txt.oxy-conflict-{keep}")), root.join("new.txt")).unwrap();
302 +
    fs::rename(
303 +
        root.join(format!("new.txt.oxy-conflict-{keep}")),
304 +
        root.join("new.txt"),
305 +
    )
306 +
    .unwrap();
249 307
    let h = repo.commit("pick two", &[]).unwrap();
250 -
    assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution);
308 +
    assert_eq!(
309 +
        repo.store().get_change(&h).unwrap().kind,
310 +
        ChangeKind::Resolution
311 +
    );
251 312
    // The resolution does not touch keep.txt, so it does not depend on h1;
252 313
    // the bookmark keeps both as heads and their union is the state.
253 314
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
254 315
    assert_eq!(heads, [h1, h].into_iter().collect());
255 -
    let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
316 +
    let snap = repo
317 +
        .load_state(&repo.bookmark_heads().unwrap())
318 +
        .unwrap()
319 +
        .materialize();
256 320
    assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
257 -
    assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]);
258 -
    assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n"));
321 +
    assert_eq!(
322 +
        snap.files.keys().cloned().collect::<Vec<_>>(),
323 +
        ["keep.txt", "new.txt"]
324 +
    );
325 +
    assert!(
326 +
        matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n")
327 +
    );
259 328
}
260 329
261 330
#[test]
…
5 unchanged lines
267 336
    write(root, "a.txt", b"a\n");
268 337
    let h0 = repo.commit("unsigned", &[]).unwrap();
269 338
    assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
270 -
    assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
339 +
    assert_eq!(
340 +
        repo.store().get_change(&h0).unwrap().author,
341 +
        ObjectRef::ZERO
342 +
    );
271 343
272 -
    let (key, id) = repo.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
344 +
    let (key, id) = repo
345 +
        .create_identity("Ada", "[email protected]", IdentityScope::Repo)
346 +
        .unwrap();
273 347
    assert_eq!(id.hash(), key);
274 348
    assert_eq!(repo.config.author_key, Some(key));
275 349
    // Config survives reopen; the secret key is found again.
…
8 unchanged lines
284 358
    assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
285 359
    // Signing later works too.
286 360
    repo.sign_change(&h1).unwrap();
287 -
    assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
361 +
    assert!(
362 +
        matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)
363 +
    );
288 364
289 365
    repo.config.set("core.sign", "change").unwrap();
290 366
    repo.save_config().unwrap();
291 367
    write(root, "a.txt", b"a\nb\nc\n");
292 368
    let h2 = repo.commit("signed", &[]).unwrap();
293 -
    assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false, revoked: false });
369 +
    assert_eq!(
370 +
        repo.verify_change(&h2).unwrap(),
371 +
        SignatureStatus::Valid {
372 +
            key,
373 +
            name: "Ada".into(),
374 +
            email: "[email protected]".into(),
375 +
            sealed: false,
376 +
            revoked: false
377 +
        }
378 +
    );
294 379
295 380
    // A signature by a key that is not the author or a delegate is rejected.
296 381
    let other = SecretKey::generate();
…
1 unchanged lines
298 383
    let other_key = repo.store().put_identity(&other_id).unwrap();
299 384
    let forged = other.sign(Domain::Change, &other_key, &h0);
300 385
    repo.store().put_signature(&h0, &forged).unwrap();
301 -
    assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")));
386 +
    assert!(
387 +
        matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author"))
388 +
    );
302 389
303 390
    // Tampering with the stored signature invalidates it.
304 391
    let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
305 392
    sig.ed[0] ^= 1;
306 393
    repo.store().put_signature(&h2, &sig).unwrap();
307 -
    assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
394 +
    assert!(matches!(
395 +
        repo.verify_change(&h2).unwrap(),
396 +
        SignatureStatus::Invalid(_)
397 +
    ));
308 398
309 399
    // Without the secret key, signing at commit fails cleanly.
310 400
    let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere"));
…
16 unchanged lines
327 417
    let h2 = repo.commit("second", &[]).unwrap();
328 418
329 419
    let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
330 -
    assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false, notes: vec![] });
420 +
    assert_eq!(
421 +
        r,
422 +
        CheckoutReport {
423 +
            written: 2,
424 +
            removed: 1,
425 +
            conflicted: false,
426 +
            notes: vec![]
427 +
        }
428 +
    );
331 429
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
332 430
    assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
333 431
    assert!(!root.join("bin.dat").exists());
334 432
    let (_, wc) = repo.working_copy().unwrap();
335 433
    assert_eq!(wc.deps, vec![h1]);
336 -
    assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy");
434 +
    assert!(
435 +
        !repo.snapshot("status").unwrap().changed,
436 +
        "checkout leaves a clean working copy"
437 +
    );
337 438
    // The bookmark still points at h2.
338 439
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
339 440
340 441
    // Dirty working copy refuses without --discard.
341 442
    write(root, "a.txt", b"dirty\n");
342 443
    assert!(repo.checkout(&[h2], false, "oxy checkout").is_err());
343 -
    let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap();
444 +
    let r = repo
445 +
        .checkout(&[h2], true, "oxy checkout --discard")
446 +
        .unwrap();
344 447
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n");
345 448
    assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]);
346 449
    assert!(!root.join("sub").exists(), "empty directories are pruned");
…
35 unchanged lines
382 485
    let origin_url = origin_path.to_string_lossy().to_string();
383 486
384 487
    // A clones, creates an identity (default core.sign = seal), commits, pushes.
385 -
    let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_home(root.join("home-a"));
488 +
    let mut a = Repo::clone(&origin_url, &root.join("a"))
489 +
        .unwrap()
490 +
        .with_home(root.join("home-a"));
386 491
    assert_eq!(a.id().unwrap(), origin.id().unwrap());
387 -
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
492 +
    let (key_a, _) = a
493 +
        .create_identity("Ada", "[email protected]", IdentityScope::Repo)
494 +
        .unwrap();
388 495
    write(&root.join("a"), "f.txt", b"a\n");
389 496
    let h1 = a.commit("first", &[]).unwrap();
390 497
    let results = a.push("origin", false).unwrap();
391 498
    assert!(results[0].ok, "{}", results[0].reason);
392 499
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
393 500
    // The change was sealed at push and the reference attested.
394 -
    assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a));
395 -
    assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
501 +
    assert!(
502 +
        matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a)
503 +
    );
504 +
    assert_eq!(
505 +
        origin.verify_change(&h1).unwrap(),
506 +
        SignatureStatus::Unsigned
507 +
    );
396 508
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
397 509
    assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }));
398 510
    let again = a.push("origin", false).unwrap();
399 511
    assert!(again[0].ok && again[0].reason.contains("up to date"));
400 512
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
401 -
    assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again");
513 +
    assert!(
514 +
        matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }),
515 +
        "a no-op push does not attest again"
516 +
    );
402 517
403 518
    // B clones and sees the file and the seal.
404 519
    let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
405 520
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
406 521
    assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
407 -
    assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. }));
522 +
    assert!(matches!(
523 +
        b.authenticate_change(&h1).unwrap(),
524 +
        SignatureStatus::Valid { sealed: true, .. }
525 +
    ));
408 526
409 527
    // B (no identity) commits and pushes unsigned; the unverified origin accepts.
410 528
    write(&root.join("b"), "f.txt", b"a\nb\n");
…
38 unchanged lines
449 567
    assert!(text.contains("<<<<<<< oxy conflict"), "{text}");
450 568
    write(&root.join("b"), "f.txt", b"a\nAB\n");
451 569
    let hr = b.commit("resolve", &[]).unwrap();
452 -
    assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution);
570 +
    assert_eq!(
571 +
        b.store().get_change(&hr).unwrap().kind,
572 +
        ChangeKind::Resolution
573 +
    );
453 574
    assert!(b.push("origin", false).unwrap()[0].ok);
454 575
    a.pull("origin").unwrap();
455 576
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n");
…
12 unchanged lines
468 589
    let root = dir.path();
469 590
    let origin = Repo::init(&root.join("origin")).unwrap();
470 591
    let url = root.join("origin").to_string_lossy().to_string();
471 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
472 -
    let (key, _) = a.create_identity("Ada", "a@b", IdentityScope::Repo).unwrap();
592 +
    let mut a = Repo::clone(&url, &root.join("a"))
593 +
        .unwrap()
594 +
        .with_home(root.join("home"));
595 +
    let (key, _) = a
596 +
        .create_identity("Ada", "a@b", IdentityScope::Repo)
597 +
        .unwrap();
473 598
    write(&root.join("a"), "f.txt", b"x\n");
474 599
    let h1 = a.commit("one", &[]).unwrap();
475 600
    assert!(a.push("origin", false).unwrap()[0].ok);
476 601
    let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap();
477 -
    let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap();
602 +
    let s1 = origin
603 +
        .store()
604 +
        .put_state(&StateObject::new(vec![h1]))
605 +
        .unwrap();
478 606
479 607
    // Missing head.
480 -
    let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
481 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None, vec![]).unwrap();
482 -
    assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
608 +
    let ghost = origin
609 +
        .store()
610 +
        .put_state(&StateObject::new(vec![ObjectRef([9; 32])]))
611 +
        .unwrap();
612 +
    let r = origin
613 +
        .receive_push(
614 +
            Bundle::default(),
615 +
            vec![RefUpdate {
616 +
                name: "main".into(),
617 +
                old_state: s1,
618 +
                new_state: ghost,
619 +
                force: false,
620 +
                attestation: None,
621 +
            }],
622 +
            None,
623 +
            vec![],
624 +
        )
625 +
        .unwrap();
626 +
    assert!(
627 +
        !r[0].ok && r[0].reason.contains("missing"),
628 +
        "{}",
629 +
        r[0].reason
630 +
    );
483 631
484 632
    // Stale old_state.
485 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None, vec![]).unwrap();
633 +
    let r = origin
634 +
        .receive_push(
635 +
            Bundle::default(),
636 +
            vec![RefUpdate {
637 +
                name: "main".into(),
638 +
                old_state: empty,
639 +
                new_state: s1,
640 +
                force: false,
641 +
                attestation: None,
642 +
            }],
643 +
            None,
644 +
            vec![],
645 +
        )
646 +
        .unwrap();
486 647
    assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
487 648
488 649
    // Non-fast-forward without force is refused; with force it passes.
489 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None, vec![]).unwrap();
490 -
    assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
491 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None, vec![]).unwrap();
650 +
    let r = origin
651 +
        .receive_push(
652 +
            Bundle::default(),
653 +
            vec![RefUpdate {
654 +
                name: "main".into(),
655 +
                old_state: s1,
656 +
                new_state: empty,
657 +
                force: false,
658 +
                attestation: None,
659 +
            }],
660 +
            None,
661 +
            vec![],
662 +
        )
663 +
        .unwrap();
664 +
    assert!(
665 +
        !r[0].ok && r[0].reason.contains("fast-forward"),
666 +
        "{}",
667 +
        r[0].reason
668 +
    );
669 +
    let r = origin
670 +
        .receive_push(
671 +
            Bundle::default(),
672 +
            vec![RefUpdate {
673 +
                name: "main".into(),
674 +
                old_state: s1,
675 +
                new_state: empty,
676 +
                force: true,
677 +
                attestation: None,
678 +
            }],
679 +
            None,
680 +
            vec![],
681 +
        )
682 +
        .unwrap();
492 683
    assert!(r[0].ok);
493 684
    assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
494 685
495 686
    // Attestation with a stale serial is a rollback and is refused.
496 -
    let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap();
497 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None, vec![]).unwrap();
498 -
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
687 +
    let sa = a
688 +
        .sign_attest(&Attest {
689 +
            repo: a.id().unwrap(),
690 +
            hlc: Hlc::now(),
691 +
            body: AttestBody::Ref {
692 +
                name: "main".into(),
693 +
                old_state: empty,
694 +
                new_state: s1,
695 +
                serial: 1,
696 +
            },
697 +
        })
698 +
        .unwrap();
699 +
    let r = origin
700 +
        .receive_push(
701 +
            Bundle::default(),
702 +
            vec![RefUpdate {
703 +
                name: "main".into(),
704 +
                old_state: empty,
705 +
                new_state: s1,
706 +
                force: false,
707 +
                attestation: Some(sa.clone()),
708 +
            }],
709 +
            None,
710 +
            vec![],
711 +
        )
712 +
        .unwrap();
713 +
    assert!(
714 +
        !r[0].ok && r[0].reason.contains("serial"),
715 +
        "{}",
716 +
        r[0].reason
717 +
    );
499 718
    // An attestation for a different update is refused too.
500 -
    let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap();
501 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None, vec![]).unwrap();
502 -
    assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
719 +
    let sa2 = a
720 +
        .sign_attest(&Attest {
721 +
            repo: a.id().unwrap(),
722 +
            hlc: Hlc::now(),
723 +
            body: AttestBody::Ref {
724 +
                name: "main".into(),
725 +
                old_state: s1,
726 +
                new_state: empty,
727 +
                serial: 5,
728 +
            },
729 +
        })
730 +
        .unwrap();
731 +
    let r = origin
732 +
        .receive_push(
733 +
            Bundle::default(),
734 +
            vec![RefUpdate {
735 +
                name: "main".into(),
736 +
                old_state: empty,
737 +
                new_state: s1,
738 +
                force: false,
739 +
                attestation: Some(sa2),
740 +
            }],
741 +
            None,
742 +
            vec![],
743 +
        )
744 +
        .unwrap();
745 +
    assert!(
746 +
        !r[0].ok && r[0].reason.contains("describe"),
747 +
        "{}",
748 +
        r[0].reason
749 +
    );
503 750
    let _ = key;
504 751
}
505 752
…
8 unchanged lines
514 761
515 762
    // Identity at user scope: the repository file stays free of author keys.
516 763
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
517 -
    let (key, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
764 +
    let (key, _) = a
765 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
766 +
        .unwrap();
518 767
    assert!(!a.config.repo_layer().contains_key("author.key"));
519 768
    assert_eq!(a.config.author_key, Some(key));
520 769
    assert!(home.join("config.toml").exists());
…
6 unchanged lines
527 776
    let again = Repo::open(&root.join("a")).unwrap().with_home(&home);
528 777
    assert!(!again.config.sign_changes);
529 778
    assert_eq!(again.config.author_key, Some(key));
530 -
    assert_eq!(read_config_file(&root.join("a/.oxy/config")).unwrap().get("core.sign").map(String::as_str), Some("seal"));
779 +
    assert_eq!(
780 +
        read_config_file(&root.join("a/.oxy/config"))
781 +
            .unwrap()
782 +
            .get("core.sign")
783 +
            .map(String::as_str),
784 +
        Some("seal")
785 +
    );
531 786
532 787
    // A fresh clone made by this user signs with the user-level identity,
533 788
    // even though the clone's store has never seen the identity object.
…
1 unchanged lines
535 790
    assert!(b.config.sign_changes);
536 791
    write(&root.join("b"), "f.txt", b"f\n");
537 792
    let h = b.commit("signed from a clone", &[]).unwrap();
538 -
    assert!(matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
793 +
    assert!(
794 +
        matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key)
795 +
    );
539 796
    assert!(b.push("origin", false).unwrap()[0].ok);
540 -
    assert!(matches!(origin.verify_change(&h).unwrap(), SignatureStatus::Valid { .. }));
797 +
    assert!(matches!(
798 +
        origin.verify_change(&h).unwrap(),
799 +
        SignatureStatus::Valid { .. }
800 +
    ));
541 801
}
542 802
543 803
#[test]
…
10 unchanged lines
554 814
    let mut v1 = Format1View::default();
555 815
    v1.bookmarks.insert("main".into(), vec![]);
556 816
    v1.workspaces.insert(repo.workspace(), wc_hash);
557 -
    let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() };
817 +
    let init = Format1Op {
818 +
        parents: vec![],
819 +
        hlc: Hlc::default(),
820 +
        command: "oxy init".into(),
821 +
        view: v1.clone(),
822 +
    };
558 823
    let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap();
559 824
    v1.bookmarks.insert("main".into(), vec![h1]);
560 -
    let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 };
825 +
    let commit = Format1Op {
826 +
        parents: vec![init_ref],
827 +
        hlc: Hlc::default(),
828 +
        command: "oxy commit".into(),
829 +
        view: v1,
830 +
    };
561 831
    let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap();
562 832
    write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap();
563 833
    write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap();
564 834
    drop(repo);
565 835
566 836
    let repo = Repo::open(root).unwrap();
567 -
    assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE);
837 +
    assert_eq!(
838 +
        fs::read_to_string(root.join(".oxy/format")).unwrap(),
839 +
        FORMAT_LINE
840 +
    );
568 841
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
569 842
    let ops = repo.op_log().unwrap();
570 -
    assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::<Vec<_>>(), ["oxy commit", "oxy init"]);
843 +
    assert_eq!(
844 +
        ops.iter()
845 +
            .map(|(_, o)| o.command.as_str())
846 +
            .collect::<Vec<_>>(),
847 +
        ["oxy commit", "oxy init"]
848 +
    );
571 849
    assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]);
572 850
    assert!(!repo.snapshot("status").unwrap().changed);
573 851
    // Reopening again is a no-op.
…
19 unchanged lines
593 871
    assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
594 872
    let s = repo.snapshot("oxy status").unwrap();
595 873
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
596 -
    assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy");
874 +
    assert_eq!(
875 +
        paths,
876 +
        ["a.txt", "b.txt"],
877 +
        "both files are back in the working copy"
878 +
    );
597 879
    assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
598 880
    // Only snapshots remain above `init`, so there is nothing left to undo.
599 881
    assert_eq!(repo.last_command().unwrap().command, "oxy init");
…
42 unchanged lines
642 924
        use std::os::unix::fs::PermissionsExt;
643 925
        fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
644 926
        let s = repo.snapshot("status").unwrap();
645 -
        assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
927 +
        assert_eq!(
928 +
            statuses(&s),
929 +
            vec![("run.sh".into(), ChangeStatus::ModeChanged)]
930 +
        );
646 931
        let h = repo.commit("exec", &[]).unwrap();
647 932
        let snap = repo.load_state(&[h]).unwrap().materialize();
648 933
        assert!(snap.files["run.sh"].mode.exec);
…
7 unchanged lines
656 941
    let h = repo.commit("text", &[]).unwrap();
657 942
    let snap = repo.load_state(&[h]).unwrap().materialize();
658 943
    assert!(!snap.files["run.sh"].mode.opaque);
659 -
    assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
944 +
    assert!(
945 +
        matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n")
946 +
    );
660 947
    assert!(!snap.is_conflicted());
661 948
}
662 949
…
6 unchanged lines
669 956
    let url = root.join("origin").to_string_lossy().to_string();
670 957
671 958
    // A is the root. Before the policy, an unsigned push from B is fine.
672 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a"));
673 -
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
674 -
    let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
959 +
    let mut a = Repo::clone(&url, &root.join("a"))
960 +
        .unwrap()
961 +
        .with_home(root.join("home-a"));
962 +
    let (key_a, _) = a
963 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
964 +
        .unwrap();
965 +
    let mut b = Repo::clone(&url, &root.join("b"))
966 +
        .unwrap()
967 +
        .with_home(root.join("home-b"));
675 968
    write(&root.join("b"), "b.txt", b"b\n");
676 969
    b.commit("unsigned before policy", &[]).unwrap();
677 970
    assert!(b.push("origin", false).unwrap()[0].ok);
…
10 unchanged lines
688 981
689 982
    // B pulls the policy. Without an identity B cannot push at all.
690 983
    b.pull("origin").unwrap();
691 -
    assert!(b.current_policy().unwrap().is_some(), "the policy arrived with the fetch");
984 +
    assert!(
985 +
        b.current_policy().unwrap().is_some(),
986 +
        "the policy arrived with the fetch"
987 +
    );
692 988
    assert!(b.push("origin", false).is_err());
693 989
    // With an identity the policy does not list, the push is rejected by origin.
694 -
    let (key_b, id_b) = b.create_identity("Bob", "[email protected]", IdentityScope::User).unwrap();
990 +
    let (key_b, id_b) = b
991 +
        .create_identity("Bob", "[email protected]", IdentityScope::User)
992 +
        .unwrap();
695 993
    write(&root.join("b"), "b.txt", b"b2\n");
696 994
    let h_b2 = b.commit("b2 as Bob", &[]).unwrap();
697 995
    assert_eq!(b.store().get_change(&h_b2).unwrap().author, key_b);
698 996
    let r = b.push("origin", false).unwrap();
699 -
    assert!(!r[0].ok && r[0].reason.contains("may not update"), "{:?}", r);
997 +
    assert!(
998 +
        !r[0].ok && r[0].reason.contains("may not update"),
999 +
        "{:?}",
1000 +
        r
1001 +
    );
700 1002
701 1003
    // A grants B (identity handed over as a file) and publishes the policy.
702 1004
    let sp = a.policy_grant(id_b.clone(), None).unwrap();
…
1 unchanged lines
704 1006
    let r = a.push("origin", false).unwrap();
705 1007
    assert!(r.iter().any(|x| x.name == "policy" && x.ok), "{r:?}");
706 1008
    // B must fetch the new policy before pushing.
707 -
    assert!(b.push("origin", false).unwrap_or_else(|e| vec![RefResult { name: "main".into(), ok: false, reason: e.to_string() }]).iter().all(|x| !x.ok));
1009 +
    assert!(b
1010 +
        .push("origin", false)
1011 +
        .unwrap_or_else(|e| vec![RefResult {
1012 +
            name: "main".into(),
1013 +
            ok: false,
1014 +
            reason: e.to_string()
1015 +
        }])
1016 +
        .iter()
1017 +
        .all(|x| !x.ok));
708 1018
    b.pull("origin").unwrap();
709 1019
    assert_eq!(b.current_policy().unwrap().unwrap().1.policy.serial, 2);
710 1020
    let r = b.push("origin", false).unwrap();
…
3 unchanged lines
714 1024
    for h in &heads {
715 1025
        let c = origin.store().get_change(h).unwrap();
716 1026
        if c.author == key_b {
717 -
            origin.change_allowed(h, &origin.current_policy().unwrap().unwrap().1.policy, "main").unwrap();
1027 +
            origin
1028 +
                .change_allowed(
1029 +
                    h,
1030 +
                    &origin.current_policy().unwrap().unwrap().1.policy,
1031 +
                    "main",
1032 +
                )
1033 +
                .unwrap();
718 1034
        }
719 1035
    }
720 1036
…
3 unchanged lines
724 1040
    forged.roots = vec![key_b];
725 1041
    let fh = forged.hash();
726 1042
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
727 -
    let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)] };
728 -
    let r = origin.receive_push(Bundle::default(), vec![], Some(forged), vec![]).unwrap();
1043 +
    let forged = SignedPolicy {
1044 +
        policy: forged,
1045 +
        sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)],
1046 +
    };
1047 +
    let r = origin
1048 +
        .receive_push(Bundle::default(), vec![], Some(forged), vec![])
1049 +
        .unwrap();
729 1050
    assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r);
730 1051
    let stale = a.current_policy().unwrap().unwrap().1;
731 1052
    let mut old = stale.clone();
732 1053
    old.policy.serial = 1;
733 -
    let r = origin.receive_push(Bundle::default(), vec![], Some(old), vec![]).unwrap();
1054 +
    let r = origin
1055 +
        .receive_push(Bundle::default(), vec![], Some(old), vec![])
1056 +
        .unwrap();
734 1057
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r);
735 1058
736 1059
    // A revokes B; after B pulls the policy, B's pushes are refused again.
…
8 unchanged lines
745 1068
    assert!(!r[0].ok, "{:?}", r);
746 1069
747 1070
    // A clone by a third party receives and verifies everything.
748 -
    let c = Repo::clone(&url, &root.join("c")).unwrap().with_home(root.join("home-c"));
1071 +
    let c = Repo::clone(&url, &root.join("c"))
1072 +
        .unwrap()
1073 +
        .with_home(root.join("home-c"));
749 1074
    assert_eq!(c.current_policy().unwrap().unwrap().1.policy.serial, 3);
750 -
    assert!(c.current_policy().unwrap().unwrap().1.policy.roots.contains(&key_a));
1075 +
    assert!(c
1076 +
        .current_policy()
1077 +
        .unwrap()
1078 +
        .unwrap()
1079 +
        .1
1080 +
        .policy
1081 +
        .roots
1082 +
        .contains(&key_a));
751 1083
    assert!(!c.snapshot("status").unwrap().changed);
752 1084
}
753 1085
…
3 unchanged lines
757 1089
    let dir = tempfile::tempdir().unwrap();
758 1090
    let root = dir.path();
759 1091
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
760 -
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
1092 +
    repo.create_identity("Ada", "a@b", IdentityScope::User)
1093 +
        .unwrap();
761 1094
    repo.set_user_config("core.sign", "change").unwrap();
762 1095
    write(root, "a.txt", b"one\n");
763 1096
    let h1 = repo.commit("one", &[]).unwrap();
…
32 unchanged lines
796 1129
    }
797 1130
    // Everything still works from the pruned, packed store.
798 1131
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
799 -
    assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { .. }));
1132 +
    assert!(matches!(
1133 +
        repo.verify_change(&h2).unwrap(),
1134 +
        SignatureStatus::Valid { .. }
1135 +
    ));
800 1136
    assert_eq!(repo.log().unwrap().len(), 3);
801 1137
    repo.checkout(&[h1], false, "checkout").unwrap();
802 1138
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
…
17 unchanged lines
820 1156
    let dir = tempfile::tempdir().unwrap();
821 1157
    let root = dir.path();
822 1158
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
823 -
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
1159 +
    repo.create_identity("Ada", "a@b", IdentityScope::User)
1160 +
        .unwrap();
824 1161
    repo.set_user_config("core.sign", "change").unwrap();
825 1162
    write(root, "a.txt", b"one\n");
826 1163
    let h1 = repo.commit("first", &[]).unwrap();
…
5 unchanged lines
832 1169
    let h2b = repo.describe(&h2, "second").unwrap();
833 1170
    assert_ne!(h2b, h2);
834 1171
    let c = repo.store().get_change(&h2b).unwrap();
835 -
    assert_eq!((c.change_id, c.message.as_str(), c.deps.clone()), (id2, "second", vec![h1]));
1172 +
    assert_eq!(
1173 +
        (c.change_id, c.message.as_str(), c.deps.clone()),
1174 +
        (id2, "second", vec![h1])
1175 +
    );
836 1176
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2b]);
837 -
    assert!(matches!(repo.verify_change(&h2b).unwrap(), SignatureStatus::Valid { .. }));
1177 +
    assert!(matches!(
1178 +
        repo.verify_change(&h2b).unwrap(),
1179 +
        SignatureStatus::Valid { .. }
1180 +
    ));
838 1181
    let s = repo.snapshot("status").unwrap();
839 1182
    assert!(s.statuses.is_empty(), "{:?}", s.statuses);
840 -
    assert_eq!(repo.resolve_change(&id2.short(8)).unwrap(), h2b, "the old object is no longer reachable from the log");
1183 +
    assert_eq!(
1184 +
        repo.resolve_change(&id2.short(8)).unwrap(),
1185 +
        h2b,
1186 +
        "the old object is no longer reachable from the log"
1187 +
    );
841 1188
    // Describing a non-head rewrites its dependents (h2b → h2c') and the working copy.
842 1189
    let h1b = repo.describe(&h1, "first, described").unwrap();
843 1190
    assert_ne!(h1b, h1);
…
1 unchanged lines
845 1192
    assert_eq!(heads.len(), 1);
846 1193
    let top = repo.store().get_change(&heads[0]).unwrap();
847 1194
    assert_eq!((top.change_id, top.deps.clone()), (id2, vec![h1b]));
848 -
    assert!(matches!(repo.verify_change(&heads[0]).unwrap(), SignatureStatus::Valid { .. }), "rewritten own change is re-signed");
1195 +
    assert!(
1196 +
        matches!(
1197 +
            repo.verify_change(&heads[0]).unwrap(),
1198 +
            SignatureStatus::Valid { .. }
1199 +
        ),
1200 +
        "rewritten own change is re-signed"
1201 +
    );
849 1202
    let (_, wc) = repo.working_copy().unwrap();
850 -
    assert_eq!(wc.deps, vec![heads[0]], "the working copy follows the rewrite");
1203 +
    assert_eq!(
1204 +
        wc.deps,
1205 +
        vec![heads[0]],
1206 +
        "the working copy follows the rewrite"
1207 +
    );
851 1208
    assert!(!repo.snapshot("status").unwrap().changed);
852 1209
    let snap = repo.load_state(&heads).unwrap().materialize();
853 -
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"));
1210 +
    assert!(
1211 +
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n")
1212 +
    );
854 1213
855 1214
    // amend: fold an edit into the head; the result is one change from h1.
856 1215
    write(root, "a.txt", b"one\ntwo\nthree\n");
…
3 unchanged lines
860 1219
    assert_eq!(c.change_id, id2);
861 1220
    assert_eq!(c.message, "second");
862 1221
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2c]);
863 -
    assert_eq!(repo.log().unwrap().len(), 3, "working copy, amended head, first");
1222 +
    assert_eq!(
1223 +
        repo.log().unwrap().len(),
1224 +
        3,
1225 +
        "working copy, amended head, first"
1226 +
    );
864 1227
    let snap = repo.load_state(&[h2c]).unwrap().materialize();
865 -
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n"));
1228 +
    assert!(
1229 +
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\nthree\n")
1230 +
    );
866 1231
    assert!(snap.files.contains_key("b.txt"));
867 1232
    assert!(!repo.snapshot("status").unwrap().changed);
868 -
    assert!(repo.amend(None).is_err(), "clean working copy and no message");
1233 +
    assert!(
1234 +
        repo.amend(None).is_err(),
1235 +
        "clean working copy and no message"
1236 +
    );
869 1237
    let h2d = repo.amend(Some("second, amended")).unwrap();
870 -
    assert_eq!(repo.store().get_change(&h2d).unwrap().message, "second, amended");
1238 +
    assert_eq!(
1239 +
        repo.store().get_change(&h2d).unwrap().message,
1240 +
        "second, amended"
1241 +
    );
871 1242
872 1243
    // abandon: the head goes, its content stays as uncommitted work.
873 1244
    repo.abandon(&h2d).unwrap();
874 -
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1b], "the described first change is the head again");
1245 +
    assert_eq!(
1246 +
        repo.bookmark_heads().unwrap(),
1247 +
        vec![h1b],
1248 +
        "the described first change is the head again"
1249 +
    );
875 1250
    let s = repo.snapshot("status").unwrap();
876 -
    let paths: Vec<_> = s.statuses.iter().map(|p| (p.path.as_str(), p.status)).collect();
877 -
    assert_eq!(paths, vec![("a.txt", ChangeStatus::Modified), ("b.txt", ChangeStatus::Added)]);
1251 +
    let paths: Vec<_> = s
1252 +
        .statuses
1253 +
        .iter()
1254 +
        .map(|p| (p.path.as_str(), p.status))
1255 +
        .collect();
1256 +
    assert_eq!(
1257 +
        paths,
1258 +
        vec![
1259 +
            ("a.txt", ChangeStatus::Modified),
1260 +
            ("b.txt", ChangeStatus::Added)
1261 +
        ]
1262 +
    );
878 1263
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\ntwo\nthree\n");
879 1264
    let h3 = repo.commit("recommitted", &[]).unwrap();
880 1265
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h3]);
…
9 unchanged lines
890 1275
    let base_change = repo.store().get_change(&base).unwrap();
891 1276
    let h1_now = base_change.deps[0];
892 1277
    let notes = repo.abandon(&h1_now).unwrap();
893 -
    assert!(!notes.is_empty(), "the dependent's edit of a.txt was reified");
1278 +
    assert!(
1279 +
        !notes.is_empty(),
1280 +
        "the dependent's edit of a.txt was reified"
1281 +
    );
894 1282
    let heads = repo.bookmark_heads().unwrap();
895 1283
    let after = repo.load_state(&heads).unwrap().materialize();
896 -
    assert!(!after.files.contains_key("a.txt") && after.files.contains_key("b.txt"), "{:?}", after.files.keys());
1284 +
    assert!(
1285 +
        !after.files.contains_key("a.txt") && after.files.contains_key("b.txt"),
1286 +
        "{:?}",
1287 +
        after.files.keys()
1288 +
    );
897 1289
    repo.undo().unwrap();
898 1290
    assert_eq!(repo.bookmark_heads().unwrap(), vec![base]);
899 1291
    write(root, "c.txt", b"c\n");
…
4 unchanged lines
904 1296
    repo.abandon(&hc).unwrap();
905 1297
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
906 1298
    assert!(!heads.contains(&hc));
907 -
    let d_now = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "d").copied().unwrap();
908 -
    assert!(repo.store().get_change(&d_now).unwrap().deps.is_empty(), "d inherited c's (empty) dependencies");
1299 +
    let d_now = heads
1300 +
        .iter()
1301 +
        .find(|h| repo.store().get_change(h).unwrap().message == "d")
1302 +
        .copied()
1303 +
        .unwrap();
1304 +
    assert!(
1305 +
        repo.store().get_change(&d_now).unwrap().deps.is_empty(),
1306 +
        "d inherited c's (empty) dependencies"
1307 +
    );
909 1308
    let s = repo.snapshot("status").unwrap();
910 -
    assert_eq!(s.statuses.iter().map(|p| p.path.as_str()).collect::<Vec<_>>(), vec!["c.txt"], "c's content is now uncommitted work");
1309 +
    assert_eq!(
1310 +
        s.statuses
1311 +
            .iter()
1312 +
            .map(|p| p.path.as_str())
1313 +
            .collect::<Vec<_>>(),
1314 +
        vec!["c.txt"],
1315 +
        "c's content is now uncommitted work"
1316 +
    );
911 1317
}
912 1318
913 1319
#[test]
914 1320
fn git_export_mirrors_history() {
915 1321
    hermetic();
916 -
    if std::process::Command::new("git").arg("--version").output().is_err() {
1322 +
    if std::process::Command::new("git")
1323 +
        .arg("--version")
1324 +
        .output()
1325 +
        .is_err()
1326 +
    {
917 1327
        eprintln!("git not installed; skipping");
918 1328
        return;
919 1329
    }
920 1330
    let dir = tempfile::tempdir().unwrap();
921 1331
    let root = dir.path();
922 -
    let mut repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home"));
923 -
    repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User).unwrap();
1332 +
    let mut repo = Repo::init(&root.join("r"))
1333 +
        .unwrap()
1334 +
        .with_home(root.join("home"));
1335 +
    repo.create_identity("Ada Lovelace", "[email protected]", IdentityScope::User)
1336 +
        .unwrap();
924 1337
    let r = root.join("r");
925 1338
    write(&r, "README.md", b"hello\n");
926 1339
    write(&r, "src/main.rs", b"fn main() {}\n");
…
6 unchanged lines
933 1346
    let rep = repo.git_export(&git_dir).unwrap();
934 1347
    assert_eq!((rep.exported, rep.skipped, rep.heads), (2, 0, 1));
935 1348
    let git = |args: &[&str]| -> String {
936 -
        let out = std::process::Command::new("git").arg("-C").arg(&git_dir).args(args).output().unwrap();
937 -
        assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
1349 +
        let out = std::process::Command::new("git")
1350 +
            .arg("-C")
1351 +
            .arg(&git_dir)
1352 +
            .args(args)
1353 +
            .output()
1354 +
            .unwrap();
1355 +
        assert!(
1356 +
            out.status.success(),
1357 +
            "{}",
1358 +
            String::from_utf8_lossy(&out.stderr)
1359 +
        );
938 1360
        String::from_utf8_lossy(&out.stdout).trim().to_string()
939 1361
    };
940 1362
    assert_eq!(git(&["log", "--format=%s", "main"]), "second\nfirst");
941 1363
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
942 1364
    assert_eq!(git(&["show", "main:README.md"]), "hello");
943 1365
    let body = git(&["log", "-1", "--format=%B", "main"]);
944 -
    assert!(body.contains(&format!("Oxy-Hash: {}", h2.to_hex())), "{body}");
1366 +
    assert!(
1367 +
        body.contains(&format!("Oxy-Hash: {}", h2.to_hex())),
1368 +
        "{body}"
1369 +
    );
945 1370
    assert!(body.contains("Change-Id: "), "{body}");
946 -
    assert_eq!(git(&["log", "-1", "--format=%an <%ae>", "main"]), "Ada Lovelace <[email protected]>");
947 -
    assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nsrc");
1371 +
    assert_eq!(
1372 +
        git(&["log", "-1", "--format=%an <%ae>", "main"]),
1373 +
        "Ada Lovelace <[email protected]>"
1374 +
    );
1375 +
    assert_eq!(
1376 +
        git(&["ls-tree", "--name-only", "main"]),
1377 +
        "README.md\nlogo.png\nsrc"
1378 +
    );
948 1379
949 1380
    // Re-export is incremental.
950 1381
    let rep = repo.git_export(&git_dir).unwrap();
…
11 unchanged lines
962 1393
    assert_eq!(parents.split(' ').count(), 2, "one parent: {parents}");
963 1394
    assert_eq!(git(&["show", "main:other.txt"]), "o");
964 1395
    assert_eq!(git(&["show", "main:src/main.rs"]), "fn main() { run(); }");
965 -
    assert_eq!(git(&["ls-tree", "--name-only", "main"]), "README.md\nlogo.png\nother.txt\nsrc");
1396 +
    assert_eq!(
1397 +
        git(&["ls-tree", "--name-only", "main"]),
1398 +
        "README.md\nlogo.png\nother.txt\nsrc"
1399 +
    );
966 1400
    let body = git(&["log", "-1", "--format=%B", "main"]);
967 -
    assert!(body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "), "{body}");
1401 +
    assert!(
1402 +
        body.contains(&format!("Oxy-Hash: {}", h3.to_hex())) && body.contains("Oxy-State: "),
1403 +
        "{body}"
1404 +
    );
968 1405
    // A rewrite is a new state whose one new change is the rewritten one.
969 1406
    repo.describe(&h3, "third, described").unwrap();
970 1407
    let rep = repo.git_export(&git_dir).unwrap();
971 1408
    assert_eq!(rep.exported, 1);
972 -
    assert_eq!(git(&["log", "-1", "--format=%s", "main"]), "third, described");
1409 +
    assert_eq!(
1410 +
        git(&["log", "-1", "--format=%s", "main"]),
1411 +
        "third, described"
1412 +
    );
973 1413
    assert_eq!(git(&["log", "--format=%s", "main"]).lines().count(), 4);
974 1414
    let _ = h2;
975 1415
}
…
1 unchanged lines
977 1417
#[test]
978 1418
fn git_import_reproduces_trees() {
979 1419
    hermetic();
980 -
    if std::process::Command::new("git").arg("--version").output().is_err() {
1420 +
    if std::process::Command::new("git")
1421 +
        .arg("--version")
1422 +
        .output()
1423 +
        .is_err()
1424 +
    {
981 1425
        eprintln!("git not installed; skipping");
982 1426
        return;
983 1427
    }
…
5 unchanged lines
989 1433
        let out = std::process::Command::new("git")
990 1434
            .arg("-C")
991 1435
            .arg(&g)
992 -
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
1436 +
            .args([
1437 +
                "-c",
1438 +
                "user.name=Grace",
1439 +
                "-c",
1440 +
                "[email protected]",
1441 +
                "-c",
1442 +
                "commit.gpgsign=false",
1443 +
            ])
993 1444
            .args(args)
994 1445
            .output()
995 1446
            .unwrap();
996 -
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1447 +
        assert!(
1448 +
            out.status.success(),
1449 +
            "git {args:?}: {}",
1450 +
            String::from_utf8_lossy(&out.stderr)
1451 +
        );
997 1452
        String::from_utf8_lossy(&out.stdout).trim().to_string()
998 1453
    };
999 1454
    git(&["init", "-q", "-b", "main"]);
…
10 unchanged lines
1010 1465
    }
1011 1466
    write(&g, "logo.png", b"\x89PNG\0\0\x01");
1012 1467
    git(&["add", "."]);
1013 -
    git(&["commit", "-q", "-m", "second\n\nChange-Id: 0123456789abcdef0123456789abcdef"]);
1468 +
    git(&[
1469 +
        "commit",
1470 +
        "-q",
1471 +
        "-m",
1472 +
        "second\n\nChange-Id: 0123456789abcdef0123456789abcdef",
1473 +
    ]);
1014 1474
    // A branch that edits README, merged back with a conflict-free merge.
1015 1475
    git(&["checkout", "-q", "-b", "topic", "HEAD~1"]);
1016 1476
    write(&g, "README.md", b"# Hi\n\nMore.\n");
…
7 unchanged lines
1024 1484
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn c() {}\n");
1025 1485
    git(&["commit", "-q", "-am", "clash"]);
1026 1486
    git(&["checkout", "-q", "main"]);
1027 -
    let _ = std::process::Command::new("git").arg("-C").arg(&g).args(["merge", "-q", "clash"]).output();
1028 -
    write(&g, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
1487 +
    let _ = std::process::Command::new("git")
1488 +
        .arg("-C")
1489 +
        .arg(&g)
1490 +
        .args(["merge", "-q", "clash"])
1491 +
        .output();
1492 +
    write(
1493 +
        &g,
1494 +
        "src/lib.rs",
1495 +
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n",
1496 +
    );
1029 1497
    git(&["add", "."]);
1030 1498
    git(&["commit", "-q", "-m", "resolve clash"]);
1031 1499
    git(&["rm", "-q", "logo.png"]);
1032 1500
    git(&["commit", "-q", "-m", "drop logo"]);
1033 1501
    // A rename with a small edit is imported as a move of the same node.
1034 1502
    git(&["mv", "src/lib.rs", "src/lib2.rs"]);
1035 -
    write(&g, "src/lib2.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n");
1503 +
    write(
1504 +
        &g,
1505 +
        "src/lib2.rs",
1506 +
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n",
1507 +
    );
1036 1508
    git(&["add", "."]);
1037 1509
    git(&["commit", "-q", "-m", "rename lib"]);
1038 1510
    let expected_count: usize = git(&["rev-list", "--count", "main"]).parse().unwrap();
…
4 unchanged lines
1043 1515
    assert_eq!(rep.commits, expected_count);
1044 1516
    assert_eq!(rep.merges, 2);
1045 1517
    // The working copy equals the Git tree at main.
1046 -
    assert_eq!(fs::read(dest.join("src/lib2.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n");
1518 +
    assert_eq!(
1519 +
        fs::read(dest.join("src/lib2.rs")).unwrap(),
1520 +
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"
1521 +
    );
1047 1522
    assert!(!dest.join("src/lib.rs").exists());
1048 -
    assert_eq!(fs::read(dest.join("README.md")).unwrap(), b"# Hi\n\nMore.\n");
1523 +
    assert_eq!(
1524 +
        fs::read(dest.join("README.md")).unwrap(),
1525 +
        b"# Hi\n\nMore.\n"
1526 +
    );
1049 1527
    assert!(!dest.join("logo.png").exists());
1050 1528
    #[cfg(unix)]
1051 1529
    {
1052 1530
        use std::os::unix::fs::PermissionsExt;
1053 -
        assert!(fs::metadata(dest.join("run.sh")).unwrap().permissions().mode() & 0o111 != 0);
1531 +
        assert!(
1532 +
            fs::metadata(dest.join("run.sh"))
1533 +
                .unwrap()
1534 +
                .permissions()
1535 +
                .mode()
1536 +
                & 0o111
1537 +
                != 0
1538 +
        );
1054 1539
    }
1055 -
    assert!(!repo.snapshot("status").unwrap().changed, "clean after import");
1540 +
    assert!(
1541 +
        !repo.snapshot("status").unwrap().changed,
1542 +
        "clean after import"
1543 +
    );
1056 1544
    let heads = repo.bookmark_heads().unwrap();
1057 1545
    let st = repo.load_state(&heads).unwrap();
1058 1546
    assert!(!st.materialize().is_conflicted());
1059 1547
    assert_eq!(st.len(), expected_count);
1060 -
    let tip = repo.log().unwrap().into_iter().find(|e| e.change.message.starts_with("rename lib")).unwrap();
1548 +
    let tip = repo
1549 +
        .log()
1550 +
        .unwrap()
1551 +
        .into_iter()
1552 +
        .find(|e| e.change.message.starts_with("rename lib"))
1553 +
        .unwrap();
1061 1554
    let c = repo.store().get_change(&tip.hash).unwrap();
1062 -
    assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")), "{:?}", c.tree_ops);
1063 -
    assert!(!c.tree_ops.iter().any(|op| matches!(op, TreeOp::AddNode { .. })));
1555 +
    assert!(
1556 +
        c.tree_ops
1557 +
            .iter()
1558 +
            .any(|op| matches!(op, TreeOp::Move { name, .. } if name == "lib2.rs")),
1559 +
        "{:?}",
1560 +
        c.tree_ops
1561 +
    );
1562 +
    assert!(!c
1563 +
        .tree_ops
1564 +
        .iter()
1565 +
        .any(|op| matches!(op, TreeOp::AddNode { .. })));
1064 1566
    // Messages carry Git trailers; the Change-Id trailer was honoured.
1065 1567
    let log = repo.log().unwrap();
1066 -
    let second = log.iter().find(|e| e.change.message.starts_with("second")).unwrap();
1067 -
    assert_eq!(second.change.change_id, ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap());
1068 -
    assert!(second.change.message.contains("Git-Author: Grace <[email protected]>"));
1069 -
    let resolve = log.iter().find(|e| e.change.message.starts_with("resolve clash")).unwrap();
1070 -
    assert_eq!(resolve.change.kind, ChangeKind::Resolution, "a conflicting Git merge becomes a resolution");
1568 +
    let second = log
1569 +
        .iter()
1570 +
        .find(|e| e.change.message.starts_with("second"))
1571 +
        .unwrap();
1572 +
    assert_eq!(
1573 +
        second.change.change_id,
1574 +
        ChangeId::from_hex("0123456789abcdef0123456789abcdef").unwrap()
1575 +
    );
1576 +
    assert!(second
1577 +
        .change
1578 +
        .message
1579 +
        .contains("Git-Author: Grace <[email protected]>"));
1580 +
    let resolve = log
1581 +
        .iter()
1582 +
        .find(|e| e.change.message.starts_with("resolve clash"))
1583 +
        .unwrap();
1584 +
    assert_eq!(
1585 +
        resolve.change.kind,
1586 +
        ChangeKind::Resolution,
1587 +
        "a conflicting Git merge becomes a resolution"
1588 +
    );
1071 1589
    // Re-importing into another fresh repository gives the same ChangeIds.
1072 -
    let repo2 = Repo::init(&root.join("oxy2")).unwrap().with_home(root.join("home"));
1590 +
    let repo2 = Repo::init(&root.join("oxy2"))
1591 +
        .unwrap()
1592 +
        .with_home(root.join("home"));
1073 1593
    repo2.import_git(&g, "main").unwrap();
1074 -
    let ids: BTreeSet<ChangeId> = repo.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect();
1075 -
    let ids2: BTreeSet<ChangeId> = repo2.log().unwrap().iter().filter(|e| !e.is_working_copy).map(|e| e.change.change_id).collect();
1594 +
    let ids: BTreeSet<ChangeId> = repo
1595 +
        .log()
1596 +
        .unwrap()
1597 +
        .iter()
1598 +
        .filter(|e| !e.is_working_copy)
1599 +
        .map(|e| e.change.change_id)
1600 +
        .collect();
1601 +
    let ids2: BTreeSet<ChangeId> = repo2
1602 +
        .log()
1603 +
        .unwrap()
1604 +
        .iter()
1605 +
        .filter(|e| !e.is_working_copy)
1606 +
        .map(|e| e.change.change_id)
1607 +
        .collect();
1076 1608
    assert_eq!(ids, ids2);
1077 1609
    // Round trip back out to Git reproduces the tip tree.
1078 1610
    let mirror = root.join("mirror.git");
1079 1611
    repo.git_export(&mirror).unwrap();
1080 -
    let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib2.rs"]).output().unwrap();
1081 -
    assert_eq!(String::from_utf8_lossy(&out.stdout), "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n");
1082 -
    let out = std::process::Command::new("git").arg("-C").arg(&mirror).args(["show", "main:src/lib.rs"]).output().unwrap();
1083 -
    assert!(!out.status.success(), "the old path is gone from the export");
1612 +
    let out = std::process::Command::new("git")
1613 +
        .arg("-C")
1614 +
        .arg(&mirror)
1615 +
        .args(["show", "main:src/lib2.rs"])
1616 +
        .output()
1617 +
        .unwrap();
1618 +
    assert_eq!(
1619 +
        String::from_utf8_lossy(&out.stdout),
1620 +
        "pub fn a() {}\npub fn b() {}\npub fn c() {}\npub fn d() {}\n"
1621 +
    );
1622 +
    let out = std::process::Command::new("git")
1623 +
        .arg("-C")
1624 +
        .arg(&mirror)
1625 +
        .args(["show", "main:src/lib.rs"])
1626 +
        .output()
1627 +
        .unwrap();
1628 +
    assert!(
1629 +
        !out.status.success(),
1630 +
        "the old path is gone from the export"
1631 +
    );
1084 1632
}
1085 1633
1086 1634
#[test]
…
7 unchanged lines
1094 1642
    let repo = Repo::open(root).unwrap().with_home(root.join("home"));
1095 1643
    let mut hashes = Vec::new();
1096 1644
    for i in 0..12 {
1097 -
        write(root, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes());
1645 +
        write(
1646 +
            root,
1647 +
            "a.txt",
1648 +
            format!(
1649 +
                "{}\n",
1650 +
                (0..=i)
1651 +
                    .map(|k| k.to_string())
1652 +
                    .collect::<Vec<_>>()
1653 +
                    .join("\n")
1654 +
            )
1655 +
            .as_bytes(),
1656 +
        );
1098 1657
        write(root, &format!("f{i}.txt"), b"x\n");
1099 1658
        hashes.push(repo.commit(&format!("c{i}"), &[]).unwrap());
1100 1659
    }
1101 -
    let (_, cp) = repo.latest_checkpoint("main").unwrap().expect("automatic checkpoint");
1660 +
    let (_, cp) = repo
1661 +
        .latest_checkpoint("main")
1662 +
        .unwrap()
1663 +
        .expect("automatic checkpoint");
1102 1664
    assert!(cp.count >= 5 && cp.count <= 12, "{}", cp.count);
1103 1665
    // Loading through the checkpoint equals loading everything.
1104 1666
    let heads = repo.bookmark_heads().unwrap();
…
2 unchanged lines
1107 1669
    assert_eq!(fast.len(), full.len());
1108 1670
    assert_eq!(fast.heads(), full.heads());
1109 1671
    let (a, b) = (fast.materialize(), full.materialize());
1110 -
    assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>());
1672 +
    assert_eq!(
1673 +
        a.files.keys().collect::<Vec<_>>(),
1674 +
        b.files.keys().collect::<Vec<_>>()
1675 +
    );
1111 1676
    for (p, f) in &a.files {
1112 1677
        assert_eq!(f.content, b.files[p].content, "{p}");
1113 1678
    }
1114 1679
    assert!(repo.load_state_checkpointed(&heads).unwrap().is_some());
1115 1680
    // A head set that does not contain the checkpoint's heads falls back.
1116 -
    assert!(repo.load_state_checkpointed(&[hashes[0]]).unwrap().is_none());
1681 +
    assert!(repo
1682 +
        .load_state_checkpointed(&[hashes[0]])
1683 +
        .unwrap()
1684 +
        .is_none());
1117 1685
    assert_eq!(repo.load_state(&[hashes[0]]).unwrap().len(), 1);
1118 1686
    // Everything else keeps working on top of the checkpoint.
1119 1687
    assert!(!repo.snapshot("status").unwrap().changed);
…
6 unchanged lines
1126 1694
    let h_after = repo.commit("after", &[]).unwrap();
1127 1695
    let st = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap();
1128 1696
    assert!(st.contains(&h_after));
1129 -
    assert!(matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n"));
1697 +
    assert!(
1698 +
        matches!(&st.materialize().files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"after\n")
1699 +
    );
1130 1700
    assert_eq!(repo.log().unwrap().len(), 14);
1131 1701
    // Checkpoints survive gc.
1132 1702
    repo.gc(true).unwrap();
1133 1703
    assert!(repo.latest_checkpoint("main").unwrap().is_some());
1134 -
    assert!(repo.load_state_checkpointed(&repo.bookmark_heads().unwrap()).unwrap().is_some());
1704 +
    assert!(repo
1705 +
        .load_state_checkpointed(&repo.bookmark_heads().unwrap())
1706 +
        .unwrap()
1707 +
        .is_some());
1135 1708
}
1136 1709
1137 1710
#[test]
…
3 unchanged lines
1141 1714
    let root = dir.path();
1142 1715
    let origin = Repo::init(&root.join("origin")).unwrap();
1143 1716
    let url = root.join("origin").to_string_lossy().to_string();
1144 -
    let a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
1717 +
    let a = Repo::clone(&url, &root.join("a"))
1718 +
        .unwrap()
1719 +
        .with_home(root.join("home"));
1145 1720
    let ops_before = a.op_log().unwrap().len();
1146 1721
    let info = a.quick_status().unwrap();
1147 -
    assert_eq!((info.bookmark.as_str(), info.heads, info.dirty, info.conflicted, info.ahead, info.behind), ("main", 0, false, false, Some(0), Some(0)));
1722 +
    assert_eq!(
1723 +
        (
1724 +
            info.bookmark.as_str(),
1725 +
            info.heads,
1726 +
            info.dirty,
1727 +
            info.conflicted,
1728 +
            info.ahead,
1729 +
            info.behind
1730 +
        ),
1731 +
        ("main", 0, false, false, Some(0), Some(0))
1732 +
    );
1148 1733
    write(&root.join("a"), "f.txt", b"f\n");
1149 1734
    assert!(a.quick_status().unwrap().dirty, "a new file is dirty");
1150 -
    assert_eq!(a.op_log().unwrap().len(), ops_before, "quick_status records nothing");
1735 +
    assert_eq!(
1736 +
        a.op_log().unwrap().len(),
1737 +
        ops_before,
1738 +
        "quick_status records nothing"
1739 +
    );
1151 1740
    // A snapshotted but uncommitted change is still dirty.
1152 1741
    a.snapshot("status").unwrap();
1153 1742
    assert!(a.quick_status().unwrap().dirty);
…
1 unchanged lines
1155 1744
    let info = a.quick_status().unwrap();
1156 1745
    assert!(!info.dirty, "clean right after a commit");
1157 1746
    assert_eq!((info.ahead, info.behind), (Some(1), Some(0)));
1158 -
    assert_eq!(info.format("%b %c%d%x%A%D"), format!("main {}+1", info.change_id));
1747 +
    assert_eq!(
1748 +
        info.format("%b %c%d%x%A%D"),
1749 +
        format!("main {}+1", info.change_id)
1750 +
    );
1159 1751
    assert_eq!(info.format("[%h heads] %a/%B %%"), "[1 heads] 1/0 %");
1160 1752
    // Touching a file without changing it is not dirty; changing content of
1161 1753
    // the same size is.
…
7 unchanged lines
1169 1761
    // Push, then a second clone commits and pushes: we are behind.
1170 1762
    assert!(a.push("origin", false).unwrap()[0].ok);
1171 1763
    assert_eq!(a.quick_status().unwrap().ahead, Some(0));
1172 -
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
1764 +
    let b = Repo::clone(&url, &root.join("b"))
1765 +
        .unwrap()
1766 +
        .with_home(root.join("home-b"));
1173 1767
    write(&root.join("b"), "g.txt", b"g\n");
1174 1768
    b.commit("g", &[]).unwrap();
1175 1769
    b.push("origin", false).unwrap();
…
10 unchanged lines
1186 1780
    let dir = tempfile::tempdir().unwrap();
1187 1781
    let root = dir.path();
1188 1782
    let origin_dir = root.join("origin");
1189 -
    let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home"));
1783 +
    let origin = Repo::init(&origin_dir)
1784 +
        .unwrap()
1785 +
        .with_home(root.join("home"));
1190 1786
    let url = origin_dir.to_string_lossy().to_string();
1191 1787
    let big: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect(); // has NUL bytes: opaque
1192 1788
    write(&origin_dir, "big.bin", &big);
…
3 unchanged lines
1196 1792
    let big_ref = Repo::blob_ref(&big);
1197 1793
1198 1794
    // Fetch only (no checkout) with a filter: the big blob stays absent.
1199 -
    let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap()).unwrap().with_home(root.join("home"));
1795 +
    let a = Repo::init_with_id(&root.join("a"), origin.id().unwrap())
1796 +
        .unwrap()
1797 +
        .with_home(root.join("home"));
1200 1798
    let a = {
1201 1799
        let mut a = a;
1202 1800
        a.config.set("remote.origin.url", &url).unwrap();
…
5 unchanged lines
1208 1806
    a.fetch("origin").unwrap();
1209 1807
    assert!(a.store().has(&h));
1210 1808
    assert!(!a.store().has(&big_ref), "withheld");
1211 -
    assert!(a.store().has(&Repo::blob_ref(&[0u8, 1, 2])), "small blob came along");
1809 +
    assert!(
1810 +
        a.store().has(&Repo::blob_ref(&[0u8, 1, 2])),
1811 +
        "small blob came along"
1812 +
    );
1212 1813
    assert_eq!(a.lazy_remote(&big_ref).as_deref(), Some("origin"));
1213 1814
1214 1815
    // status and commit do not need it: adopt the state, edit a text file, commit.
1215 1816
    let (op_ref, op) = a.head_op().unwrap();
1216 1817
    let mut view = op.view.clone();
1217 -
    view.bookmarks.insert("main".into(), a.store().put_state(&StateObject::new(vec![h])).unwrap());
1818 +
    view.bookmarks.insert(
1819 +
        "main".into(),
1820 +
        a.store().put_state(&StateObject::new(vec![h])).unwrap(),
1821 +
    );
1218 1822
    a.record_op(&[op_ref], "adopt", view).unwrap();
1219 1823
    // Checkout reads the blob lazily, which fetches it.
1220 1824
    a.checkout(&[h], true, "checkout").unwrap();
…
17 unchanged lines
1238 1842
    let dir = tempfile::tempdir().unwrap();
1239 1843
    let root = dir.path();
1240 1844
    let origin_dir = root.join("origin");
1241 -
    let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home"));
1845 +
    let origin = Repo::init(&origin_dir)
1846 +
        .unwrap()
1847 +
        .with_home(root.join("home"));
1242 1848
    write(&origin_dir, "src/main.rs", b"fn main() {}\n");
1243 1849
    write(&origin_dir, "src/lib.rs", b"pub fn a() {}\n");
1244 1850
    write(&origin_dir, "docs/guide.md", b"# guide\n");
…
3 unchanged lines
1248 1854
    let h1 = origin.commit("tree", &[]).unwrap();
1249 1855
    let url = origin_dir.to_string_lossy().to_string();
1250 1856
1251 -
    let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()]).unwrap().with_home(root.join("home"));
1857 +
    let a = Repo::clone_with(&url, &root.join("a"), None, &["src".into()])
1858 +
        .unwrap()
1859 +
        .with_home(root.join("home"));
1252 1860
    assert_eq!(a.config.sparse_paths, vec!["src"]);
1253 1861
    assert_eq!(a.config.max_blob, Some(0), "sparse implies lazy blobs");
1254 1862
    assert!(root.join("a/src/main.rs").exists());
1255 1863
    assert!(!root.join("a/docs").exists() && !root.join("a/README.md").exists());
1256 -
    assert!(!a.store().has(&Repo::blob_ref(&logo)), "out-of-scope blob never fetched");
1257 -
    assert!(!a.snapshot("status").unwrap().changed, "out-of-scope files are not removals");
1864 +
    assert!(
1865 +
        !a.store().has(&Repo::blob_ref(&logo)),
1866 +
        "out-of-scope blob never fetched"
1867 +
    );
1868 +
    assert!(
1869 +
        !a.snapshot("status").unwrap().changed,
1870 +
        "out-of-scope files are not removals"
1871 +
    );
1258 1872
    assert_eq!(a.quick_status().unwrap().dirty, false);
1259 1873
1260 1874
    // Edits in scope commit normally and keep the rest of the tree intact.
1261 -
    write(&root.join("a"), "src/lib.rs", b"pub fn a() {}\npub fn b() {}\n");
1875 +
    write(
1876 +
        &root.join("a"),
1877 +
        "src/lib.rs",
1878 +
        b"pub fn a() {}\npub fn b() {}\n",
1879 +
    );
1262 1880
    let h2 = a.commit("edit lib", &[]).unwrap();
1263 1881
    let st = a.load_state(&[h2]).unwrap().materialize();
1264 -
    assert!(st.files.contains_key("docs/guide.md"), "the state still has everything");
1882 +
    assert!(
1883 +
        st.files.contains_key("docs/guide.md"),
1884 +
        "the state still has everything"
1885 +
    );
1265 1886
    assert!(a.push("origin", false).unwrap()[0].ok);
1266 1887
    // Files created outside the scope are ignored by status.
1267 1888
    write(&root.join("a"), "docs/new.md", b"ignored\n");
…
3 unchanged lines
1271 1892
    let mut a = a;
1272 1893
    a.config.set("filter.paths", "src,docs").unwrap();
1273 1894
    a.save_config().unwrap();
1274 -
    let r = a.checkout(&a.bookmark_heads().unwrap(), true, "widen").unwrap();
1895 +
    let r = a
1896 +
        .checkout(&a.bookmark_heads().unwrap(), true, "widen")
1897 +
        .unwrap();
1275 1898
    assert!(r.written >= 2);
1276 -
    assert_eq!(fs::read(root.join("a/docs/logo.png")).unwrap(), logo, "now fetched lazily");
1899 +
    assert_eq!(
1900 +
        fs::read(root.join("a/docs/logo.png")).unwrap(),
1901 +
        logo,
1902 +
        "now fetched lazily"
1903 +
    );
1277 1904
    assert!(!root.join("a/README.md").exists());
1278 1905
    // Origin sees the change made under a sparse checkout with the full tree intact.
1279 1906
    origin.pull("origin").err();
1280 -
    let full = origin.load_state(&origin.bookmark_heads().unwrap()).unwrap().materialize();
1281 -
    assert!(matches!(&full.files["src/lib.rs"].content, FileContent::Text { bytes, .. } if bytes == b"pub fn a() {}\npub fn b() {}\n"));
1907 +
    let full = origin
1908 +
        .load_state(&origin.bookmark_heads().unwrap())
1909 +
        .unwrap()
1910 +
        .materialize();
1911 +
    assert!(
1912 +
        matches!(&full.files["src/lib.rs"].content, FileContent::Text { bytes, .. } if bytes == b"pub fn a() {}\npub fn b() {}\n")
1913 +
    );
1282 1914
    assert!(full.files.contains_key("README.md"));
1283 1915
    let _ = h1;
1284 1916
}
…
4 unchanged lines
1289 1921
    let dir = tempfile::tempdir().unwrap();
1290 1922
    let root = dir.path();
1291 1923
    let origin_dir = root.join("origin");
1292 -
    let origin = Repo::init(&origin_dir).unwrap().with_home(root.join("home"));
1924 +
    let origin = Repo::init(&origin_dir)
1925 +
        .unwrap()
1926 +
        .with_home(root.join("home"));
1293 1927
    let mut hashes = Vec::new();
1294 1928
    for i in 0..8 {
1295 -
        write(&origin_dir, "a.txt", format!("{}\n", (0..=i).map(|k| k.to_string()).collect::<Vec<_>>().join("\n")).as_bytes());
1929 +
        write(
1930 +
            &origin_dir,
1931 +
            "a.txt",
1932 +
            format!(
1933 +
                "{}\n",
1934 +
                (0..=i)
1935 +
                    .map(|k| k.to_string())
1936 +
                    .collect::<Vec<_>>()
1937 +
                    .join("\n")
1938 +
            )
1939 +
            .as_bytes(),
1940 +
        );
1296 1941
        hashes.push(origin.commit(&format!("c{i}"), &[]).unwrap());
1297 1942
    }
1298 1943
    let url = origin_dir.to_string_lossy().to_string();
1299 -
    let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[]).unwrap().with_home(root.join("home"));
1944 +
    let a = Repo::clone_shallow(&url, &root.join("a"), 3, None, &[])
1945 +
        .unwrap()
1946 +
        .with_home(root.join("home"));
1300 1947
    assert!(a.is_shallow());
1301 -
    assert_eq!(fs::read_to_string(root.join("a/a.txt")).unwrap(), "0\n1\n2\n3\n4\n5\n6\n7\n");
1302 -
    assert!(a.store().has(&hashes[7]) && a.store().has(&hashes[5]), "three levels present");
1948 +
    assert_eq!(
1949 +
        fs::read_to_string(root.join("a/a.txt")).unwrap(),
1950 +
        "0\n1\n2\n3\n4\n5\n6\n7\n"
1951 +
    );
1952 +
    assert!(
1953 +
        a.store().has(&hashes[7]) && a.store().has(&hashes[5]),
1954 +
        "three levels present"
1955 +
    );
1303 1956
    assert!(!a.store().has(&hashes[4]), "beyond the depth is absent");
1304 1957
    assert_eq!(a.boundary(), [hashes[4]].into_iter().collect());
1305 -
    assert_eq!(a.log().unwrap().len(), 9, "the log shows every change from the checkpoint's metadata");
1958 +
    assert_eq!(
1959 +
        a.log().unwrap().len(),
1960 +
        9,
1961 +
        "the log shows every change from the checkpoint's metadata"
1962 +
    );
1306 1963
    assert!(!a.snapshot("status").unwrap().changed);
1307 1964
    assert_eq!(a.quick_status().unwrap().heads, 1);
1308 1965
…
7 unchanged lines
1316 1973
    origin.pull("origin").err();
1317 1974
    let (_, wc) = origin.working_copy().unwrap();
1318 1975
    let _ = wc;
1319 -
    origin.checkout(&origin.bookmark_heads().unwrap(), true, "co").unwrap();
1976 +
    origin
1977 +
        .checkout(&origin.bookmark_heads().unwrap(), true, "co")
1978 +
        .unwrap();
1320 1979
    write(&origin_dir, "b.txt", b"b\n");
1321 1980
    let h_b = origin.commit("b", &[]).unwrap();
1322 1981
    let pr = a.pull("origin").unwrap();
…
10 unchanged lines
1333 1992
    a.deepen("origin", 100).unwrap();
1334 1993
    assert!(!a.is_shallow(), "boundary reached the root");
1335 1994
    assert!(a.store().has(&hashes[0]));
1336 -
    assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10);
1995 +
    assert_eq!(
1996 +
        a.load_state_full(&a.bookmark_heads().unwrap())
1997 +
            .unwrap()
1998 +
            .len(),
1999 +
        10
2000 +
    );
1337 2001
}
1338 2002
1339 2003
#[test]
…
3 unchanged lines
1343 2007
    let root = dir.path();
1344 2008
    let origin = Repo::init(&root.join("origin")).unwrap();
1345 2009
    let url = root.join("origin").to_string_lossy().to_string();
1346 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a"));
1347 -
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
2010 +
    let mut a = Repo::clone(&url, &root.join("a"))
2011 +
        .unwrap()
2012 +
        .with_home(root.join("home-a"));
2013 +
    let (key_a, _) = a
2014 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
2015 +
        .unwrap();
1348 2016
    write(&root.join("a"), "f.txt", b"1\n");
1349 2017
    let h1 = a.commit("one", &[]).unwrap();
1350 2018
    write(&root.join("a"), "f.txt", b"1\n2\n");
1351 2019
    let h2 = a.commit("two", &[]).unwrap();
1352 2020
1353 2021
    let t = a.tag_create("v1", &[h1], "first release").unwrap();
1354 -
    assert_eq!((t.heads.clone(), t.signer.as_deref()), (vec![h1], Some("Ada")));
2022 +
    assert_eq!(
2023 +
        (t.heads.clone(), t.signer.as_deref()),
2024 +
        (vec![h1], Some("Ada"))
2025 +
    );
1355 2026
    assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable");
1356 2027
    assert!(a.tag_create("bad/name", &[h2], "").is_err());
1357 2028
    assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1]));
…
2 unchanged lines
1360 2031
    let (_, sa) = a.tag_attest("v1").unwrap().unwrap();
1361 2032
    assert_eq!(sa.sigs[0].key, key_a);
1362 2033
    // Checkout by tag.
1363 -
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co").unwrap();
2034 +
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co")
2035 +
        .unwrap();
1364 2036
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n");
1365 -
    a.checkout(&a.bookmark_heads().unwrap(), false, "co").unwrap();
2037 +
    a.checkout(&a.bookmark_heads().unwrap(), false, "co")
2038 +
        .unwrap();
1366 2039
1367 2040
    // Push carries the tag; a second attestation for the name is refused.
1368 2041
    let r = a.push("origin", false).unwrap();
1369 2042
    assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}");
1370 2043
    assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1]));
1371 -
    let mut forged = a.tag_refs().unwrap().into_iter().find(|t| t.name == "v1").unwrap();
1372 -
    forged.state = origin.store().put_state(&StateObject::new(vec![h2])).unwrap();
2044 +
    let mut forged = a
2045 +
        .tag_refs()
2046 +
        .unwrap()
2047 +
        .into_iter()
2048 +
        .find(|t| t.name == "v1")
2049 +
        .unwrap();
2050 +
    forged.state = origin
2051 +
        .store()
2052 +
        .put_state(&StateObject::new(vec![h2]))
2053 +
        .unwrap();
1373 2054
    forged.heads = vec![h2];
1374 2055
    forged.attestation = None;
1375 -
    let r = origin.receive_push(Bundle::default(), vec![], None, vec![forged]).unwrap();
2056 +
    let r = origin
2057 +
        .receive_push(Bundle::default(), vec![], None, vec![forged])
2058 +
        .unwrap();
1376 2059
    assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}");
1377 2060
1378 2061
    // A clone gets the tag; a later tag arrives on fetch.
1379 -
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
2062 +
    let b = Repo::clone(&url, &root.join("b"))
2063 +
        .unwrap()
2064 +
        .with_home(root.join("home-b"));
1380 2065
    assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1]));
1381 2066
    assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada"));
1382 2067
    a.tag_create("v2", &[h2], "second").unwrap();
…
5 unchanged lines
1388 2073
    // Under a policy, an unattested tag is refused and an attested one by the root passes.
1389 2074
    a.policy_init().unwrap();
1390 2075
    a.push("origin", false).unwrap();
1391 -
    let unattested = crate::protocol::TagRef { name: "v3".into(), state: origin.store().put_state(&StateObject::new(vec![h2])).unwrap(), heads: vec![h2], attestation: None };
1392 -
    let r = origin.receive_push(Bundle::default(), vec![], None, vec![unattested]).unwrap();
2076 +
    let unattested = crate::protocol::TagRef {
2077 +
        name: "v3".into(),
2078 +
        state: origin
2079 +
            .store()
2080 +
            .put_state(&StateObject::new(vec![h2]))
2081 +
            .unwrap(),
2082 +
        heads: vec![h2],
2083 +
        attestation: None,
2084 +
    };
2085 +
    let r = origin
2086 +
        .receive_push(Bundle::default(), vec![], None, vec![unattested])
2087 +
        .unwrap();
1393 2088
    assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}");
1394 2089
    a.tag_create("v3", &[h2], "").unwrap();
1395 2090
    let r = a.push("origin", false).unwrap();
…
21 unchanged lines
1417 2112
    assert_ne!(ws.workspace(), repo.workspace());
1418 2113
    let list = repo.workspaces().unwrap();
1419 2114
    assert_eq!(list.len(), 2);
1420 -
    assert!(list.iter().any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current));
2115 +
    assert!(list
2116 +
        .iter()
2117 +
        .any(|w| w.id == ws.workspace() && w.path == ws_dir.canonicalize().unwrap() && !w.current));
1421 2118
    assert!(!ws.snapshot("status").unwrap().changed);
1422 2119
1423 2120
    // Work in the second workspace: its own working copy, shared bookmark.
1424 2121
    write(&ws_dir, "b.txt", b"b\n");
1425 2122
    let s = ws.snapshot("status").unwrap();
1426 2123
    assert_eq!(s.statuses.len(), 1);
1427 -
    assert!(!repo.snapshot("status").unwrap().changed, "the first workspace is untouched");
2124 +
    assert!(
2125 +
        !repo.snapshot("status").unwrap().changed,
2126 +
        "the first workspace is untouched"
2127 +
    );
1428 2128
    let h2 = ws.commit("b from ws2", &[]).unwrap();
1429 2129
    let mut heads = repo.bookmark_heads().unwrap();
1430 2130
    heads.sort();
…
1 unchanged lines
1432 2132
    want.sort();
1433 2133
    assert_eq!(heads, want, "both workspaces see the bookmark move");
1434 2134
    assert!(!main_dir.join("b.txt").exists());
1435 -
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "co").unwrap();
2135 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "co")
2136 +
        .unwrap();
1436 2137
    assert!(main_dir.join("b.txt").exists());
1437 2138
1438 2139
    // Reopening from inside the second workspace finds the shared repository.
…
8 unchanged lines
1447 2148
    repo.workspace_forget(&ws.workspace()).unwrap();
1448 2149
    assert_eq!(repo.workspaces().unwrap().len(), 1);
1449 2150
    assert!(ws_dir.join("b.txt").exists());
1450 -
    assert!(ws.snapshot("status").is_err(), "a forgotten workspace is no longer in the view");
2151 +
    assert!(
2152 +
        ws.snapshot("status").is_err(),
2153 +
        "a forgotten workspace is no longer in the view"
2154 +
    );
1451 2155
1452 2156
    // The snapshot lock: a fresh lock blocks a second, a stale one is taken over.
1453 2157
    let lock = repo.lock_snapshot().unwrap();
1454 2158
    drop(lock);
1455 -
    let lock_path = main_dir.join(".oxy/workspaces").join(repo.workspace().to_hex()).join("lock");
2159 +
    let lock_path = main_dir
2160 +
        .join(".oxy/workspaces")
2161 +
        .join(repo.workspace().to_hex())
2162 +
        .join("lock");
1456 2163
    fs::write(&lock_path, "1").unwrap();
1457 2164
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(120);
1458 -
    fs::File::open(&lock_path).unwrap().set_modified(old).unwrap();
2165 +
    fs::File::open(&lock_path)
2166 +
        .unwrap()
2167 +
        .set_modified(old)
2168 +
        .unwrap();
1459 2169
    assert!(repo.lock_snapshot().is_ok(), "stale lock taken over");
1460 2170
}
1461 2171
…
3 unchanged lines
1465 2175
    let dir = tempfile::tempdir().unwrap();
1466 2176
    let root = dir.path();
1467 2177
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
1468 -
    write(root, ".oxyattributes", b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n");
2178 +
    write(
2179 +
        root,
2180 +
        ".oxyattributes",
2181 +
        b"*.dat binary nocompress\n*.txt eol=crlf\nassets/** lockable\n",
2182 +
    );
1469 2183
    write(root, "plain.dat", b"no nul bytes here\n");
1470 2184
    write(root, "notes.txt", b"one\r\ntwo\r\n");
1471 2185
    write(root, "assets/model.bin", b"\0model");
1472 2186
    let h = repo.commit("attrs", &[]).unwrap();
1473 2187
    let snap = repo.load_state(&[h]).unwrap().materialize();
1474 -
    assert!(snap.files["plain.dat"].mode.opaque, "binary attribute forces opaque without NUL bytes");
1475 -
    assert!(matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "stored with LF");
1476 -
    assert!(!repo.snapshot("status").unwrap().changed, "CRLF on disk equals LF in the state");
2188 +
    assert!(
2189 +
        snap.files["plain.dat"].mode.opaque,
2190 +
        "binary attribute forces opaque without NUL bytes"
2191 +
    );
2192 +
    assert!(
2193 +
        matches!(&snap.files["notes.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"),
2194 +
        "stored with LF"
2195 +
    );
2196 +
    assert!(
2197 +
        !repo.snapshot("status").unwrap().changed,
2198 +
        "CRLF on disk equals LF in the state"
2199 +
    );
1477 2200
    // nocompress chunks pack raw.
1478 2201
    let r = repo.gc(true).unwrap();
1479 2202
    assert!(r.packed.objects > 0);
…
1 unchanged lines
1481 2204
    fs::remove_file(root.join("notes.txt")).unwrap();
1482 2205
    repo.checkout(&[h], true, "co").unwrap();
1483 2206
    assert_eq!(fs::read(root.join("notes.txt")).unwrap(), b"one\r\ntwo\r\n");
1484 -
    assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
2207 +
    assert!(fs::metadata(root.join("assets/model.bin"))
2208 +
        .unwrap()
2209 +
        .permissions()
2210 +
        .readonly());
1485 2211
    assert!(repo.lock_path("plain.dat").is_err(), "not lockable");
1486 2212
    repo.lock_path("assets/model.bin").unwrap();
1487 -
    assert!(!fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
2213 +
    assert!(!fs::metadata(root.join("assets/model.bin"))
2214 +
        .unwrap()
2215 +
        .permissions()
2216 +
        .readonly());
1488 2217
    assert!(repo.is_locked("assets/model.bin"));
1489 2218
    write(root, "assets/model.bin", b"\0model2");
1490 2219
    repo.commit("edit model", &[]).unwrap();
1491 2220
    repo.unlock_path("assets/model.bin").unwrap();
1492 -
    assert!(fs::metadata(root.join("assets/model.bin")).unwrap().permissions().readonly());
2221 +
    assert!(fs::metadata(root.join("assets/model.bin"))
2222 +
        .unwrap()
2223 +
        .permissions()
2224 +
        .readonly());
1493 2225
    // A later checkout can still overwrite a read-only lockable file.
1494 2226
    repo.checkout(&[h], true, "co").unwrap();
1495 2227
    assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model");
…
24 unchanged lines
1520 2252
    assert!(err.contains("pre-commit refused: no WIP commits"), "{err}");
1521 2253
    let h1 = repo.commit("first", &[]).unwrap();
1522 2254
    let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap();
1523 -
    assert!(pre.contains("\"event\":\"pre-commit\"") && pre.contains("\"path\":\"a.txt\"") && pre.contains("\"message\":\"first\""), "{pre}");
2255 +
    assert!(
2256 +
        pre.contains("\"event\":\"pre-commit\"")
2257 +
            && pre.contains("\"path\":\"a.txt\"")
2258 +
            && pre.contains("\"message\":\"first\""),
2259 +
        "{pre}"
2260 +
    );
1524 2261
    let post = fs::read_to_string(root.join(".last-post-commit")).unwrap();
1525 2262
    assert!(post.contains(&h1.to_hex()), "{post}");
1526 2263
    repo.checkout(&[h1], false, "co").unwrap();
1527 -
    assert!(fs::read_to_string(root.join(".last-post-checkout")).unwrap().contains("post-checkout"));
2264 +
    assert!(fs::read_to_string(root.join(".last-post-checkout"))
2265 +
        .unwrap()
2266 +
        .contains("post-checkout"));
1528 2267
    repo.config.set("remote.origin.url", "/nowhere").unwrap();
1529 2268
    repo.save_config().unwrap();
1530 2269
    let err = repo.push("origin", false).unwrap_err().to_string();
…
1 unchanged lines
1532 2271
1533 2272
    // semantic-deps: only when enabled; adds an explicit dependency.
1534 2273
    write(root, "b.txt", b"b\n");
1535 -
    hook("semantic-deps", &format!("cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'", h1.to_hex()));
2274 +
    hook(
2275 +
        "semantic-deps",
2276 +
        &format!(
2277 +
            "cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'",
2278 +
            h1.to_hex()
2279 +
        ),
2280 +
    );
1536 2281
    let h2 = repo.commit("b without hook", &[]).unwrap();
1537 -
    assert!(repo.store().get_change(&h2).unwrap().deps.is_empty(), "hook disabled by default");
2282 +
    assert!(
2283 +
        repo.store().get_change(&h2).unwrap().deps.is_empty(),
2284 +
        "hook disabled by default"
2285 +
    );
1538 2286
    repo.config.set("hooks.semantic-deps", "true").unwrap();
1539 2287
    repo.save_config().unwrap();
1540 2288
    write(root, "c.txt", b"c\n");
1541 2289
    let h3 = repo.commit("c with hook", &[]).unwrap();
1542 2290
    assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1543 2291
    let sem = fs::read_to_string(root.join(".last-semantic")).unwrap();
1544 -
    assert!(sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}");
2292 +
    assert!(
2293 +
        sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"),
2294 +
        "{sem}"
2295 +
    );
1545 2296
    assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
1546 2297
}
1547 2298
…
3 unchanged lines
1551 2302
    let dir = tempfile::tempdir().unwrap();
1552 2303
    let root = dir.path();
1553 2304
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
1554 -
    repo.create_identity("Ada", "a@b", IdentityScope::User).unwrap();
2305 +
    repo.create_identity("Ada", "a@b", IdentityScope::User)
2306 +
        .unwrap();
1555 2307
    repo.set_user_config("core.sign", "change").unwrap();
1556 2308
    write(root, "a.txt", b"one\ntwo\n");
1557 2309
    let h0 = repo.commit("base", &[]).unwrap();
…
10 unchanged lines
1568 2320
    let c1b = repo.store().get_change(&h1b).unwrap();
1569 2321
    assert_eq!(c1b.change_id, id1);
1570 2322
    assert_eq!(c1b.deps, vec![h0]);
1571 -
    assert!(matches!(repo.verify_change(&h1b).unwrap(), SignatureStatus::Valid { .. }));
2323 +
    assert!(matches!(
2324 +
        repo.verify_change(&h1b).unwrap(),
2325 +
        SignatureStatus::Valid { .. }
2326 +
    ));
1572 2327
    let heads = repo.bookmark_heads().unwrap();
1573 2328
    assert_eq!(heads.len(), 1);
1574 2329
    let top = repo.store().get_change(&heads[0]).unwrap();
1575 2330
    assert_eq!(top.message, "add three");
1576 -
    assert_eq!(top.deps.iter().copied().collect::<BTreeSet<_>>(), [h0, h1b].into_iter().collect(), "depends on the file's creator and the rewritten line");
2331 +
    assert_eq!(
2332 +
        top.deps.iter().copied().collect::<BTreeSet<_>>(),
2333 +
        [h0, h1b].into_iter().collect(),
2334 +
        "depends on the file's creator and the rewritten line"
2335 +
    );
1577 2336
    let snap = repo.load_state(&heads).unwrap().materialize();
1578 -
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n"));
1579 -
    assert!(!repo.snapshot("status").unwrap().changed, "the working copy is clean after folding");
2337 +
    assert!(
2338 +
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\nthree\n")
2339 +
    );
2340 +
    assert!(
2341 +
        !repo.snapshot("status").unwrap().changed,
2342 +
        "the working copy is clean after folding"
2343 +
    );
1580 2344
    // Only h1b's own state shows the fix without "three".
1581 2345
    let only = repo.load_state(&[h1b]).unwrap().materialize();
1582 -
    assert!(matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n"));
2346 +
    assert!(
2347 +
        matches!(&only.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\nTWO!\n")
2348 +
    );
1583 2349
    assert_eq!(repo.log().unwrap().len(), 4);
1584 2350
1585 2351
    // An edit to a line introduced by h2 cannot fold into h1.
1586 2352
    write(root, "a.txt", b"one\nTWO!\nthree!\n");
1587 2353
    let err = repo.amend_into(&h1b, None).unwrap_err().to_string();
1588 2354
    assert!(err.contains("introduced after"), "{err}");
1589 -
    assert!(repo.snapshot("status").unwrap().statuses.len() == 1, "nothing was changed by the refusal");
2355 +
    assert!(
2356 +
        repo.snapshot("status").unwrap().statuses.len() == 1,
2357 +
        "nothing was changed by the refusal"
2358 +
    );
1590 2359
    write(root, "a.txt", b"one\nTWO!\nthree\n");
1591 2360
1592 2361
    // Split the head that touches two files.
…
7 unchanged lines
1600 2369
    assert!(c_1.message.contains("part 1: b.txt"));
1601 2370
    assert_eq!(c_2.change_id, id3);
1602 2371
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1603 -
    assert!(heads.contains(&p1) && heads.contains(&p2), "independent parts are both heads");
1604 -
    let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
2372 +
    assert!(
2373 +
        heads.contains(&p1) && heads.contains(&p2),
2374 +
        "independent parts are both heads"
2375 +
    );
2376 +
    let snap = repo
2377 +
        .load_state(&repo.bookmark_heads().unwrap())
2378 +
        .unwrap()
2379 +
        .materialize();
1605 2380
    assert!(snap.files.contains_key("b.txt") && snap.files.contains_key("c.txt"));
1606 2381
    let only1 = repo.load_state(&[p1]).unwrap().materialize();
1607 2382
    assert!(only1.files.contains_key("b.txt") && !only1.files.contains_key("c.txt"));
…
9 unchanged lines
1617 2392
    let root = dir.path();
1618 2393
    let origin = Repo::init(&root.join("origin")).unwrap();
1619 2394
    let url = root.join("origin").to_string_lossy().to_string();
1620 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a"));
2395 +
    let mut a = Repo::clone(&url, &root.join("a"))
2396 +
        .unwrap()
2397 +
        .with_home(root.join("home-a"));
1621 2398
    a.set_user_config("core.sign", "change").unwrap();
1622 -
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
2399 +
    let (key_a, _) = a
2400 +
        .create_identity("Ada", "[email protected]", IdentityScope::User)
2401 +
        .unwrap();
1623 2402
    write(&root.join("a"), "a.txt", b"a\n");
1624 2403
    let h1 = a.commit("one", &[]).unwrap();
1625 2404
    assert!(a.policy_init().unwrap().applied);
1626 2405
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
1627 2406
1628 2407
    // Expiry: an identity that had expired when the change was made does not authenticate it.
1629 -
    let mut e = Repo::init(&root.join("e")).unwrap().with_home(root.join("home-e"));
2408 +
    let mut e = Repo::init(&root.join("e"))
2409 +
        .unwrap()
2410 +
        .with_home(root.join("home-e"));
1630 2411
    e.set_user_config("core.sign", "change").unwrap();
1631 -
    e.create_identity_with("Eve", "[email protected]", IdentityScope::User, Some(Hlc::now().wall_ms - 1)).unwrap();
2412 +
    e.create_identity_with(
2413 +
        "Eve",
2414 +
        "[email protected]",
2415 +
        IdentityScope::User,
2416 +
        Some(Hlc::now().wall_ms - 1),
2417 +
    )
2418 +
    .unwrap();
1632 2419
    write(&root.join("e"), "e.txt", b"e\n");
1633 2420
    let he = e.commit("late", &[]).unwrap();
1634 -
    assert!(matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired")));
2421 +
    assert!(
2422 +
        matches!(e.verify_change(&he).unwrap(), SignatureStatus::Invalid(m) if m.contains("expired"))
2423 +
    );
1635 2424
    // Renewal re-issues the identity (same keys, new KeyId) with a later expiry.
1636 -
    let r = e.renew_identity(Some(Hlc::now().wall_ms + 86_400_000)).unwrap();
2425 +
    let r = e
2426 +
        .renew_identity(Some(Hlc::now().wall_ms + 86_400_000))
2427 +
        .unwrap();
1637 2428
    assert_eq!(e.identity(&r.new).unwrap().previous, Some(r.old));
1638 2429
    write(&root.join("e"), "e.txt", b"e2\n");
1639 2430
    let he2 = e.commit("in time", &[]).unwrap();
1640 -
    assert!(matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new));
2431 +
    assert!(
2432 +
        matches!(e.verify_change(&he2).unwrap(), SignatureStatus::Valid { key, .. } if key == r.new)
2433 +
    );
1641 2434
1642 2435
    // Rotation: a fresh key; the old change still verifies; the policy follows.
1643 2436
    let r = a.rotate_identity().unwrap();
…
2 unchanged lines
1646 2439
    assert_eq!(a.identity(&r.new).unwrap().previous, Some(key_a));
1647 2440
    assert!(r.policy.as_ref().unwrap().applied);
1648 2441
    let (_, sp) = a.current_policy().unwrap().unwrap();
1649 -
    assert_eq!((sp.policy.serial, sp.policy.roots.clone()), (2, vec![r.new]));
1650 -
    assert!(!sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"), "superseded, not revoked");
2442 +
    assert_eq!(
2443 +
        (sp.policy.serial, sp.policy.roots.clone()),
2444 +
        (2, vec![r.new])
2445 +
    );
2446 +
    assert!(
2447 +
        !sp.policy.revoked.contains(&key_a) && sp.policy.authorizes(&r.new, "main"),
2448 +
        "superseded, not revoked"
2449 +
    );
1651 2450
    assert!(sp.policy.succeeds(&r.new, &key_a) && !sp.policy.succeeds(&key_a, &r.new));
1652 -
    assert!(matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a));
2451 +
    assert!(
2452 +
        matches!(a.verify_change(&h1).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == key_a)
2453 +
    );
1653 2454
    write(&root.join("a"), "a.txt", b"a2\n");
1654 2455
    let h2 = a.commit("two", &[]).unwrap();
1655 -
    assert!(matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new));
2456 +
    assert!(
2457 +
        matches!(a.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, revoked: false, .. } if key == r.new)
2458 +
    );
1656 2459
    // Origin accepts the rotated policy (signed by the old root) and the new key's change.
1657 2460
    let res = a.push("origin", false).unwrap();
1658 2461
    assert!(res.iter().all(|x| x.ok), "{res:?}");
…
2 unchanged lines
1661 2464
1662 2465
    // Delegation: Bob may sign on Ada's behalf. Delegates live in the
1663 2466
    // identity object, so Ada's KeyId changes while her keys stay the same.
1664 -
    let mut b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
1665 -
    let (key_b, id_b) = b.create_identity("Bob", "[email protected]", IdentityScope::User).unwrap();
2467 +
    let mut b = Repo::clone(&url, &root.join("b"))
2468 +
        .unwrap()
2469 +
        .with_home(root.join("home-b"));
2470 +
    let (key_b, id_b) = b
2471 +
        .create_identity("Bob", "[email protected]", IdentityScope::User)
2472 +
        .unwrap();
1666 2473
    let r = a.delegate_add(id_b.clone()).unwrap();
1667 2474
    assert_eq!(r.old, key_a);
1668 2475
    let key_a2 = r.new;
1669 2476
    assert_eq!(a.identity(&key_a2).unwrap().delegates, vec![key_b]);
1670 -
    assert_eq!(a.identity(&key_a2).unwrap().ed25519_pk, a.identity(&key_a).unwrap().ed25519_pk);
2477 +
    assert_eq!(
2478 +
        a.identity(&key_a2).unwrap().ed25519_pk,
2479 +
        a.identity(&key_a).unwrap().ed25519_pk
2480 +
    );
1671 2481
    assert!(a.delegate_add(id_b.clone()).is_err(), "already a delegate");
1672 2482
    assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok));
1673 2483
    write(&root.join("a"), "a.txt", b"a3\n");
1674 2484
    let h3 = a.commit("three", &[]).unwrap();
1675 -
    assert!(matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2), "the same secret signs under the new KeyId");
2485 +
    assert!(
2486 +
        matches!(a.verify_change(&h3).unwrap(), SignatureStatus::Valid { key, .. } if key == key_a2),
2487 +
        "the same secret signs under the new KeyId"
2488 +
    );
1676 2489
    // A change authored by Ada but signed only by Bob verifies through the delegation.
1677 2490
    a.set_user_config("core.sign", "seal").unwrap();
1678 2491
    write(&root.join("a"), "a.txt", b"a4\n");
1679 2492
    let h4 = a.commit("four", &[]).unwrap();
1680 2493
    assert_eq!(a.verify_change(&h4).unwrap(), SignatureStatus::Unsigned);
1681 2494
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
1682 -
    a.store().put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4)).unwrap();
1683 -
    assert!(matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b));
2495 +
    a.store()
2496 +
        .put_signature(&h4, &sk_b.sign(Domain::Change, &key_b, &h4))
2497 +
        .unwrap();
2498 +
    assert!(
2499 +
        matches!(a.verify_change(&h4).unwrap(), SignatureStatus::Valid { key, .. } if key == key_b)
2500 +
    );
1684 2501
    a.set_user_config("core.sign", "change").unwrap();
1685 2502
    // Withdrawing the delegation re-issues the identity again.
1686 2503
    let r = a.delegate_remove(&key_b).unwrap();
…
11 unchanged lines
1698 2515
    assert!(a.policy_set_threshold(2).unwrap().applied);
1699 2516
    assert!(a.push("origin", false).unwrap().iter().all(|x| x.ok));
1700 2517
    b.pull("origin").unwrap();
1701 -
    assert_eq!(b.current_policy().unwrap().unwrap().1.policy.root_threshold, 2);
1702 -
    let mut c = Repo::init(&root.join("c")).unwrap().with_home(root.join("home-c"));
1703 -
    let (key_c, id_c) = c.create_identity("Cy", "[email protected]", IdentityScope::User).unwrap();
2518 +
    assert_eq!(
2519 +
        b.current_policy().unwrap().unwrap().1.policy.root_threshold,
2520 +
        2
2521 +
    );
2522 +
    let mut c = Repo::init(&root.join("c"))
2523 +
        .unwrap()
2524 +
        .with_home(root.join("home-c"));
2525 +
    let (key_c, id_c) = c
2526 +
        .create_identity("Cy", "[email protected]", IdentityScope::User)
2527 +
        .unwrap();
1704 2528
    let serial_before = a.current_policy().unwrap().unwrap().1.policy.serial;
1705 2529
    let proposal = a.policy_grant(id_c, None).unwrap();
1706 2530
    assert!(!proposal.applied && proposal.sigs.len() == 1);
1707 2531
    assert_eq!(proposal.policy.serial, serial_before + 1);
1708 -
    assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before, "a proposal changes nothing yet");
2532 +
    assert_eq!(
2533 +
        a.current_policy().unwrap().unwrap().1.policy.serial,
2534 +
        serial_before,
2535 +
        "a proposal changes nothing yet"
2536 +
    );
1709 2537
    // Bob signs the proposal, handed over as a file, and it applies for him.
1710 2538
    let bytes = proposal.signed().encode();
1711 -
    let signed = b.policy_sign(SignedPolicy::decode(&bytes).unwrap()).unwrap();
2539 +
    let signed = b
2540 +
        .policy_sign(SignedPolicy::decode(&bytes).unwrap())
2541 +
        .unwrap();
1712 2542
    assert!(signed.applied && signed.sigs.len() == 2);
1713 -
    assert!(b.current_policy().unwrap().unwrap().1.policy.authorizes(&key_c, "main"));
1714 -
    assert!(b.policy_sign(signed.signed()).is_err(), "a stale proposal is refused");
2543 +
    assert!(b
2544 +
        .current_policy()
2545 +
        .unwrap()
2546 +
        .unwrap()
2547 +
        .1
2548 +
        .policy
2549 +
        .authorizes(&key_c, "main"));
2550 +
    assert!(
2551 +
        b.policy_sign(signed.signed()).is_err(),
2552 +
        "a stale proposal is refused"
2553 +
    );
1715 2554
    // Bob publishes it and Ada picks it up.
1716 -
    assert!(b.push("origin", false).unwrap().iter().any(|x| x.name == "policy" && x.ok));
2555 +
    assert!(b
2556 +
        .push("origin", false)
2557 +
        .unwrap()
2558 +
        .iter()
2559 +
        .any(|x| x.name == "policy" && x.ok));
1717 2560
    a.pull("origin").unwrap();
1718 -
    assert_eq!(a.current_policy().unwrap().unwrap().1.policy.serial, serial_before + 1);
2561 +
    assert_eq!(
2562 +
        a.current_policy().unwrap().unwrap().1.policy.serial,
2563 +
        serial_before + 1
2564 +
    );
1719 2565
    // A root cannot revoke itself; revoking Cy needs both roots but is recorded locally at once.
1720 2566
    assert!(a.policy_revoke(&key_a3).is_err());
1721 -
    let u = a.revoke_identity(&key_c, "left the project").unwrap().unwrap();
2567 +
    let u = a
2568 +
        .revoke_identity(&key_c, "left the project")
2569 +
        .unwrap()
2570 +
        .unwrap();
1722 2571
    assert!(!u.applied);
1723 2572
    assert!(a.is_revoked(&key_c).unwrap());
1724 2573
    assert!(!b.is_revoked(&key_c).unwrap());
…
1 unchanged lines
1726 2575
    assert!(u.applied && u.policy.is_revoked(&key_c));
1727 2576
    assert!(b.is_revoked(&key_c).unwrap());
1728 2577
    // Bob can revoke Ada's root key, leaving himself as the only root (threshold 2 would be unmeetable).
1729 -
    assert!(b.policy_revoke(&key_a3).is_err(), "would leave one root for a threshold of 2");
2578 +
    assert!(
2579 +
        b.policy_revoke(&key_a3).is_err(),
2580 +
        "would leave one root for a threshold of 2"
2581 +
    );
1730 2582
    // Revoking one's own key without a policy root works locally: a plain marker plus attestation.
1731 2583
    let key_c_own = c.config.author_key.unwrap();
1732 -
    assert!(c.revoke_identity(&key_c_own, "lost laptop").unwrap().is_none());
2584 +
    assert!(c
2585 +
        .revoke_identity(&key_c_own, "lost laptop")
2586 +
        .unwrap()
2587 +
        .is_none());
1733 2588
    assert!(c.is_revoked(&key_c_own).unwrap());
1734 2589
    assert_eq!(key_c_own, key_c);
1735 2590
}
…
4 unchanged lines
1740 2595
    let dir = tempfile::tempdir().unwrap();
1741 2596
    let root = dir.path();
1742 2597
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
1743 -
    write(root, "src/old.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n");
2598 +
    write(
2599 +
        root,
2600 +
        "src/old.rs",
2601 +
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\n",
2602 +
    );
1744 2603
    write(root, "img.bin", b"\x89PNG\0\0\x01\x02");
1745 2604
    write(root, "tiny.txt", b"x\n");
1746 2605
    let h1 = repo.commit("base", &[]).unwrap();
…
3 unchanged lines
1750 2609
    // Rename with a small edit, a binary moved unchanged, and a rewrite
1751 2610
    // that shares nothing with the removed file.
1752 2611
    fs::remove_file(root.join("src/old.rs")).unwrap();
1753 -
    write(root, "lib/new.rs", b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n");
2612 +
    write(
2613 +
        root,
2614 +
        "lib/new.rs",
2615 +
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n",
2616 +
    );
1754 2617
    fs::remove_file(root.join("img.bin")).unwrap();
1755 2618
    write(root, "assets/img.bin", b"\x89PNG\0\0\x01\x02");
1756 2619
    fs::remove_file(root.join("tiny.txt")).unwrap();
1757 2620
    write(root, "other.txt", b"completely different\n");
1758 2621
    let snap = repo.snapshot("status").unwrap();
1759 -
    let statuses: Vec<(&str, ChangeStatus)> = snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect();
2622 +
    let statuses: Vec<(&str, ChangeStatus)> = snap
2623 +
        .statuses
2624 +
        .iter()
2625 +
        .map(|s| (s.path.as_str(), s.status))
2626 +
        .collect();
1760 2627
    assert_eq!(
1761 2628
        statuses,
1762 -
        vec![("assets/img.bin", ChangeStatus::Renamed), ("lib/new.rs", ChangeStatus::Renamed), ("other.txt", ChangeStatus::Added), ("src", ChangeStatus::Removed), ("tiny.txt", ChangeStatus::Removed)]
2629 +
        vec![
2630 +
            ("assets/img.bin", ChangeStatus::Renamed),
2631 +
            ("lib/new.rs", ChangeStatus::Renamed),
2632 +
            ("other.txt", ChangeStatus::Added),
2633 +
            ("src", ChangeStatus::Removed),
2634 +
            ("tiny.txt", ChangeStatus::Removed)
2635 +
        ]
1763 2636
    );
1764 2637
    let h2 = repo.commit("move things", &[]).unwrap();
1765 2638
    let c = repo.store().get_change(&h2).unwrap();
1766 -
    let moves: Vec<_> = c.tree_ops.iter().filter(|op| matches!(op, TreeOp::Move { .. })).collect();
2639 +
    let moves: Vec<_> = c
2640 +
        .tree_ops
2641 +
        .iter()
2642 +
        .filter(|op| matches!(op, TreeOp::Move { .. }))
2643 +
        .collect();
1767 2644
    assert_eq!(moves.len(), 2, "{:?}", c.tree_ops);
1768 -
    assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs")));
1769 -
    assert!(c.tree_ops.iter().any(|op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin")));
2645 +
    assert!(c.tree_ops.iter().any(
2646 +
        |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_old && name == "new.rs")
2647 +
    ));
2648 +
    assert!(c.tree_ops.iter().any(
2649 +
        |op| matches!(op, TreeOp::Move { node, name, .. } if *node == node_img && name == "img.bin")
2650 +
    ));
1770 2651
    let snap = repo.load_state(&[h2]).unwrap().materialize();
1771 -
    assert_eq!(snap.files["lib/new.rs"].node, node_old, "the node survives the rename");
1772 -
    assert!(matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"));
2652 +
    assert_eq!(
2653 +
        snap.files["lib/new.rs"].node, node_old,
2654 +
        "the node survives the rename"
2655 +
    );
2656 +
    assert!(
2657 +
        matches!(&snap.files["lib/new.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n")
2658 +
    );
1773 2659
    assert!(!snap.files.contains_key("src/old.rs") && !snap.files.contains_key("src"));
1774 -
    assert!(render_change(&c, &repo.load_state(&[h1]).unwrap(), &repo.load_state(&[h1]).unwrap().materialize()).contains("R "));
2660 +
    assert!(render_change(
2661 +
        &c,
2662 +
        &repo.load_state(&[h1]).unwrap(),
2663 +
        &repo.load_state(&[h1]).unwrap().materialize()
2664 +
    )
2665 +
    .contains("R "));
1775 2666
    // A fresh checkout reproduces the tree.
1776 2667
    let other = tempfile::tempdir().unwrap();
1777 2668
    let clone = Repo::clone(&root.to_string_lossy(), other.path()).unwrap();
1778 -
    assert_eq!(fs::read(other.path().join("lib/new.rs")).unwrap(), b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n");
2669 +
    assert_eq!(
2670 +
        fs::read(other.path().join("lib/new.rs")).unwrap(),
2671 +
        b"fn a() {}\nfn b() {}\nfn c() {}\nfn e() {}\n"
2672 +
    );
1779 2673
    assert!(!other.path().join("src").exists());
1780 2674
    assert!(!clone.snapshot("status").unwrap().changed);
1781 2675
    // Similarity is line based, byte weighted.
…
6 unchanged lines
1788 2682
#[test]
1789 2683
fn git_fetch_and_push_round_trip() {
1790 2684
    hermetic();
1791 -
    if std::process::Command::new("git").arg("--version").output().is_err() {
2685 +
    if std::process::Command::new("git")
2686 +
        .arg("--version")
2687 +
        .output()
2688 +
        .is_err()
2689 +
    {
1792 2690
        eprintln!("git not installed; skipping");
1793 2691
        return;
1794 2692
    }
…
3 unchanged lines
1798 2696
        let out = std::process::Command::new("git")
1799 2697
            .arg("-C")
1800 2698
            .arg(d)
1801 -
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
2699 +
            .args([
2700 +
                "-c",
2701 +
                "user.name=Grace",
2702 +
                "-c",
2703 +
                "[email protected]",
2704 +
                "-c",
2705 +
                "commit.gpgsign=false",
2706 +
            ])
1802 2707
            .args(args)
1803 2708
            .output()
1804 2709
            .unwrap();
1805 -
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
2710 +
        assert!(
2711 +
            out.status.success(),
2712 +
            "git {args:?}: {}",
2713 +
            String::from_utf8_lossy(&out.stderr)
2714 +
        );
1806 2715
        String::from_utf8_lossy(&out.stdout).trim().to_string()
1807 2716
    };
1808 2717
    // A bare "remote" and a work tree that pushes to it.
…
1 unchanged lines
1810 2719
    fs::create_dir_all(&remote).unwrap();
1811 2720
    git_in(&remote, &["init", "-q", "--bare", "-b", "main"]);
1812 2721
    let work = root.join("work");
1813 -
    git_in(root, &["clone", "-q", remote.to_str().unwrap(), work.to_str().unwrap()]);
2722 +
    git_in(
2723 +
        root,
2724 +
        &[
2725 +
            "clone",
2726 +
            "-q",
2727 +
            remote.to_str().unwrap(),
2728 +
            work.to_str().unwrap(),
2729 +
        ],
2730 +
    );
1814 2731
    git_in(&work, &["checkout", "-q", "-b", "main"]);
1815 2732
    write(&work, "README.md", b"# Project\n");
1816 2733
    write(&work, "src/lib.rs", b"pub fn a() {}\n");
…
7 unchanged lines
1824 2741
    // Fetch into a fresh oxy repository.
1825 2742
    let o = root.join("oxy");
1826 2743
    let mut repo = Repo::init(&o).unwrap().with_home(root.join("home"));
1827 -
    repo.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
2744 +
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
2745 +
        .unwrap();
1828 2746
    let url = remote.to_string_lossy().to_string();
1829 2747
    let r = repo.git_fetch(&url, "main").unwrap();
1830 2748
    assert_eq!((r.commits, r.heads), (2, 1));
1831 -
    assert_eq!(fs::read(o.join("src/lib.rs")).unwrap(), b"pub fn a() {}\npub fn b() {}\n");
2749 +
    assert_eq!(
2750 +
        fs::read(o.join("src/lib.rs")).unwrap(),
2751 +
        b"pub fn a() {}\npub fn b() {}\n"
2752 +
    );
1832 2753
    assert!(!repo.snapshot("status").unwrap().changed);
1833 -
    assert_eq!(repo.git_fetch(&url, "main").unwrap().commits, 0, "nothing new");
2754 +
    assert_eq!(
2755 +
        repo.git_fetch(&url, "main").unwrap().commits,
2756 +
        0,
2757 +
        "nothing new"
2758 +
    );
1834 2759
1835 2760
    // An oxy change, pushed back: one commit on top of the Git tip.
1836 -
    write(&o, "src/lib.rs", b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n");
2761 +
    write(
2762 +
        &o,
2763 +
        "src/lib.rs",
2764 +
        b"pub fn a() {}\npub fn b() {}\npub fn c() {}\n",
2765 +
    );
1837 2766
    write(&o, "NOTES.md", b"from oxy\n");
1838 2767
    let h_oxy = repo.commit("add c and notes", &[]).unwrap();
1839 2768
    let r = repo.git_push(&url, "main").unwrap();
1840 2769
    assert_eq!(r.exported, 1, "{r:?}");
1841 2770
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
1842 -
    assert_eq!(git_in(&work, &["log", "--format=%s", "main"]), "add c and notes\nsecond\ninitial");
2771 +
    assert_eq!(
2772 +
        git_in(&work, &["log", "--format=%s", "main"]),
2773 +
        "add c and notes\nsecond\ninitial"
2774 +
    );
1843 2775
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip1);
1844 2776
    assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\n");
1845 2777
    assert_eq!(git_in(&work, &["log", "-1", "--format=%an", "main"]), "Ada");
1846 -
    assert!(git_in(&work, &["log", "-1", "--format=%B", "main"]).contains(&format!("Oxy-Hash: {}", h_oxy.to_hex())));
1847 -
    assert_eq!(repo.git_push(&url, "main").unwrap().exported, 0, "already pushed");
2778 +
    assert!(git_in(&work, &["log", "-1", "--format=%B", "main"])
2779 +
        .contains(&format!("Oxy-Hash: {}", h_oxy.to_hex())));
2780 +
    assert_eq!(
2781 +
        repo.git_push(&url, "main").unwrap().exported,
2782 +
        0,
2783 +
        "already pushed"
2784 +
    );
1848 2785
1849 2786
    // Git moves on; pushing without fetching is refused, fetching brings
1850 2787
    // the commit in and the working copy follows.
…
1 unchanged lines
1852 2789
    git_in(&work, &["commit", "-q", "-am", "readme from git"]);
1853 2790
    git_in(&work, &["push", "-q", "origin", "main"]);
1854 2791
    let tip2 = git_in(&work, &["rev-parse", "HEAD"]);
1855 -
    assert!(repo.git_push(&url, "main").unwrap_err().to_string().contains("fetch"));
2792 +
    assert!(repo
2793 +
        .git_push(&url, "main")
2794 +
        .unwrap_err()
2795 +
        .to_string()
2796 +
        .contains("fetch"));
1856 2797
    let r = repo.git_fetch(&url, "main").unwrap();
1857 -
    assert_eq!((r.commits, r.heads), (1, 2), "the Git change and the oxy change commute");
1858 -
    assert_eq!(fs::read(o.join("README.md")).unwrap(), b"# Project\n\nMore.\n");
2798 +
    assert_eq!(
2799 +
        (r.commits, r.heads),
2800 +
        (1, 2),
2801 +
        "the Git change and the oxy change commute"
2802 +
    );
2803 +
    assert_eq!(
2804 +
        fs::read(o.join("README.md")).unwrap(),
2805 +
        b"# Project\n\nMore.\n"
2806 +
    );
1859 2807
    assert_eq!(fs::read(o.join("NOTES.md")).unwrap(), b"from oxy\n");
1860 2808
    assert!(!repo.snapshot("status").unwrap().changed);
1861 2809
    // Another oxy change pushes on top of Git's newest commit.
…
2 unchanged lines
1864 2812
    assert_eq!(repo.git_push(&url, "main").unwrap().exported, 1);
1865 2813
    git_in(&work, &["pull", "-q", "--ff-only", "origin", "main"]);
1866 2814
    assert_eq!(git_in(&work, &["rev-parse", "HEAD~1"]), tip2);
1867 -
    assert_eq!(fs::read(work.join("NOTES.md")).unwrap(), b"from oxy\nagain\n");
1868 -
    assert_eq!(git_in(&work, &["log", "--format=%s", "main"]).lines().count(), 5);
2815 +
    assert_eq!(
2816 +
        fs::read(work.join("NOTES.md")).unwrap(),
2817 +
        b"from oxy\nagain\n"
2818 +
    );
2819 +
    assert_eq!(
2820 +
        git_in(&work, &["log", "--format=%s", "main"])
2821 +
            .lines()
2822 +
            .count(),
2823 +
        5
2824 +
    );
1869 2825
}
1870 2826
1871 2827
/// Benchmark, not a gate: `cargo test --release -p oxy-core --lib bench_one_gibibyte -- --ignored --nocapture`.
…
8 unchanged lines
1880 2836
        None => tempfile::tempdir().unwrap(),
1881 2837
    };
1882 2838
    let root = dir.path();
1883 -
    let repo = Repo::init(&root.join("r")).unwrap().with_home(root.join("home"));
2839 +
    let repo = Repo::init(&root.join("r"))
2840 +
        .unwrap()
2841 +
        .with_home(root.join("home"));
1884 2842
    let size = 1usize << 30;
1885 2843
    let mut data = vec![0u8; size];
1886 2844
    let mut x = 0x9e3779b97f4a7c15u64;
…
28 unchanged lines
1915 2873
    eprintln!("read back and verify: {:.2?}", t.elapsed());
1916 2874
    assert_eq!(clone.bookmark_heads().unwrap(), vec![h]);
1917 2875
    let store_bytes: u64 = walkdir_size(&root.join("r/.oxy/objects"));
1918 -
    eprintln!("store size: {:.1} MiB for 1 GiB of data", store_bytes as f64 / (1024.0 * 1024.0));
2876 +
    eprintln!(
2877 +
        "store size: {:.1} MiB for 1 GiB of data",
2878 +
        store_bytes as f64 / (1024.0 * 1024.0)
2879 +
    );
1919 2880
}
1920 2881
1921 2882
fn walkdir_size(p: &Path) -> u64 {
…
30 unchanged lines
1952 2913
    write(root, "a.txt", b"text\n");
1953 2914
    let h1 = repo.commit("base", &[]).unwrap();
1954 2915
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
1955 -
    assert!(m.entries["big.bin"].blob.is_some(), "opaque files record their blob");
2916 +
    assert!(
2917 +
        m.entries["big.bin"].blob.is_some(),
2918 +
        "opaque files record their blob"
2919 +
    );
1956 2920
    assert!(m.entries["a.txt"].blob.is_none());
1957 2921
    let blob = m.entries["big.bin"].blob.unwrap();
1958 2922
    assert!(repo.store().has(&blob));
…
1 unchanged lines
1960 2924
    // Files modified within two seconds of the manifest are racy and still
1961 2925
    // read; age the file so the cache applies, then snapshot to record it.
1962 2926
    let old = std::time::SystemTime::now() - std::time::Duration::from_secs(30);
1963 -
    fs::File::options().write(true).open(root.join("big.bin")).unwrap().set_modified(old).unwrap();
2927 +
    fs::File::options()
2928 +
        .write(true)
2929 +
        .open(root.join("big.bin"))
2930 +
        .unwrap()
2931 +
        .set_modified(old)
2932 +
        .unwrap();
1964 2933
    assert!(!repo.snapshot("status").unwrap().changed);
1965 2934
    // Now the file cannot be read at all; the snapshot succeeds without it.
1966 2935
    fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o000)).unwrap();
1967 2936
    let snap = repo.snapshot("status").unwrap();
1968 -
    assert!(!snap.changed && snap.statuses.is_empty(), "{:?}", snap.statuses);
2937 +
    assert!(
2938 +
        !snap.changed && snap.statuses.is_empty(),
2939 +
        "{:?}",
2940 +
        snap.statuses
2941 +
    );
1969 2942
    fs::set_permissions(root.join("big.bin"), fs::Permissions::from_mode(0o644)).unwrap();
1970 2943
    // A real change (new mtime, new content) is seen and committed correctly.
1971 2944
    let mut big2 = big.clone();
1972 2945
    big2[10] ^= 0xff;
1973 2946
    write(root, "big.bin", &big2);
1974 2947
    let snap = repo.snapshot("status").unwrap();
1975 -
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("big.bin", ChangeStatus::Modified)]);
2948 +
    assert_eq!(
2949 +
        snap.statuses
2950 +
            .iter()
2951 +
            .map(|s| (s.path.as_str(), s.status))
2952 +
            .collect::<Vec<_>>(),
2953 +
        vec![("big.bin", ChangeStatus::Modified)]
2954 +
    );
1976 2955
    let h2 = repo.commit("edit", &[]).unwrap();
1977 2956
    let files = repo.load_state(&[h2]).unwrap().materialize().files;
1978 -
    assert_eq!(files["big.bin"].content, FileContent::Opaque { blobs: vec![Repo::blob_ref(&big2)] });
2957 +
    assert_eq!(
2958 +
        files["big.bin"].content,
2959 +
        FileContent::Opaque {
2960 +
            blobs: vec![Repo::blob_ref(&big2)]
2961 +
        }
2962 +
    );
1979 2963
    // Checkout writes a manifest with blobs too, so the next status is cached.
1980 2964
    repo.checkout(&[h1], false, "checkout").unwrap();
1981 2965
    let (_, _, m) = repo.read_manifest().unwrap().unwrap();
…
21 unchanged lines
2003 2987
    let ida = repo.store().get_change(&ha).unwrap().change_id.to_hex();
2004 2988
    let idb = repo.store().get_change(&hb).unwrap().change_id.to_hex();
2005 2989
    assert!(json.contains("\"conflicted\":true"), "{json}");
2006 -
    assert!(json.contains("\"kind\":\"text\",\"path\":\"a.txt\""), "{json}");
2990 +
    assert!(
2991 +
        json.contains("\"kind\":\"text\",\"path\":\"a.txt\""),
2992 +
        "{json}"
2993 +
    );
2007 2994
    assert!(json.contains(&ida) && json.contains(&idb), "{json}");
2008 2995
    assert!(json.contains("\"resolved\":false"), "{json}");
2009 2996
    assert!(json.starts_with('{') && json.ends_with('}'));
…
5 unchanged lines
2015 3002
    repo.commit("resolve", &[]).unwrap();
2016 3003
    let snap = repo.snapshot("status").unwrap();
2017 3004
    let json = repo.status_json(&snap).unwrap();
2018 -
    assert!(json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"), "{json}");
3005 +
    assert!(
3006 +
        json.contains("\"clean\":true,\"conflicted\":false,\"paths\":[],\"conflicts\":[]"),
3007 +
        "{json}"
3008 +
    );
2019 3009
}
2020 3010
2021 3011
#[test]
…
9 unchanged lines
2031 3021
    write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\n");
2032 3022
    let snap = repo.snapshot("status").unwrap();
2033 3023
    let added: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
2034 -
    assert_eq!(added, vec![".oxyignore", "src.c", "sub/keep.log"], "anchored, glob, and negated rules");
3024 +
    assert_eq!(
3025 +
        added,
3026 +
        vec![".oxyignore", "src.c", "sub/keep.log"],
3027 +
        "anchored, glob, and negated rules"
3028 +
    );
2035 3029
    let h1 = repo.commit("base", &[]).unwrap();
2036 3030
    // Ignoring a tracked file changes nothing about it.
2037 3031
    write(root, ".oxyignore", b"/build\n*.log\n!sub/keep.log\nsrc.c\n");
2038 3032
    let snap = repo.snapshot("status").unwrap();
2039 -
    assert_eq!(snap.statuses.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), vec![".oxyignore"]);
3033 +
    assert_eq!(
3034 +
        snap.statuses
3035 +
            .iter()
3036 +
            .map(|s| s.path.as_str())
3037 +
            .collect::<Vec<_>>(),
3038 +
        vec![".oxyignore"]
3039 +
    );
2040 3040
    let h2 = repo.commit("ignore src.c", &[]).unwrap();
2041 -
    assert!(repo.load_state(&[h2]).unwrap().materialize().files.contains_key("src.c"));
3041 +
    assert!(repo
3042 +
        .load_state(&[h2])
3043 +
        .unwrap()
3044 +
        .materialize()
3045 +
        .files
3046 +
        .contains_key("src.c"));
2042 3047
    // Its edits are still seen, by status and by the prompt.
2043 3048
    assert_eq!(repo.quick_dirty().unwrap(), Some(false));
2044 3049
    write(root, "src.c", b"int x, y;\n");
2045 3050
    assert_eq!(repo.quick_dirty().unwrap(), Some(true));
2046 3051
    let snap = repo.snapshot("status").unwrap();
2047 -
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("src.c", ChangeStatus::Modified)]);
3052 +
    assert_eq!(
3053 +
        snap.statuses
3054 +
            .iter()
3055 +
            .map(|s| (s.path.as_str(), s.status))
3056 +
            .collect::<Vec<_>>(),
3057 +
        vec![("src.c", ChangeStatus::Modified)]
3058 +
    );
2048 3059
    let h3 = repo.commit("edit src.c", &[]).unwrap();
2049 -
    assert!(matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n"));
3060 +
    assert!(
3061 +
        matches!(&repo.load_state(&[h3]).unwrap().materialize().files["src.c"].content, FileContent::Text { bytes, .. } if bytes == b"int x, y;\n")
3062 +
    );
2050 3063
    // Deleting it on disk removes it, ignored or not; a checkout brings it back.
2051 3064
    fs::remove_file(root.join("src.c")).unwrap();
2052 3065
    let snap = repo.snapshot("status").unwrap();
2053 -
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("src.c", ChangeStatus::Removed)]);
3066 +
    assert_eq!(
3067 +
        snap.statuses
3068 +
            .iter()
3069 +
            .map(|s| (s.path.as_str(), s.status))
3070 +
            .collect::<Vec<_>>(),
3071 +
        vec![("src.c", ChangeStatus::Removed)]
3072 +
    );
2054 3073
    repo.checkout(&[h3], true, "checkout").unwrap();
2055 3074
    assert_eq!(fs::read(root.join("src.c")).unwrap(), b"int x, y;\n");
2056 3075
    assert!(!repo.snapshot("status").unwrap().changed);
…
16 unchanged lines
2073 3092
    // h1 is not a head: split it by node.
2074 3093
    let (hj, h1b) = repo.split(&h1, &["junk".into()]).unwrap();
2075 3094
    assert_ne!(h1b, h1);
2076 -
    assert_eq!(repo.store().get_change(&h1b).unwrap().change_id, id1, "the rest keeps the ChangeId");
3095 +
    assert_eq!(
3096 +
        repo.store().get_change(&h1b).unwrap().change_id,
3097 +
        id1,
3098 +
        "the rest keeps the ChangeId"
3099 +
    );
2077 3100
    let junk = repo.store().get_change(&hj).unwrap();
2078 -
    assert!(junk.tree_ops.iter().all(|op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock")));
3101 +
    assert!(junk.tree_ops.iter().all(
3102 +
        |op| matches!(op, TreeOp::AddNode { name, .. } if name == "junk" || name == "x.lock")
3103 +
    ));
2079 3104
    let heads = repo.bookmark_heads().unwrap();
2080 -
    assert!(heads.contains(&hj), "the split-off part is a head of its own");
3105 +
    assert!(
3106 +
        heads.contains(&hj),
3107 +
        "the split-off part is a head of its own"
3108 +
    );
2081 3109
    let snap = repo.load_state(&heads).unwrap().materialize();
2082 -
    assert!(matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"), "the dependent was rewritten onto the new hash");
3110 +
    assert!(
3111 +
        matches!(&snap.files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"one\ntwo\n"),
3112 +
        "the dependent was rewritten onto the new hash"
3113 +
    );
2083 3114
    assert!(snap.files.contains_key("junk/x.lock"));
2084 -
    let h2b = heads.iter().find(|h| repo.store().get_change(h).unwrap().message == "more work").copied().unwrap();
3115 +
    let h2b = heads
3116 +
        .iter()
3117 +
        .find(|h| repo.store().get_change(h).unwrap().message == "more work")
3118 +
        .copied()
3119 +
        .unwrap();
2085 3120
    assert_eq!(repo.store().get_change(&h2b).unwrap().deps, vec![h1b]);
2086 3121
    assert!(!repo.snapshot("status").unwrap().changed);
2087 3122
    // Abandoning the split-off head removes the junk from history.
…
19 unchanged lines
2107 3142
    let root = dir.path();
2108 3143
    let origin = Repo::init(&root.join("origin")).unwrap();
2109 3144
    let url = root.join("origin").to_string_lossy().to_string();
2110 -
    let mut repo = Repo::clone(&url, &root.join("r")).unwrap().with_home(root.join("home"));
3145 +
    let mut repo = Repo::clone(&url, &root.join("r"))
3146 +
        .unwrap()
3147 +
        .with_home(root.join("home"));
2111 3148
    let r = root.join("r");
2112 3149
    write(&r, "a.txt", b"a\n");
2113 3150
    let h_a = repo.commit("a", &[]).unwrap();
…
9 unchanged lines
2123 3160
    write(&r, "a.txt", b"a\nb\n");
2124 3161
    write(&r, "b.txt", b"b\n");
2125 3162
    let h_b = repo.commit("b on feature", &[]).unwrap();
2126 -
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h_b], "b edits a.txt, so it depends on a");
2127 -
    assert_eq!(repo.resolve_heads("main").unwrap(), vec![h_a], "main is untouched");
2128 -
    assert_eq!(repo.resolve_change("feature").unwrap(), h_b, "a one-head bookmark names its change");
3163 +
    assert_eq!(
3164 +
        repo.bookmark_heads().unwrap(),
3165 +
        vec![h_b],
3166 +
        "b edits a.txt, so it depends on a"
3167 +
    );
3168 +
    assert_eq!(
3169 +
        repo.resolve_heads("main").unwrap(),
3170 +
        vec![h_a],
3171 +
        "main is untouched"
3172 +
    );
3173 +
    assert_eq!(
3174 +
        repo.resolve_change("feature").unwrap(),
3175 +
        h_b,
3176 +
        "a one-head bookmark names its change"
3177 +
    );
2129 3178
    // Reopening keeps the workspace's bookmark.
2130 3179
    let reopened = Repo::open(&r).unwrap().with_home(root.join("home"));
2131 3180
    assert_eq!(reopened.config.bookmark, "feature");
…
4 unchanged lines
2136 3185
    write(&r, "c.txt", b"c\n");
2137 3186
    let h_c = repo.commit("c on main", &[]).unwrap();
2138 3187
    let list = repo.bookmarks().unwrap();
2139 -
    assert_eq!(list.iter().map(|b| (b.name.as_str(), b.current)).collect::<Vec<_>>(), vec![("feature", false), ("main", true)]);
2140 -
    assert_eq!(list.iter().find(|b| b.name == "main").unwrap().ahead, Some(1), "c is not on origin yet");
3188 +
    assert_eq!(
3189 +
        list.iter()
3190 +
            .map(|b| (b.name.as_str(), b.current))
3191 +
            .collect::<Vec<_>>(),
3192 +
        vec![("feature", false), ("main", true)]
3193 +
    );
3194 +
    assert_eq!(
3195 +
        list.iter().find(|b| b.name == "main").unwrap().ahead,
3196 +
        Some(1),
3197 +
        "c is not on origin yet"
3198 +
    );
2141 3199
    // Merge feature into main: the union, both heads, no merge change.
2142 3200
    let m = repo.merge("feature").unwrap();
2143 3201
    assert!(m.changed && m.checked_out);
…
7 unchanged lines
2151 3209
    assert!(repo.push_bookmark("origin", "feature", false).unwrap()[0].ok);
2152 3210
    assert_eq!(origin.resolve_heads("feature").unwrap(), vec![h_b]);
2153 3211
    assert!(repo.push("origin", false).unwrap()[0].ok);
2154 -
    assert_eq!(repo.bookmarks().unwrap().iter().find(|b| b.name == "main").unwrap().ahead, Some(0));
3212 +
    assert_eq!(
3213 +
        repo.bookmarks()
3214 +
            .unwrap()
3215 +
            .iter()
3216 +
            .find(|b| b.name == "main")
3217 +
            .unwrap()
3218 +
            .ahead,
3219 +
        Some(0)
3220 +
    );
2155 3221
    // A second workspace inherits the current bookmark and can switch independently.
2156 3222
    let ws = repo.workspace_add(&root.join("ws"), None).unwrap();
2157 3223
    assert_eq!(ws.config.bookmark, "main");
2158 3224
    let mut ws = ws;
2159 3225
    ws.bookmark_switch("feature", false).unwrap();
2160 -
    assert_eq!(Repo::open(&r).unwrap().config.bookmark, "main", "the first workspace is unaffected");
2161 -
    assert!(repo.bookmark_delete("feature").is_err(), "current bookmark of another workspace");
3226 +
    assert_eq!(
3227 +
        Repo::open(&r).unwrap().config.bookmark,
3228 +
        "main",
3229 +
        "the first workspace is unaffected"
3230 +
    );
3231 +
    assert!(
3232 +
        repo.bookmark_delete("feature").is_err(),
3233 +
        "current bookmark of another workspace"
3234 +
    );
2162 3235
    ws.bookmark_switch("main", false).unwrap();
2163 3236
    // Set and delete.
2164 3237
    assert_eq!(repo.bookmark_set("feature", &[h_a]).unwrap(), vec![h_a]);
…
1 unchanged lines
2166 3239
    repo.bookmark_delete("feature").unwrap();
2167 3240
    assert!(repo.resolve_heads("feature").is_err());
2168 3241
    assert!(repo.bookmark_set("feature", &[h_a]).is_err());
2169 -
    assert!(repo.checkout(&[h_a], false, "checkout").is_ok(), "changes of a deleted bookmark remain");
3242 +
    assert!(
3243 +
        repo.checkout(&[h_a], false, "checkout").is_ok(),
3244 +
        "changes of a deleted bookmark remain"
3245 +
    );
2170 3246
}
2171 3247
2172 3248
#[test]
…
4 unchanged lines
2177 3253
    // A library repository with two commits, and a bare-ish origin for it.
2178 3254
    let lib_origin = Repo::init(&root.join("lib-origin")).unwrap();
2179 3255
    let lib_url = root.join("lib-origin").to_string_lossy().to_string();
2180 -
    let lib = Repo::clone(&lib_url, &root.join("lib")).unwrap().with_home(root.join("home"));
3256 +
    let lib = Repo::clone(&lib_url, &root.join("lib"))
3257 +
        .unwrap()
3258 +
        .with_home(root.join("home"));
2181 3259
    write(&root.join("lib"), "lib.txt", b"v1\n");
2182 3260
    let l1 = lib.commit("lib v1", &[]).unwrap();
2183 3261
    assert!(lib.push("origin", false).unwrap()[0].ok);
…
4 unchanged lines
2188 3266
    let app_url = root.join("app-origin").to_string_lossy().to_string();
2189 3267
    let _ = app_origin;
2190 3268
    let app_dir = root.join("app");
2191 -
    let app = Repo::clone(&app_url, &app_dir).unwrap().with_home(root.join("home"));
3269 +
    let app = Repo::clone(&app_url, &app_dir)
3270 +
        .unwrap()
3271 +
        .with_home(root.join("home"));
2192 3272
    write(&app_dir, "main.txt", b"app\n");
2193 3273
    let pin = app.submodule_add(&lib_url, "libs/lib", None).unwrap();
2194 3274
    assert!(matches!(&pin, crate::subrepo::Pin::Oxy { heads, .. } if heads == &vec![l1]));
2195 3275
    assert!(app_dir.join(".oxymodules").exists() && app_dir.join("libs/lib/.oxy").is_dir());
2196 3276
    let snap = app.snapshot("status").unwrap();
2197 3277
    let paths: Vec<&str> = snap.statuses.iter().map(|s| s.path.as_str()).collect();
2198 -
    assert_eq!(paths, vec![".oxymodules", "libs/lib", "main.txt"], "the nested files are not walked");
3278 +
    assert_eq!(
3279 +
        paths,
3280 +
        vec![".oxymodules", "libs/lib", "main.txt"],
3281 +
        "the nested files are not walked"
3282 +
    );
2199 3283
    let a1 = app.commit("app with lib", &[]).unwrap();
2200 3284
    let files = app.load_state(&[a1]).unwrap().materialize().files;
2201 3285
    assert_eq!(files["libs/lib"].kind, NodeKind::Subrepo);
…
4 unchanged lines
2206 3290
    assert_eq!(subs[0].actual.as_ref(), Some(&subs[0].pin));
2207 3291
2208 3292
    // Advance the nested repository: the parent sees a modified pin.
2209 -
    let nested = Repo::open(&app_dir.join("libs/lib")).unwrap().with_home(root.join("home"));
3293 +
    let nested = Repo::open(&app_dir.join("libs/lib"))
3294 +
        .unwrap()
3295 +
        .with_home(root.join("home"));
2210 3296
    write(&app_dir.join("libs/lib"), "lib.txt", b"v2\n");
2211 3297
    let l2 = nested.commit("lib v2", &[]).unwrap();
2212 3298
    assert!(nested.push("origin", false).unwrap()[0].ok);
2213 3299
    let snap = app.snapshot("status").unwrap();
2214 -
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("libs/lib", ChangeStatus::Modified)]);
3300 +
    assert_eq!(
3301 +
        snap.statuses
3302 +
            .iter()
3303 +
            .map(|s| (s.path.as_str(), s.status))
3304 +
            .collect::<Vec<_>>(),
3305 +
        vec![("libs/lib", ChangeStatus::Modified)]
3306 +
    );
2215 3307
    let a2 = app.commit("bump lib", &[]).unwrap();
2216 3308
    // Checking out the older app state moves the nested repository back.
2217 3309
    let r = app.checkout(&[a1], false, "checkout").unwrap();
2218 3310
    assert!(r.notes.is_empty(), "{:?}", r.notes);
2219 3311
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
2220 -
    assert_eq!(Repo::open(&app_dir.join("libs/lib")).unwrap().bookmark_heads().unwrap(), vec![l1]);
3312 +
    assert_eq!(
3313 +
        Repo::open(&app_dir.join("libs/lib"))
3314 +
            .unwrap()
3315 +
            .bookmark_heads()
3316 +
            .unwrap(),
3317 +
        vec![l1]
3318 +
    );
2221 3319
    assert!(!app.snapshot("status").unwrap().changed);
2222 3320
    // A dirty nested repository is left alone and reported.
2223 3321
    write(&app_dir.join("libs/lib"), "scratch.txt", b"wip\n");
2224 3322
    let r = app.checkout(&[a2], false, "checkout").unwrap();
2225 -
    assert!(r.notes.iter().any(|n| n.contains("uncommitted")), "{:?}", r.notes);
3323 +
    assert!(
3324 +
        r.notes.iter().any(|n| n.contains("uncommitted")),
3325 +
        "{:?}",
3326 +
        r.notes
3327 +
    );
2226 3328
    assert_eq!(fs::read(app_dir.join("libs/lib/lib.txt")).unwrap(), b"v1\n");
2227 3329
    fs::remove_file(app_dir.join("libs/lib/scratch.txt")).unwrap();
2228 3330
    let r = app.submodule_update(false, &[]).unwrap();
…
4 unchanged lines
2233 3335
    // A fresh clone of the app has the pin but no nested repository until update --init.
2234 3336
    assert!(app.push("origin", false).unwrap()[0].ok);
2235 3337
    let clone_dir = root.join("app-clone");
2236 -
    let clone = Repo::clone(&app_url, &clone_dir).unwrap().with_home(root.join("home"));
3338 +
    let clone = Repo::clone(&app_url, &clone_dir)
3339 +
        .unwrap()
3340 +
        .with_home(root.join("home"));
2237 3341
    assert!(!clone_dir.join("libs/lib/.oxy").exists());
2238 -
    assert_eq!(clone.submodule_update(false, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Missing)]);
2239 -
    assert_eq!(clone.submodule_update(true, &[]).unwrap(), vec![("libs/lib".to_string(), SubrepoSync::Updated)]);
2240 -
    assert_eq!(fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(), b"v2\n");
3342 +
    assert_eq!(
3343 +
        clone.submodule_update(false, &[]).unwrap(),
3344 +
        vec![("libs/lib".to_string(), SubrepoSync::Missing)]
3345 +
    );
3346 +
    assert_eq!(
3347 +
        clone.submodule_update(true, &[]).unwrap(),
3348 +
        vec![("libs/lib".to_string(), SubrepoSync::Updated)]
3349 +
    );
3350 +
    assert_eq!(
3351 +
        fs::read(clone_dir.join("libs/lib/lib.txt")).unwrap(),
3352 +
        b"v2\n"
3353 +
    );
2241 3354
    assert!(!clone.snapshot("status").unwrap().changed);
2242 3355
    // Removing the subrepo leaves the directory and drops the node.
2243 3356
    clone.submodule_remove("libs/lib").unwrap();
2244 3357
    let snap = clone.snapshot("status").unwrap();
2245 -
    assert!(snap.statuses.iter().any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed), "{:?}", snap.statuses);
3358 +
    assert!(
3359 +
        snap.statuses
3360 +
            .iter()
3361 +
            .any(|s| s.path == "libs/lib" && s.status == ChangeStatus::Removed),
3362 +
        "{:?}",
3363 +
        snap.statuses
3364 +
    );
2246 3365
    let c = clone.commit("drop lib", &[]).unwrap();
2247 -
    assert!(!clone.load_state(&[c]).unwrap().materialize().files.contains_key("libs/lib"));
3366 +
    assert!(!clone
3367 +
        .load_state(&[c])
3368 +
        .unwrap()
3369 +
        .materialize()
3370 +
        .files
3371 +
        .contains_key("libs/lib"));
2248 3372
    assert!(clone_dir.join("libs/lib/lib.txt").exists());
2249 -
    assert!(!clone.snapshot("status").unwrap().changed, "an unregistered nested repository is ignored");
3373 +
    assert!(
3374 +
        !clone.snapshot("status").unwrap().changed,
3375 +
        "an unregistered nested repository is ignored"
3376 +
    );
2250 3377
}
2251 3378
2252 3379
#[test]
2253 3380
fn git_submodules_import_and_export_as_gitlinks() {
2254 3381
    hermetic();
2255 -
    if std::process::Command::new("git").arg("--version").output().is_err() {
3382 +
    if std::process::Command::new("git")
3383 +
        .arg("--version")
3384 +
        .output()
3385 +
        .is_err()
3386 +
    {
2256 3387
        eprintln!("git not installed; skipping");
2257 3388
        return;
2258 3389
    }
…
3 unchanged lines
2262 3393
        let out = std::process::Command::new("git")
2263 3394
            .arg("-C")
2264 3395
            .arg(d)
2265 -
            .args(["-c", "user.name=Grace", "-c", "[email protected]", "-c", "commit.gpgsign=false", "-c", "protocol.file.allow=always"])
3396 +
            .args([
3397 +
                "-c",
3398 +
                "user.name=Grace",
3399 +
                "-c",
3400 +
                "[email protected]",
3401 +
                "-c",
3402 +
                "commit.gpgsign=false",
3403 +
                "-c",
3404 +
                "protocol.file.allow=always",
3405 +
            ])
2266 3406
            .args(args)
2267 3407
            .output()
2268 3408
            .unwrap();
2269 -
        assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
3409 +
        assert!(
3410 +
            out.status.success(),
3411 +
            "git {args:?}: {}",
3412 +
            String::from_utf8_lossy(&out.stderr)
3413 +
        );
2270 3414
        String::from_utf8_lossy(&out.stdout).trim().to_string()
2271 3415
    };
2272 3416
    let sub = root.join("sub");
…
9 unchanged lines
2282 3426
    write(&g, "README.md", b"# app\n");
2283 3427
    git_in(&g, &["add", "."]);
2284 3428
    git_in(&g, &["commit", "-q", "-m", "initial"]);
2285 -
    git_in(&g, &["submodule", "add", "-q", sub.to_str().unwrap(), "vendor/sub"]);
3429 +
    git_in(
3430 +
        &g,
3431 +
        &[
3432 +
            "submodule",
3433 +
            "add",
3434 +
            "-q",
3435 +
            sub.to_str().unwrap(),
3436 +
            "vendor/sub",
3437 +
        ],
3438 +
    );
2286 3439
    git_in(&g, &["commit", "-q", "-m", "add submodule"]);
2287 3440
2288 3441
    let dest = root.join("oxy");
2289 3442
    let repo = Repo::init(&dest).unwrap().with_home(root.join("home"));
2290 3443
    let rep = repo.import_git(&g, "main").unwrap();
2291 3444
    assert_eq!((rep.commits, rep.submodules), (2, 1));
2292 -
    let files = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize().files;
3445 +
    let files = repo
3446 +
        .load_state(&repo.bookmark_heads().unwrap())
3447 +
        .unwrap()
3448 +
        .materialize()
3449 +
        .files;
2293 3450
    assert_eq!(files["vendor/sub"].kind, NodeKind::Subrepo);
2294 -
    let FileContent::Opaque { blobs } = &files["vendor/sub"].content else { panic!() };
2295 -
    assert_eq!(crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(), crate::subrepo::Pin::Git { sha: sub_sha.clone(), branch: "HEAD".into() });
2296 -
    let modules = crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap());
3451 +
    let FileContent::Opaque { blobs } = &files["vendor/sub"].content else {
3452 +
        panic!()
3453 +
    };
3454 +
    assert_eq!(
3455 +
        crate::subrepo::Pin::decode(&repo.read_blob(&blobs[0]).unwrap()).unwrap(),
3456 +
        crate::subrepo::Pin::Git {
3457 +
            sha: sub_sha.clone(),
3458 +
            branch: "HEAD".into()
3459 +
        }
3460 +
    );
3461 +
    let modules =
3462 +
        crate::subrepo::Modules::parse(&fs::read_to_string(dest.join(".oxymodules")).unwrap());
2297 3463
    assert_eq!(modules.entries["vendor/sub"].url, sub.to_string_lossy());
2298 3464
    assert!(modules.entries["vendor/sub"].git);
2299 -
    assert!(files.contains_key(".gitmodules"), "the Git file is kept for round trips");
2300 -
    assert!(!dest.join("vendor/sub/.git").exists(), "not cloned until update --init");
3465 +
    assert!(
3466 +
        files.contains_key(".gitmodules"),
3467 +
        "the Git file is kept for round trips"
3468 +
    );
3469 +
    assert!(
3470 +
        !dest.join("vendor/sub/.git").exists(),
3471 +
        "not cloned until update --init"
3472 +
    );
2301 3473
    assert!(!repo.snapshot("status").unwrap().changed);
2302 3474
    // update --init clones it at the pinned commit; status is unchanged.
2303 -
    assert_eq!(repo.submodule_update(true, &[]).unwrap(), vec![("vendor/sub".to_string(), SubrepoSync::Updated)]);
2304 -
    assert_eq!(git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]), sub_sha);
3475 +
    assert_eq!(
3476 +
        repo.submodule_update(true, &[]).unwrap(),
3477 +
        vec![("vendor/sub".to_string(), SubrepoSync::Updated)]
3478 +
    );
3479 +
    assert_eq!(
3480 +
        git_in(&dest.join("vendor/sub"), &["rev-parse", "HEAD"]),
3481 +
        sub_sha
3482 +
    );
2305 3483
    assert!(!repo.snapshot("status").unwrap().changed);
2306 3484
    // Export writes the gitlink and .gitmodules back.
2307 3485
    let mirror = root.join("mirror.git");
2308 3486
    let rep = repo.git_export(&mirror).unwrap();
2309 3487
    assert_eq!(rep.skipped_subrepos, 0);
2310 3488
    let ls = git_in(&mirror, &["ls-tree", "main", "vendor/sub"]);
2311 -
    assert!(ls.starts_with("160000 commit ") && ls.contains(&sub_sha), "{ls}");
2312 -
    assert_eq!(git_in(&mirror, &["ls-tree", "--name-only", "main"]), ".gitmodules\nREADME.md\nvendor");
3489 +
    assert!(
3490 +
        ls.starts_with("160000 commit ") && ls.contains(&sub_sha),
3491 +
        "{ls}"
3492 +
    );
3493 +
    assert_eq!(
3494 +
        git_in(&mirror, &["ls-tree", "--name-only", "main"]),
3495 +
        ".gitmodules\nREADME.md\nvendor"
3496 +
    );
2313 3497
    let orig_tree = git_in(&g, &["rev-parse", "main^{tree}"]);
2314 -
    assert_eq!(git_in(&mirror, &["rev-parse", "main^{tree}"]), orig_tree, "tree round trip");
3498 +
    assert_eq!(
3499 +
        git_in(&mirror, &["rev-parse", "main^{tree}"]),
3500 +
        orig_tree,
3501 +
        "tree round trip"
3502 +
    );
2315 3503
}
2316 3504
2317 3505
#[test]
…
9 unchanged lines
2327 3515
    write(root, "b.txt", b"b\nbb\n");
2328 3516
    write(root, "c.txt", b"c\n");
2329 3517
    // By path: only a.txt goes in; the rest stays on disk as uncommitted work.
2330 -
    let h1 = repo.commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()])).unwrap();
3518 +
    let h1 = repo
3519 +
        .commit_selection("a only", &[], &Selection::Paths(vec!["a.txt".into()]))
3520 +
        .unwrap();
2331 3521
    let c1 = repo.store().get_change(&h1).unwrap();
2332 -
    assert!(c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()), "{:?}", c1.tree_ops);
3522 +
    assert!(
3523 +
        c1.tree_ops.is_empty() && c1.edits.iter().all(|e| e.node() == c1.edits[0].node()),
3524 +
        "{:?}",
3525 +
        c1.tree_ops
3526 +
    );
2333 3527
    assert_eq!(c1.deps, vec![h0]);
2334 3528
    let files = repo.load_state(&[h1]).unwrap().materialize().files;
2335 -
    assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"));
3529 +
    assert!(
3530 +
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n")
3531 +
    );
2336 3532
    assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\n"));
2337 3533
    assert!(!files.contains_key("c.txt"));
2338 3534
    let snap = repo.snapshot("status").unwrap();
2339 -
    assert_eq!(snap.statuses.iter().map(|s| (s.path.as_str(), s.status)).collect::<Vec<_>>(), vec![("b.txt", ChangeStatus::Modified), ("c.txt", ChangeStatus::Added)]);
2340 -
    assert_eq!(fs::read(root.join("b.txt")).unwrap(), b"b\nbb\n", "disk untouched");
3535 +
    assert_eq!(
3536 +
        snap.statuses
3537 +
            .iter()
3538 +
            .map(|s| (s.path.as_str(), s.status))
3539 +
            .collect::<Vec<_>>(),
3540 +
        vec![
3541 +
            ("b.txt", ChangeStatus::Modified),
3542 +
            ("c.txt", ChangeStatus::Added)
3543 +
        ]
3544 +
    );
3545 +
    assert_eq!(
3546 +
        fs::read(root.join("b.txt")).unwrap(),
3547 +
        b"b\nbb\n",
3548 +
        "disk untouched"
3549 +
    );
2341 3550
    // By hunk: pick the b.txt insert; c.txt's addition is left.
2342 3551
    let (_, hunks) = repo.hunks().unwrap();
2343 -
    let b_hunk = hunks.iter().find(|h| h.path == "b.txt" && h.text.contains("+bb")).unwrap().index;
2344 -
    let h2 = repo.commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk])).unwrap();
3552 +
    let b_hunk = hunks
3553 +
        .iter()
3554 +
        .find(|h| h.path == "b.txt" && h.text.contains("+bb"))
3555 +
        .unwrap()
3556 +
        .index;
3557 +
    let h2 = repo
3558 +
        .commit_selection("b only", &[], &Selection::Hunks(vec![b_hunk]))
3559 +
        .unwrap();
2345 3560
    let files = repo.load_state(&[h2]).unwrap().materialize().files;
2346 -
    assert!(matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n"));
3561 +
    assert!(
3562 +
        matches!(&files["b.txt"].content, FileContent::Text { bytes, .. } if bytes == b"b\nbb\n")
3563 +
    );
2347 3564
    assert!(!files.contains_key("c.txt"));
2348 3565
    let snap = repo.snapshot("status").unwrap();
2349 -
    assert_eq!(snap.statuses.iter().map(|s| s.path.as_str()).collect::<Vec<_>>(), vec!["c.txt"]);
3566 +
    assert_eq!(
3567 +
        snap.statuses
3568 +
            .iter()
3569 +
            .map(|s| s.path.as_str())
3570 +
            .collect::<Vec<_>>(),
3571 +
        vec!["c.txt"]
3572 +
    );
2350 3573
    // Choosing only the content hunk of a file in a new directory pulls in
2351 3574
    // the directory and file additions it needs.
2352 3575
    write(root, "d/e.txt", b"e\n");
2353 3576
    let (_, hunks) = repo.hunks().unwrap();
2354 -
    let e_insert = hunks.iter().find(|h| h.path == "d/e.txt" && h.text.contains("+e")).unwrap().index;
2355 -
    let h3 = repo.commit_selection("e only", &[], &Selection::Hunks(vec![e_insert])).unwrap();
3577 +
    let e_insert = hunks
3578 +
        .iter()
3579 +
        .find(|h| h.path == "d/e.txt" && h.text.contains("+e"))
3580 +
        .unwrap()
3581 +
        .index;
3582 +
    let h3 = repo
3583 +
        .commit_selection("e only", &[], &Selection::Hunks(vec![e_insert]))
3584 +
        .unwrap();
2356 3585
    let c3 = repo.store().get_change(&h3).unwrap();
2357 3586
    assert_eq!(c3.tree_ops.len(), 2, "{:?}", c3.tree_ops);
2358 3587
    let files = repo.load_state(&[h3]).unwrap().materialize().files;
…
1 unchanged lines
2360 3589
    // Choosing a new file's addition hunk brings its content along.
2361 3590
    write(root, "f/g.txt", b"g\n");
2362 3591
    let (_, hunks) = repo.hunks().unwrap();
2363 -
    let g_add = hunks.iter().find(|h| h.path == "f/g.txt" && h.text.starts_with("A ")).unwrap().index;
2364 -
    let h4 = repo.commit_selection("g by addition", &[], &Selection::Hunks(vec![g_add])).unwrap();
3592 +
    let g_add = hunks
3593 +
        .iter()
3594 +
        .find(|h| h.path == "f/g.txt" && h.text.starts_with("A "))
3595 +
        .unwrap()
3596 +
        .index;
3597 +
    let h4 = repo
3598 +
        .commit_selection("g by addition", &[], &Selection::Hunks(vec![g_add]))
3599 +
        .unwrap();
2365 3600
    let files = repo.load_state(&[h4]).unwrap().materialize().files;
2366 -
    assert!(matches!(&files["f/g.txt"].content, FileContent::Text { bytes, .. } if bytes == b"g\n"));
2367 -
    assert!(!repo.snapshot("status").unwrap().statuses.iter().any(|s| s.path == "f/g.txt"));
3601 +
    assert!(
3602 +
        matches!(&files["f/g.txt"].content, FileContent::Text { bytes, .. } if bytes == b"g\n")
3603 +
    );
3604 +
    assert!(!repo
3605 +
        .snapshot("status")
3606 +
        .unwrap()
3607 +
        .statuses
3608 +
        .iter()
3609 +
        .any(|s| s.path == "f/g.txt"));
2368 3610
    // Everything left commits normally; the working copy is then clean.
2369 3611
    repo.commit("the rest", &[]).unwrap();
2370 3612
    assert!(!repo.snapshot("status").unwrap().changed);
2371 -
    let files = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize().files;
3613 +
    let files = repo
3614 +
        .load_state(&repo.bookmark_heads().unwrap())
3615 +
        .unwrap()
3616 +
        .materialize()
3617 +
        .files;
2372 3618
    assert!(files.contains_key("c.txt"));
2373 3619
}
2374 3620
…
3 unchanged lines
2378 3624
    let dir = tempfile::tempdir().unwrap();
2379 3625
    let root = dir.path();
2380 3626
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
2381 -
    repo.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
3627 +
    repo.create_identity("Ada", "[email protected]", IdentityScope::User)
3628 +
        .unwrap();
2382 3629
    write(root, "a.txt", b"one\n");
2383 3630
    let h1 = repo.commit("first", &[]).unwrap();
2384 3631
    write(root, "a.txt", b"one\ntwo\n");
2385 3632
    let h2 = repo.commit("second", &[]).unwrap();
2386 3633
    let b = repo.blame("a.txt").unwrap();
2387 -
    assert_eq!(b.iter().map(|l| (l.line, l.hash, l.text.as_slice())).collect::<Vec<_>>(), vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())]);
3634 +
    assert_eq!(
3635 +
        b.iter()
3636 +
            .map(|l| (l.line, l.hash, l.text.as_slice()))
3637 +
            .collect::<Vec<_>>(),
3638 +
        vec![(1, h1, b"one\n".as_slice()), (2, h2, b"two\n".as_slice())]
3639 +
    );
2388 3640
    assert!(b.iter().all(|l| l.author == "Ada"));
2389 -
    assert_eq!(b[0].change_id, repo.store().get_change(&h1).unwrap().change_id);
3641 +
    assert_eq!(
3642 +
        b[0].change_id,
3643 +
        repo.store().get_change(&h1).unwrap().change_id
3644 +
    );
2390 3645
    fs::rename(root.join("a.txt"), root.join("b.txt")).unwrap();
2391 3646
    repo.commit("rename", &[]).unwrap();
2392 3647
    let b = repo.blame("b.txt").unwrap();
2393 -
    assert_eq!(b.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![h1, h2], "the node survives the rename");
3648 +
    assert_eq!(
3649 +
        b.iter().map(|l| l.hash).collect::<Vec<_>>(),
3650 +
        vec![h1, h2],
3651 +
        "the node survives the rename"
3652 +
    );
2394 3653
    assert!(repo.blame("a.txt").is_err());
2395 3654
}
2396 3655
…
13 unchanged lines
2410 3669
    // Each step appends after the previous line, so the chain is linear.
2411 3670
    assert_eq!(repo.bookmark_heads().unwrap(), vec![hashes[7]]);
2412 3671
    repo.bisect_start().unwrap();
2413 -
    assert!(matches!(repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(), BisectStep::NeedMarks { good: 1, bad: 0 }));
3672 +
    assert!(matches!(
3673 +
        repo.bisect_mark(Some(true), &[hashes[0]]).unwrap(),
3674 +
        BisectStep::NeedMarks { good: 1, bad: 0 }
3675 +
    ));
2414 3676
    let step = repo.bisect_mark(Some(false), &[hashes[7]]).unwrap();
2415 -
    assert!(matches!(step, BisectStep::Testing { remaining: 7, .. }), "{step:?}");
3677 +
    assert!(
3678 +
        matches!(step, BisectStep::Testing { remaining: 7, .. }),
3679 +
        "{step:?}"
3680 +
    );
2416 3681
    // Automated: bad when the bug line is present.
2417 -
    let step = repo.bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()]).unwrap();
3682 +
    let step = repo
3683 +
        .bisect_run(&["sh".into(), "-c".into(), "! grep -q bug f.txt".into()])
3684 +
        .unwrap();
2418 3685
    assert_eq!(step, BisectStep::Found { culprit: hashes[4] });
2419 -
    assert_eq!(repo.bisect_status().unwrap(), Some(BisectStep::Found { culprit: hashes[4] }));
3686 +
    assert_eq!(
3687 +
        repo.bisect_status().unwrap(),
3688 +
        Some(BisectStep::Found { culprit: hashes[4] })
3689 +
    );
2420 3690
    repo.bisect_reset().unwrap();
2421 3691
    assert_eq!(fs::read_to_string(root.join("f.txt")).unwrap(), content);
2422 3692
    assert!(repo.bisect_status().unwrap().is_none());
…
28 unchanged lines
2451 3721
        guard += 1;
2452 3722
        assert!(guard < 20);
2453 3723
        let text = fs::read_to_string(root.join("f.txt")).unwrap();
2454 -
        let is_culprit_state = heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h));
2455 -
        step = if is_culprit_state { repo.bisect_mark(None, &[]).unwrap() } else { repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap() };
3724 +
        let is_culprit_state =
3725 +
            heads.contains(&hashes[4]) && !heads.iter().any(|h| hashes[5..].contains(h));
3726 +
        step = if is_culprit_state {
3727 +
            repo.bisect_mark(None, &[]).unwrap()
3728 +
        } else {
3729 +
            repo.bisect_mark(Some(!text.contains("bug")), &[]).unwrap()
3730 +
        };
2456 3731
    }
2457 3732
    match step {
2458 -
        BisectStep::Inconclusive { candidates } => assert!(candidates.contains(&hashes[4]) && candidates.len() <= 2, "{candidates:?}"),
3733 +
        BisectStep::Inconclusive { candidates } => assert!(
3734 +
            candidates.contains(&hashes[4]) && candidates.len() <= 2,
3735 +
            "{candidates:?}"
3736 +
        ),
2459 3737
        other => panic!("{other:?}"),
2460 3738
    }
2461 3739
    repo.bisect_reset().unwrap();
…
26 unchanged lines
2488 3766
    let heads = repo.bookmark_heads().unwrap();
2489 3767
    let st = repo.load_state(&heads).unwrap();
2490 3768
    assert!(!st.contains(&c) && !st.contains(&a) && !st.contains(&e));
2491 -
    let e2 = st.changes().find(|(_, m)| m.change_id == id_e).map(|(h, _)| *h).unwrap();
2492 -
    assert_eq!(repo.store().get_change(&e2).unwrap().deps, vec![a2], "E now depends on A' alone");
3769 +
    let e2 = st
3770 +
        .changes()
3771 +
        .find(|(_, m)| m.change_id == id_e)
3772 +
        .map(|(h, _)| *h)
3773 +
        .unwrap();
3774 +
    assert_eq!(
3775 +
        repo.store().get_change(&e2).unwrap().deps,
3776 +
        vec![a2],
3777 +
        "E now depends on A' alone"
3778 +
    );
2493 3779
    let files = st.materialize().files;
2494 -
    assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n"));
3780 +
    assert!(
3781 +
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"1\n2\n3\n")
3782 +
    );
2495 3783
    assert!(files.contains_key("b.txt"));
2496 3784
    assert!(st.contains(&b));
2497 3785
    assert!(!repo.snapshot("status").unwrap().changed);
…
31 unchanged lines
2529 3817
    let heads = repo.bookmark_heads().unwrap();
2530 3818
    let st = repo.load_state(&heads).unwrap();
2531 3819
    assert!(!st.contains(&c) && st.contains(&d));
2532 -
    let e2 = st.changes().find(|(_, m)| m.change_id == id_e).map(|(h, _)| *h).unwrap();
3820 +
    let e2 = st
3821 +
        .changes()
3822 +
        .find(|(_, m)| m.change_id == id_e)
3823 +
        .map(|(h, _)| *h)
3824 +
        .unwrap();
2533 3825
    let e2deps = repo.store().get_change(&e2).unwrap().deps;
2534 -
    assert!(e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a), "E follows C by content and still depends on A, which introduced the file: {e2deps:?}");
3826 +
    assert!(
3827 +
        e2deps.contains(&c2) && !e2deps.contains(&c) && e2deps.contains(&a),
3828 +
        "E follows C by content and still depends on A, which introduced the file: {e2deps:?}"
3829 +
    );
2535 3830
    let files = st.materialize().files;
2536 -
    assert!(matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"), "{:?}", files["a.txt"].content);
3831 +
    assert!(
3832 +
        matches!(&files["a.txt"].content, FileContent::Text { bytes, .. } if bytes == b"0\n1\n2\n3\n4\n"),
3833 +
        "{:?}",
3834 +
        files["a.txt"].content
3835 +
    );
2537 3836
    assert!(!files["a.txt"].is_conflicted());
2538 3837
    // Rebasing onto a state that already contains the change is refused.
2539 3838
    assert!(repo.rebase(&c2, &[c2]).is_err());
2540 3839
    let err = repo.rebase(&e2, &[d]).unwrap_err().to_string();
2541 -
    assert!(err.contains("lacks dependencies"), "E needs C, which D's state does not have: {err}");
3840 +
    assert!(
3841 +
        err.contains("lacks dependencies"),
3842 +
        "E needs C, which D's state does not have: {err}"
3843 +
    );
2542 3844
}
2543 3845
2544 3846
#[test]
…
5 unchanged lines
2550 3852
    write(root, "a.txt", b"a\n");
2551 3853
    repo.commit("a", &[]).unwrap();
2552 3854
    assert!(repo.secret_key().unwrap().is_none());
2553 -
    repo.tag_create("v1", &repo.bookmark_heads().unwrap(), "first release").unwrap();
3855 +
    repo.tag_create("v1", &repo.bookmark_heads().unwrap(), "first release")
3856 +
        .unwrap();
2554 3857
    let tags = repo.tags().unwrap();
2555 -
    assert_eq!((tags[0].name.as_str(), tags[0].message.as_str(), tags[0].signer.as_deref()), ("v1", "first release", None));
2556 -
    assert!(repo.tag_refs().unwrap()[0].attestation.is_none(), "an unsigned attestation is not advertised");
3858 +
    assert_eq!(
3859 +
        (
3860 +
            tags[0].name.as_str(),
3861 +
            tags[0].message.as_str(),
3862 +
            tags[0].signer.as_deref()
3863 +
        ),
3864 +
        ("v1", "first release", None)
3865 +
    );
3866 +
    assert!(
3867 +
        repo.tag_refs().unwrap()[0].attestation.is_none(),
3868 +
        "an unsigned attestation is not advertised"
3869 +
    );
2557 3870
}
2558 -
modified crates/oxy-core/src/repo/workspace.rs +35 −7
…
54 unchanged lines
55 55
56 56
    /// The directory of a workspace, if registered.
57 57
    pub fn workspace_path(&self, id: &WorkspaceId) -> Option<PathBuf> {
58 -
        fs::read_to_string(self.workspace_dir(id).join("path")).ok().map(|s| PathBuf::from(s.trim()))
58 +
        fs::read_to_string(self.workspace_dir(id).join("path"))
59 +
            .ok()
60 +
            .map(|s| PathBuf::from(s.trim()))
59 61
    }
60 62
61 63
    /// Every workspace this repository knows about.
…
14 unchanged lines
76 78
            let path = if id == self.workspace {
77 79
                self.root.clone()
78 80
            } else {
79 -
                self.workspace_path(&id).unwrap_or_else(|| PathBuf::from("(unknown)"))
81 +
                self.workspace_path(&id)
82 +
                    .unwrap_or_else(|| PathBuf::from("(unknown)"))
80 83
            };
81 -
            out.push(WorkspaceInfo { id, path, working_copy: view.workspaces.get(&id).copied(), current: id == self.workspace });
84 +
            out.push(WorkspaceInfo {
85 +
                id,
86 +
                path,
87 +
                working_copy: view.workspaces.get(&id).copied(),
88 +
                current: id == self.workspace,
89 +
            });
82 90
        }
83 91
        Ok(out)
84 92
    }
…
19 unchanged lines
104 112
        let (op_ref, op) = self.head_op()?;
105 113
        let mut view = op.view.clone();
106 114
        view.workspaces.insert(id, wc_hash);
107 -
        self.record_op(&[op_ref], &format!("oxy workspace add {}", dir.display()), view)?;
115 +
        self.record_op(
116 +
            &[op_ref],
117 +
            &format!("oxy workspace add {}", dir.display()),
118 +
            view,
119 +
        )?;
108 120
        self.register_workspace(&id, &dir)?;
109 121
        self.set_workspace_bookmark(&id, &self.config.bookmark)?;
110 -
        write_atomic(&dir.join(OXY_DIR), format!("oxydir: {}\nworkspace: {}\n", self.oxy.display(), id.to_hex()).as_bytes())?;
122 +
        write_atomic(
123 +
            &dir.join(OXY_DIR),
124 +
            format!(
125 +
                "oxydir: {}\nworkspace: {}\n",
126 +
                self.oxy.display(),
127 +
                id.to_hex()
128 +
            )
129 +
            .as_bytes(),
130 +
        )?;
111 131
        let repo = Repo::open(&dir)?.with_home(self.keys_dir.parent().unwrap_or(&self.keys_dir));
112 132
        repo.checkout(&heads, true, "oxy workspace add (checkout)")?;
113 133
        Ok(repo)
…
9 unchanged lines
123 143
        if view.workspaces.remove(id).is_none() && self.workspace_path(id).is_none() {
124 144
            return fail(format!("no workspace {}", id.short(8)));
125 145
        }
126 -
        self.record_op(&[op_ref], &format!("oxy workspace forget {}", id.short(8)), view)?;
146 +
        self.record_op(
147 +
            &[op_ref],
148 +
            &format!("oxy workspace forget {}", id.short(8)),
149 +
            view,
150 +
        )?;
127 151
        let _ = fs::remove_dir_all(self.workspace_dir(id));
128 152
        Ok(())
129 153
    }
…
4 unchanged lines
134 158
        fs::create_dir_all(&dir)?;
135 159
        let path = dir.join("lock");
136 160
        for _ in 0..50 {
137 -
            match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
161 +
            match fs::OpenOptions::new()
162 +
                .write(true)
163 +
                .create_new(true)
164 +
                .open(&path)
165 +
            {
138 166
                Ok(mut f) => {
139 167
                    let _ = write!(f, "{}", std::process::id());
140 168
                    return Ok(SnapshotLock { path });
…
18 unchanged lines
modified crates/oxy-core/src/rewrite.rs +377 −64
…
72 unchanged lines
73 73
        }
74 74
        let mut ops = HashMap::new();
75 75
        for (i, op) in old.tree_ops.iter().enumerate() {
76 -
            if let Some(j) = new.tree_ops.iter().position(|n| n.node() == op.node() && std::mem::discriminant(n) == std::mem::discriminant(op)) {
76 +
            if let Some(j) = new.tree_ops.iter().position(|n| {
77 +
                n.node() == op.node() && std::mem::discriminant(n) == std::mem::discriminant(op)
78 +
            }) {
77 79
                ops.insert(i as u32, j as u32);
78 80
            }
79 81
        }
…
29 unchanged lines
109 111
    let mut per_node: HashMap<crate::ids::NodeId, u32> = HashMap::new();
110 112
    let mut out = Vec::new();
111 113
    for e in &c.edits {
112 -
        if let Edit::Insert { node, after, before, lines } = e {
114 +
        if let Edit::Insert {
115 +
            node,
116 +
            after,
117 +
            before,
118 +
            lines,
119 +
        } = e
120 +
        {
113 121
            let idx = per_node.entry(*node).or_default();
114 -
            out.push(Chain { node: *node, first: *idx, last: *idx + lines.len() as u32 - 1, after: *after, before: *before });
122 +
            out.push(Chain {
123 +
                node: *node,
124 +
                first: *idx,
125 +
                last: *idx + lines.len() as u32 - 1,
126 +
                after: *after,
127 +
                before: *before,
128 +
            });
115 129
            *idx += lines.len() as u32;
116 130
        }
117 131
    }
…
15 unchanged lines
133 147
/// orphaned references.
134 148
///
135 149
/// `load` fetches a change's full object (a state keeps only metadata).
136 -
pub fn propagate(state: &State, old: ChangeHash, new: Option<&Change>, corr: &Correspondence, load: &dyn Fn(&ChangeHash) -> Option<Change>) -> Result<Propagation, RewriteError> {
150 +
pub fn propagate(
151 +
    state: &State,
152 +
    old: ChangeHash,
153 +
    new: Option<&Change>,
154 +
    corr: &Correspondence,
155 +
    load: &dyn Fn(&ChangeHash) -> Option<Change>,
156 +
) -> Result<Propagation, RewriteError> {
137 157
    let old_meta = state.get(&old).ok_or(RewriteError::Missing(old))?;
138 158
    let old_full = load(&old);
139 159
    let mut mapping: HashMap<ChangeHash, Replacement> = HashMap::new();
140 -
    mapping.insert(old, match new {
141 -
        Some(n) => Replacement::Change(n.hash()),
142 -
        None => Replacement::Removed(old_meta.deps.clone()),
143 -
    });
160 +
    mapping.insert(
161 +
        old,
162 +
        match new {
163 +
            Some(n) => Replacement::Change(n.hash()),
164 +
            None => Replacement::Removed(old_meta.deps.clone()),
165 +
        },
166 +
    );
144 167
    let mut corrs: HashMap<ChangeHash, Correspondence> = HashMap::new();
145 168
    corrs.insert(old, corr.clone());
146 169
    // Chains of the rewritten change, for re-anchoring; vanished nodes.
147 -
    let chains: HashMap<ChangeHash, Vec<Chain>> = old_full.as_ref().map(|c| (old, chains_of(c))).into_iter().collect();
170 +
    let chains: HashMap<ChangeHash, Vec<Chain>> = old_full
171 +
        .as_ref()
172 +
        .map(|c| (old, chains_of(c)))
173 +
        .into_iter()
174 +
        .collect();
148 175
    let mut vanished_nodes: BTreeSet<crate::ids::NodeId> = BTreeSet::new();
149 176
    if let Some(c) = &old_full {
150 177
        for (i, op) in c.tree_ops.iter().enumerate() {
…
9 unchanged lines
160 187
161 188
    // Dependents in topological order: every change whose closure has `old`,
162 189
    // sorted so that dependencies come first.
163 -
    let mut dependents: Vec<ChangeHash> = state.changes().filter(|(h, _)| **h != old && state.closure(h).contains(&old)).map(|(h, _)| *h).collect();
190 +
    let mut dependents: Vec<ChangeHash> = state
191 +
        .changes()
192 +
        .filter(|(h, _)| **h != old && state.closure(h).contains(&old))
193 +
        .map(|(h, _)| *h)
194 +
        .collect();
164 195
    dependents.sort_by_key(|h| (state.closure(h).len(), *h));
165 196
166 197
    let mut out = Vec::new();
167 198
    for h in dependents {
168 199
        let c = load(&h).ok_or(RewriteError::Missing(h))?;
169 -
        let rewritten = rewrite_one(&h, &c, &mapping, &corrs, &chains, &vanished_nodes, &mut notes);
200 +
        let rewritten = rewrite_one(
201 +
            &h,
202 +
            &c,
203 +
            &mapping,
204 +
            &corrs,
205 +
            &chains,
206 +
            &vanished_nodes,
207 +
            &mut notes,
208 +
        );
170 209
        let rh = rewritten.hash();
171 210
        corrs.insert(h, Correspondence::identity(&rewritten));
172 211
        mapping.insert(h, Replacement::Change(rh));
173 212
        out.push((h, rewritten));
174 213
    }
175 -
    Ok(Propagation { changes: out, notes })
214 +
    Ok(Propagation {
215 +
        changes: out,
216 +
        notes,
217 +
    })
176 218
}
177 219
178 220
/// Re-anchor a reference to a vanished atom of `change`: the nearest
179 221
/// surviving atom of its chain (before it for `after` anchors, after it for
180 222
/// `before` anchors), else the chain's own anchor.
181 -
fn reanchor(change: &ChangeHash, index: u32, want_after: bool, mapping: &HashMap<ChangeHash, Replacement>, corrs: &HashMap<ChangeHash, Correspondence>, chains: &HashMap<ChangeHash, Vec<Chain>>) -> AtomRef {
223 +
fn reanchor(
224 +
    change: &ChangeHash,
225 +
    index: u32,
226 +
    want_after: bool,
227 +
    mapping: &HashMap<ChangeHash, Replacement>,
228 +
    corrs: &HashMap<ChangeHash, Correspondence>,
229 +
    chains: &HashMap<ChangeHash, Vec<Chain>>,
230 +
) -> AtomRef {
182 231
    let corr = corrs.get(change);
183 -
    let chain = chains.get(change).and_then(|cs| cs.iter().find(|ch| ch.first <= index && index <= ch.last));
184 -
    if let (Some(Replacement::Change(nh)), Some(corr), Some(ch)) = (mapping.get(change), corr, chain) {
185 -
        let candidates: Box<dyn Iterator<Item = u32>> = if want_after { Box::new((ch.first..index).rev()) } else { Box::new(index + 1..=ch.last) };
232 +
    let chain = chains
233 +
        .get(change)
234 +
        .and_then(|cs| cs.iter().find(|ch| ch.first <= index && index <= ch.last));
235 +
    if let (Some(Replacement::Change(nh)), Some(corr), Some(ch)) =
236 +
        (mapping.get(change), corr, chain)
237 +
    {
238 +
        let candidates: Box<dyn Iterator<Item = u32>> = if want_after {
239 +
            Box::new((ch.first..index).rev())
240 +
        } else {
241 +
            Box::new(index + 1..=ch.last)
242 +
        };
186 243
        for j in candidates {
187 244
            if let Some(nj) = corr.atoms.get(&(ch.node, j)) {
188 -
                return AtomRef::Atom { change: *nh, index: *nj };
245 +
                return AtomRef::Atom {
246 +
                    change: *nh,
247 +
                    index: *nj,
248 +
                };
189 249
            }
190 250
        }
191 251
    }
192 252
    match chain {
193 -
        Some(ch) => if want_after { ch.after } else { ch.before },
194 -
        None => if want_after { AtomRef::Start } else { AtomRef::End },
253 +
        Some(ch) => {
254 +
            if want_after {
255 +
                ch.after
256 +
            } else {
257 +
                ch.before
258 +
            }
259 +
        }
260 +
        None => {
261 +
            if want_after {
262 +
                AtomRef::Start
263 +
            } else {
264 +
                AtomRef::End
265 +
            }
266 +
        }
195 267
    }
196 268
}
197 269
…
8 unchanged lines
206 278
) -> Change {
207 279
    let short = hash.short(12);
208 280
    // An anchor: translate, or re-anchor when its counterpart is gone.
209 -
    let map_anchor = |r: AtomRef, node: &crate::ids::NodeId, want_after: bool, notes: &mut Vec<String>| -> AtomRef {
281 +
    let map_anchor = |r: AtomRef,
282 +
                      node: &crate::ids::NodeId,
283 +
                      want_after: bool,
284 +
                      notes: &mut Vec<String>|
285 +
     -> AtomRef {
210 286
        match r {
211 287
            AtomRef::Atom { change, index } => match mapping.get(&change) {
212 288
                Some(Replacement::Change(nh)) => match corrs[&change].atoms.get(&(*node, index)) {
213 -
                    Some(idx) => AtomRef::Atom { change: *nh, index: *idx },
289 +
                    Some(idx) => AtomRef::Atom {
290 +
                        change: *nh,
291 +
                        index: *idx,
292 +
                    },
214 293
                    None => {
215 294
                        notes.push(format!("change {short}: an anchor line of the rewritten change vanished; re-anchored to its nearest neighbour"));
216 295
                        reanchor(&change, index, want_after, mapping, corrs, chains)
…
11 unchanged lines
228 307
    // A tree-operation reference: translate, or `None` when gone.
229 308
    let map_op = |r: OpRef| -> Option<OpRef> {
230 309
        match mapping.get(&r.change) {
231 -
            Some(Replacement::Change(nh)) => corrs[&r.change].ops.get(&r.index).map(|idx| OpRef { change: *nh, index: *idx }),
310 +
            Some(Replacement::Change(nh)) => corrs[&r.change].ops.get(&r.index).map(|idx| OpRef {
311 +
                change: *nh,
312 +
                index: *idx,
313 +
            }),
232 314
            Some(Replacement::Removed(_)) => None,
233 315
            None => Some(r),
234 316
        }
…
15 unchanged lines
250 332
    let mut ops = Vec::new();
251 333
    for op in &out.tree_ops {
252 334
        let node = op.node();
253 -
        if vanished_nodes.contains(&node) || matches!(op, TreeOp::AddNode { parent, .. } if vanished_nodes.contains(parent)) {
335 +
        if vanished_nodes.contains(&node)
336 +
            || matches!(op, TreeOp::AddNode { parent, .. } if vanished_nodes.contains(parent))
337 +
        {
254 338
            notes.push(format!("change {short}: an operation on a file that vanished with the rewritten change was dropped"));
255 339
            continue;
256 340
        }
257 341
        match op {
258 342
            TreeOp::RemoveNode { node, placement } => match map_op(*placement) {
259 -
                Some(p) => ops.push(TreeOp::RemoveNode { node: *node, placement: p }),
260 -
                None => notes.push(format!("change {short}: removal of a placement that vanished was dropped")),
343 +
                Some(p) => ops.push(TreeOp::RemoveNode {
344 +
                    node: *node,
345 +
                    placement: p,
346 +
                }),
347 +
                None => notes.push(format!(
348 +
                    "change {short}: removal of a placement that vanished was dropped"
349 +
                )),
261 350
            },
262 -
            TreeOp::Move { node, old, parent, name } => match map_op(*old) {
263 -
                Some(o) => ops.push(TreeOp::Move { node: *node, old: o, parent: *parent, name: name.clone() }),
264 -
                None => notes.push(format!("change {short}: a move of {name} lost its origin and was dropped")),
351 +
            TreeOp::Move {
352 +
                node,
353 +
                old,
354 +
                parent,
355 +
                name,
356 +
            } => match map_op(*old) {
357 +
                Some(o) => ops.push(TreeOp::Move {
358 +
                    node: *node,
359 +
                    old: o,
360 +
                    parent: *parent,
361 +
                    name: name.clone(),
362 +
                }),
363 +
                None => notes.push(format!(
364 +
                    "change {short}: a move of {name} lost its origin and was dropped"
365 +
                )),
265 366
            },
266 367
            TreeOp::SetMode { node, old, mode } => match map_op(*old) {
267 -
                Some(o) => ops.push(TreeOp::SetMode { node: *node, old: o, mode: *mode }),
268 -
                None => notes.push(format!("change {short}: a mode change lost its origin and was dropped")),
368 +
                Some(o) => ops.push(TreeOp::SetMode {
369 +
                    node: *node,
370 +
                    old: o,
371 +
                    mode: *mode,
372 +
                }),
373 +
                None => notes.push(format!(
374 +
                    "change {short}: a mode change lost its origin and was dropped"
375 +
                )),
269 376
            },
270 377
            add @ TreeOp::AddNode { .. } => ops.push(add.clone()),
271 378
        }
…
6 unchanged lines
278 385
            continue;
279 386
        }
280 387
        match e {
281 -
            Edit::Insert { node, after, before, lines } => edits.push(Edit::Insert {
388 +
            Edit::Insert {
389 +
                node,
390 +
                after,
391 +
                before,
392 +
                lines,
393 +
            } => edits.push(Edit::Insert {
282 394
                node: *node,
283 395
                after: map_anchor(*after, node, true, notes),
284 396
                before: map_anchor(*before, node, false, notes),
…
5 unchanged lines
290 402
                for r in atoms.iter() {
291 403
                    for i in r.from..r.to {
292 404
                        match mapping.get(&r.change) {
293 -
                            Some(Replacement::Change(nh)) => match corrs[&r.change].atoms.get(&(*node, i)) {
294 -
                                Some(idx) => mapped.push((*nh, *idx)),
295 -
                                None => dropped += 1,
296 -
                            },
405 +
                            Some(Replacement::Change(nh)) => {
406 +
                                match corrs[&r.change].atoms.get(&(*node, i)) {
407 +
                                    Some(idx) => mapped.push((*nh, *idx)),
408 +
                                    None => dropped += 1,
409 +
                                }
410 +
                            }
297 411
                            Some(Replacement::Removed(_)) => dropped += 1,
298 412
                            None => mapped.push((r.change, i)),
299 413
                        }
300 414
                    }
301 415
                }
302 416
                if dropped > 0 {
303 -
                    notes.push(format!("change {short}: {dropped} deletion(s) of lines that vanished were dropped"));
417 +
                    notes.push(format!(
418 +
                        "change {short}: {dropped} deletion(s) of lines that vanished were dropped"
419 +
                    ));
304 420
                }
305 421
                let mut ranges: Vec<AtomRange> = Vec::new();
306 422
                for (ch, i) in mapped {
307 423
                    match ranges.last_mut() {
308 424
                        Some(r) if r.change == ch && r.to == i => r.to = i + 1,
309 -
                        _ => ranges.push(AtomRange { change: ch, from: i, to: i + 1 }),
425 +
                        _ => ranges.push(AtomRange {
426 +
                            change: ch,
427 +
                            from: i,
428 +
                            to: i + 1,
429 +
                        }),
310 430
                    }
311 431
                }
312 432
                if !ranges.is_empty() {
313 -
                    edits.push(Edit::Delete { node: *node, atoms: ranges });
433 +
                    edits.push(Edit::Delete {
434 +
                        node: *node,
435 +
                        atoms: ranges,
436 +
                    });
314 437
                }
315 438
            }
316 439
            other => edits.push(other.clone()),
…
13 unchanged lines
330 453
    use crate::state::FileContent;
331 454
332 455
    fn change(deps: Vec<ChangeHash>, tree_ops: Vec<TreeOp>, edits: Vec<Edit>, msg: &str) -> Change {
333 -
        let mut c = Change { change_id: ChangeId::random(), deps, author: ObjectRef::ZERO, hlc: Hlc { wall_ms: 1, logical: 0 }, message: msg.into(), tree_ops, edits, kind: ChangeKind::Normal };
456 +
        let mut c = Change {
457 +
            change_id: ChangeId::random(),
458 +
            deps,
459 +
            author: ObjectRef::ZERO,
460 +
            hlc: Hlc {
461 +
                wall_ms: 1,
462 +
                logical: 0,
463 +
            },
464 +
            message: msg.into(),
465 +
            tree_ops,
466 +
            edits,
467 +
            kind: ChangeKind::Normal,
468 +
        };
334 469
        c.normalize();
335 470
        c
336 471
    }
…
2 unchanged lines
339 474
    fn describe_propagates_through_dependents() {
340 475
        let mut st = State::new();
341 476
        let f = NodeId::random();
342 -
        let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one");
477 +
        let c1 = change(
478 +
            vec![],
479 +
            vec![TreeOp::AddNode {
480 +
                node: f,
481 +
                kind: NodeKind::File,
482 +
                mode: Mode::TEXT,
483 +
                parent: NodeId::ROOT,
484 +
                name: "f".into(),
485 +
            }],
486 +
            vec![Edit::Insert {
487 +
                node: f,
488 +
                after: AtomRef::Start,
489 +
                before: AtomRef::End,
490 +
                lines: vec![b"a\n".to_vec(), b"b\n".to_vec()],
491 +
            }],
492 +
            "one",
493 +
        );
343 494
        let h1 = st.add(c1.clone()).unwrap();
344 -
        let c2 = change(vec![h1], vec![], vec![Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] }, Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two");
495 +
        let c2 = change(
496 +
            vec![h1],
497 +
            vec![],
498 +
            vec![
499 +
                Edit::Delete {
500 +
                    node: f,
501 +
                    atoms: vec![AtomRange {
502 +
                        change: h1,
503 +
                        from: 0,
504 +
                        to: 1,
505 +
                    }],
506 +
                },
507 +
                Edit::Insert {
508 +
                    node: f,
509 +
                    after: AtomRef::Atom {
510 +
                        change: h1,
511 +
                        index: 1,
512 +
                    },
513 +
                    before: AtomRef::End,
514 +
                    lines: vec![b"c\n".to_vec()],
515 +
                },
516 +
            ],
517 +
            "two",
518 +
        );
345 519
        let h2 = st.add(c2.clone()).unwrap();
346 -
        let c3 = change(vec![h2], vec![TreeOp::SetMode { node: f, old: OpRef { change: h1, index: 0 }, mode: Mode { exec: true, opaque: false } }], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h2, index: 0 }, before: AtomRef::End, lines: vec![b"d\n".to_vec()] }], "three");
520 +
        let c3 = change(
521 +
            vec![h2],
522 +
            vec![TreeOp::SetMode {
523 +
                node: f,
524 +
                old: OpRef {
525 +
                    change: h1,
526 +
                    index: 0,
527 +
                },
528 +
                mode: Mode {
529 +
                    exec: true,
530 +
                    opaque: false,
531 +
                },
532 +
            }],
533 +
            vec![Edit::Insert {
534 +
                node: f,
535 +
                after: AtomRef::Atom {
536 +
                    change: h2,
537 +
                    index: 0,
538 +
                },
539 +
                before: AtomRef::End,
540 +
                lines: vec![b"d\n".to_vec()],
541 +
            }],
542 +
            "three",
543 +
        );
347 544
        let h3 = st.add(c3.clone()).unwrap();
348 545
        let before = st.materialize();
349 546
350 547
        // Rewrite c1's message; everything downstream follows.
351 548
        let mut n1 = c1.clone();
352 549
        n1.message = "one, described".into();
353 -
        let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2.clone()), (h3, c3.clone())].into_iter().collect();
550 +
        let all: HashMap<ChangeHash, Change> =
551 +
            [(h1, c1.clone()), (h2, c2.clone()), (h3, c3.clone())]
552 +
                .into_iter()
553 +
                .collect();
354 554
        let load = |h: &ChangeHash| all.get(h).cloned();
355 -
        let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load).unwrap().changes;
356 -
        assert_eq!(rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(), vec![h2, h3]);
555 +
        let rewritten = propagate(&st, h1, Some(&n1), &Correspondence::identity(&c1), &load)
556 +
            .unwrap()
557 +
            .changes;
558 +
        assert_eq!(
559 +
            rewritten.iter().map(|(h, _)| *h).collect::<Vec<_>>(),
560 +
            vec![h2, h3]
561 +
        );
357 562
        let mut st2 = State::new();
358 563
        st2.add(n1.clone()).unwrap();
359 564
        for (_, c) in &rewritten {
…
3 unchanged lines
363 568
        let after = st2.materialize();
364 569
        assert_eq!(after.files["f"].mode, before.files["f"].mode);
365 570
        match (&after.files["f"].content, &before.files["f"].content) {
366 -
            (FileContent::Text { bytes: a, .. }, FileContent::Text { bytes: b, .. }) => assert_eq!(a, b),
571 +
            (FileContent::Text { bytes: a, .. }, FileContent::Text { bytes: b, .. }) => {
572 +
                assert_eq!(a, b)
573 +
            }
367 574
            _ => panic!(),
368 575
        }
369 -
        assert!(matches!(&after.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"b\nc\nd\n"));
576 +
        assert!(
577 +
            matches!(&after.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"b\nc\nd\n")
578 +
        );
370 579
        // ChangeIds survive; hashes change; deps point at the new hashes.
371 580
        assert_eq!(rewritten[0].1.change_id, c2.change_id);
372 581
        assert_eq!(rewritten[0].1.deps, vec![n1.hash()]);
…
5 unchanged lines
378 587
    fn missing_counterpart_is_reanchored() {
379 588
        let mut st = State::new();
380 589
        let f = NodeId::random();
381 -
        let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"b\n".to_vec()] }], "one");
590 +
        let c1 = change(
591 +
            vec![],
592 +
            vec![TreeOp::AddNode {
593 +
                node: f,
594 +
                kind: NodeKind::File,
595 +
                mode: Mode::TEXT,
596 +
                parent: NodeId::ROOT,
597 +
                name: "f".into(),
598 +
            }],
599 +
            vec![Edit::Insert {
600 +
                node: f,
601 +
                after: AtomRef::Start,
602 +
                before: AtomRef::End,
603 +
                lines: vec![b"a\n".to_vec(), b"b\n".to_vec()],
604 +
            }],
605 +
            "one",
606 +
        );
382 607
        let h1 = st.add(c1.clone()).unwrap();
383 -
        let c2 = change(vec![h1], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"c\n".to_vec()] }], "two");
608 +
        let c2 = change(
609 +
            vec![h1],
610 +
            vec![],
611 +
            vec![Edit::Insert {
612 +
                node: f,
613 +
                after: AtomRef::Atom {
614 +
                    change: h1,
615 +
                    index: 1,
616 +
                },
617 +
                before: AtomRef::End,
618 +
                lines: vec![b"c\n".to_vec()],
619 +
            }],
620 +
            "two",
621 +
        );
384 622
        let h2 = st.add(c2.clone()).unwrap();
385 623
        let all: HashMap<ChangeHash, Change> = [(h1, c1.clone()), (h2, c2)].into_iter().collect();
386 624
        let load = |h: &ChangeHash| all.get(h).cloned();
387 625
        // The rewritten c1 drops line "b": atom 1 has no counterpart, so c2's
388 626
        // insert after "b" is re-anchored after "a", the nearest survivor.
389 627
        let mut n1 = c1.clone();
390 -
        n1.edits = vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec()] }];
391 -
        let corr = Correspondence { atoms: [((f, 0), 0)].into_iter().collect(), ops: [(0, 0)].into_iter().collect() };
628 +
        n1.edits = vec![Edit::Insert {
629 +
            node: f,
630 +
            after: AtomRef::Start,
631 +
            before: AtomRef::End,
632 +
            lines: vec![b"a\n".to_vec()],
633 +
        }];
634 +
        let corr = Correspondence {
635 +
            atoms: [((f, 0), 0)].into_iter().collect(),
636 +
            ops: [(0, 0)].into_iter().collect(),
637 +
        };
392 638
        let p = propagate(&st, h1, Some(&n1), &corr, &load).unwrap();
393 639
        assert_eq!(p.notes.len(), 1, "{:?}", p.notes);
394 640
        let n1h = n1.hash();
395 -
        assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Atom { change, index: 0 }, before: AtomRef::End, .. } if change == n1h));
641 +
        assert!(
642 +
            matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Atom { change, index: 0 }, before: AtomRef::End, .. } if change == n1h)
643 +
        );
396 644
        let mut st2 = State::new();
397 645
        st2.add(n1.clone()).unwrap();
398 646
        st2.add(p.changes[0].1.clone()).unwrap();
399 647
        let m = st2.materialize();
400 648
        assert!(!m.is_conflicted());
401 -
        assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
402 -
        assert_eq!(propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr, &load).unwrap_err(), RewriteError::Missing(ObjectRef([9; 32])));
649 +
        assert!(
650 +
            matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n")
651 +
        );
652 +
        assert_eq!(
653 +
            propagate(&st, ObjectRef([9; 32]), Some(&n1), &corr, &load).unwrap_err(),
654 +
            RewriteError::Missing(ObjectRef([9; 32]))
655 +
        );
403 656
404 657
        // Removing c1 altogether: f vanishes with it, so c2's edit is dropped.
405 658
        let p = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap();
…
4 unchanged lines
410 663
        // to c1's chain anchors (START, END).
411 664
        let mut n2 = c1.clone();
412 665
        n2.edits = vec![];
413 -
        let corr = Correspondence { atoms: HashMap::new(), ops: [(0, 0)].into_iter().collect() };
666 +
        let corr = Correspondence {
667 +
            atoms: HashMap::new(),
668 +
            ops: [(0, 0)].into_iter().collect(),
669 +
        };
414 670
        let p = propagate(&st, h1, Some(&n2), &corr, &load).unwrap();
415 -
        assert!(matches!(p.changes[0].1.edits[0], Edit::Insert { after: AtomRef::Start, before: AtomRef::End, .. }));
671 +
        assert!(matches!(
672 +
            p.changes[0].1.edits[0],
673 +
            Edit::Insert {
674 +
                after: AtomRef::Start,
675 +
                before: AtomRef::End,
676 +
                ..
677 +
            }
678 +
        ));
416 679
        let mut st3 = State::new();
417 680
        st3.add(n2.clone()).unwrap();
418 681
        st3.add(p.changes[0].1.clone()).unwrap();
419 682
        let m = st3.materialize();
420 683
        assert!(!m.is_conflicted());
421 -
        assert!(matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"c\n"));
684 +
        assert!(
685 +
            matches!(&m.files["f"].content, FileContent::Text { bytes, .. } if bytes == b"c\n")
686 +
        );
422 687
    }
423 688
424 689
    #[test]
…
1 unchanged lines
426 691
        let mut st = State::new();
427 692
        let f = NodeId::random();
428 693
        let g = NodeId::random();
429 -
        let c1 = change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "f".into() }], vec![], "one");
694 +
        let c1 = change(
695 +
            vec![],
696 +
            vec![TreeOp::AddNode {
697 +
                node: f,
698 +
                kind: NodeKind::File,
699 +
                mode: Mode::TEXT,
700 +
                parent: NodeId::ROOT,
701 +
                name: "f".into(),
702 +
            }],
703 +
            vec![],
704 +
            "one",
705 +
        );
430 706
        let h1 = st.add(c1.clone()).unwrap();
431 707
        // c2 merely depends on c1 explicitly (touches another file).
432 -
        let c2 = change(vec![h1], vec![TreeOp::AddNode { node: g, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "g".into() }], vec![], "two");
708 +
        let c2 = change(
709 +
            vec![h1],
710 +
            vec![TreeOp::AddNode {
711 +
                node: g,
712 +
                kind: NodeKind::File,
713 +
                mode: Mode::TEXT,
714 +
                parent: NodeId::ROOT,
715 +
                name: "g".into(),
716 +
            }],
717 +
            vec![],
718 +
            "two",
719 +
        );
433 720
        let h2 = st.add(c2.clone()).unwrap();
434 -
        let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: g, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"x\n".to_vec()] }], "three");
721 +
        let c3 = change(
722 +
            vec![h2],
723 +
            vec![],
724 +
            vec![Edit::Insert {
725 +
                node: g,
726 +
                after: AtomRef::Start,
727 +
                before: AtomRef::End,
728 +
                lines: vec![b"x\n".to_vec()],
729 +
            }],
730 +
            "three",
731 +
        );
435 732
        let h3 = st.add(c3.clone()).unwrap();
436 -
        let mut all: HashMap<ChangeHash, Change> = [(h1, c1), (h2, c2), (h3, c3)].into_iter().collect();
733 +
        let mut all: HashMap<ChangeHash, Change> =
734 +
            [(h1, c1), (h2, c2), (h3, c3)].into_iter().collect();
437 735
        let load_all = all.clone();
438 736
        let load = move |h: &ChangeHash| load_all.get(h).cloned();
439 737
        // Removing c2 (an explicit-only dependency of nothing that references it) works: c3 inherits h1.
440 -
        let out = propagate(&st, h2, None, &Correspondence::default(), &load).unwrap().changes;
738 +
        let out = propagate(&st, h2, None, &Correspondence::default(), &load)
739 +
            .unwrap()
740 +
            .changes;
441 741
        assert_eq!(out.len(), 1);
442 742
        assert_eq!(out[0].0, h3);
443 743
        assert_eq!(out[0].1.deps, vec![h1]);
444 744
        // Removing c1 fails: c2 does not reference c1 (only an explicit dep), but
445 745
        // nothing else does either, so it succeeds and c2 inherits no deps.
446 -
        let out = propagate(&st, h1, None, &Correspondence::default(), &load).unwrap().changes;
746 +
        let out = propagate(&st, h1, None, &Correspondence::default(), &load)
747 +
            .unwrap()
748 +
            .changes;
447 749
        assert!(out.iter().any(|(h, c)| *h == h2 && c.deps.is_empty()));
448 750
        // Removing c2, which created g: c3's edit of g and c4's removal of g's
449 751
        // placement are dropped with notes, since the file no longer exists.
450 -
        let c4 = change(vec![h2], vec![TreeOp::RemoveNode { node: g, placement: OpRef { change: h2, index: 0 } }], vec![], "four");
752 +
        let c4 = change(
753 +
            vec![h2],
754 +
            vec![TreeOp::RemoveNode {
755 +
                node: g,
756 +
                placement: OpRef {
757 +
                    change: h2,
758 +
                    index: 0,
759 +
                },
760 +
            }],
761 +
            vec![],
762 +
            "four",
763 +
        );
451 764
        let h4 = st.add(c4.clone()).unwrap();
452 765
        all.insert(h4, c4);
453 766
        let load2 = |h: &ChangeHash| all.get(h).cloned();
…
7 unchanged lines
modified crates/oxy-core/src/state.rs +336 −66
1 1
//! A state: a dependency-closed set of changes, applied to a tree graph and
2 2
//! per-node file graphs (spec §8.3), with materialization (spec §9.5, §11).
3 3
4 +
use crate::cbor::{self, Value};
4 5
use crate::graph::{AtomKey, DepOracle, FileGraph, Materialized, Vertex};
6 +
use crate::hash::KeyId;
5 7
use crate::hash::{ChangeHash, ObjectRef};
6 8
use crate::ids::NodeId;
7 -
use crate::cbor::{self, Value};
8 -
use crate::hash::KeyId;
9 -
use crate::objects::{self as o, AtomRef, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, ObjectError, TreeOp};
9 +
use crate::objects::{
10 +
    self as o, AtomRef, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, ObjectError, TreeOp,
11 +
};
10 12
use crate::tree::{TreeGraph, TreeView};
11 13
use std::cell::RefCell;
12 14
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
…
22 unchanged lines
35 37
36 38
impl ChangeMeta {
37 39
    pub fn of(c: &Change) -> ChangeMeta {
38 -
        ChangeMeta { change_id: c.change_id, deps: c.deps.clone(), author: c.author, hlc: c.hlc, message: c.message.clone(), kind: c.kind }
40 +
        ChangeMeta {
41 +
            change_id: c.change_id,
42 +
            deps: c.deps.clone(),
43 +
            author: c.author,
44 +
            hlc: c.hlc,
45 +
            message: c.message.clone(),
46 +
            kind: c.kind,
47 +
        }
39 48
    }
40 49
}
41 50
…
8 unchanged lines
50 59
51 60
#[derive(Clone, Debug, PartialEq, Eq)]
52 61
pub enum FileContent {
53 -
    Text { bytes: Vec<u8>, materialized: Materialized },
62 +
    Text {
63 +
        bytes: Vec<u8>,
64 +
        materialized: Materialized,
65 +
    },
54 66
    /// Live blobs; more than one is a replace conflict (spec §9.3).
55 -
    Opaque { blobs: Vec<ObjectRef> },
67 +
    Opaque {
68 +
        blobs: Vec<ObjectRef>,
69 +
    },
56 70
    Dir,
57 71
}
58 72
…
47 unchanged lines
106 120
/// Replace self references in a tree op with the concrete change hash.
107 121
pub fn resolve_tree_op(op: &TreeOp, me: ChangeHash) -> TreeOp {
108 122
    match op {
109 -
        TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode { node: *node, placement: placement.resolve(me) },
110 -
        TreeOp::Move { node, old, parent, name } => {
111 -
            TreeOp::Move { node: *node, old: old.resolve(me), parent: *parent, name: name.clone() }
112 -
        }
113 -
        TreeOp::SetMode { node, old, mode } => TreeOp::SetMode { node: *node, old: old.resolve(me), mode: *mode },
123 +
        TreeOp::RemoveNode { node, placement } => TreeOp::RemoveNode {
124 +
            node: *node,
125 +
            placement: placement.resolve(me),
126 +
        },
127 +
        TreeOp::Move {
128 +
            node,
129 +
            old,
130 +
            parent,
131 +
            name,
132 +
        } => TreeOp::Move {
133 +
            node: *node,
134 +
            old: old.resolve(me),
135 +
            parent: *parent,
136 +
            name: name.clone(),
137 +
        },
138 +
        TreeOp::SetMode { node, old, mode } => TreeOp::SetMode {
139 +
            node: *node,
140 +
            old: old.resolve(me),
141 +
            mode: *mode,
142 +
        },
114 143
        add @ TreeOp::AddNode { .. } => add.clone(),
115 144
    }
116 145
}
…
77 unchanged lines
194 223
        );
195 224
        let mut nodes: Vec<&NodeId> = self.files.keys().collect();
196 225
        nodes.sort();
197 -
        let files = Value::Array(nodes.into_iter().map(|n| Value::Array(vec![Value::Bytes(n.0.to_vec()), self.files[n].to_value()])).collect());
226 +
        let files = Value::Array(
227 +
            nodes
228 +
                .into_iter()
229 +
                .map(|n| Value::Array(vec![Value::Bytes(n.0.to_vec()), self.files[n].to_value()]))
230 +
                .collect(),
231 +
        );
198 232
        let mut rnodes: Vec<&NodeId> = self.replaces.keys().collect();
199 233
        rnodes.sort();
200 -
        let opt = |o: &Option<ObjectRef>| o.map(|r| Value::Bytes(r.0.to_vec())).unwrap_or(Value::Null);
234 +
        let opt =
235 +
            |o: &Option<ObjectRef>| o.map(|r| Value::Bytes(r.0.to_vec())).unwrap_or(Value::Null);
201 236
        let replaces = Value::Array(
202 237
            rnodes
203 238
                .into_iter()
204 239
                .map(|n| {
205 240
                    Value::Array(vec![
206 241
                        Value::Bytes(n.0.to_vec()),
207 -
                        Value::Array(self.replaces[n].iter().map(|(c, o, nw)| Value::Array(vec![Value::Bytes(c.0.to_vec()), opt(o), opt(nw)])).collect()),
242 +
                        Value::Array(
243 +
                            self.replaces[n]
244 +
                                .iter()
245 +
                                .map(|(c, o, nw)| {
246 +
                                    Value::Array(vec![Value::Bytes(c.0.to_vec()), opt(o), opt(nw)])
247 +
                                })
248 +
                                .collect(),
249 +
                        ),
208 250
                    ])
209 251
                })
210 252
                .collect(),
211 253
        );
212 -
        cbor::encode(&Value::map([(0, Value::Uint(o::FORMAT_VERSION)), (1, changes), (2, self.tree.to_value()), (3, files), (4, replaces)]))
254 +
        cbor::encode(&Value::map([
255 +
            (0, Value::Uint(o::FORMAT_VERSION)),
256 +
            (1, changes),
257 +
            (2, self.tree.to_value()),
258 +
            (3, files),
259 +
            (4, replaces),
260 +
        ]))
213 261
    }
214 262
215 263
    pub fn from_checkpoint_payload(bytes: &[u8]) -> Result<State, ObjectError> {
…
8 unchanged lines
224 272
            }
225 273
            let hash = o::oref(&e[0], "hash")?;
226 274
            let meta = ChangeMeta {
227 -
                change_id: crate::ids::ChangeId::from_slice(o::bytes(&e[1], "change id")?).ok_or_else(|| ObjectError::Schema("change id: expected 16 bytes".into()))?,
228 -
                deps: o::array(&e[2], "deps")?.iter().map(|d| o::oref(d, "dep")).collect::<Result<Vec<_>, _>>()?,
275 +
                change_id: crate::ids::ChangeId::from_slice(o::bytes(&e[1], "change id")?)
276 +
                    .ok_or_else(|| ObjectError::Schema("change id: expected 16 bytes".into()))?,
277 +
                deps: o::array(&e[2], "deps")?
278 +
                    .iter()
279 +
                    .map(|d| o::oref(d, "dep"))
280 +
                    .collect::<Result<Vec<_>, _>>()?,
229 281
                message: o::text(&e[3], "message")?.to_owned(),
230 -
                hlc: Hlc { wall_ms: o::uint(&e[4], "wall")?, logical: o::u32_(&e[5], "logical")? },
282 +
                hlc: Hlc {
283 +
                    wall_ms: o::uint(&e[4], "wall")?,
284 +
                    logical: o::u32_(&e[5], "logical")?,
285 +
                },
231 286
                kind: ChangeKind::from_tag(o::uint(&e[6], "kind")?)?,
232 287
                author: o::oref(&e[7], "author")?,
233 288
            };
…
5 unchanged lines
239 294
            if e.len() != 2 {
240 295
                return Err(ObjectError::Schema("file: expected [node, graph]".into()));
241 296
            }
242 -
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
297 +
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?)
298 +
                .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
243 299
            st.files.insert(node, FileGraph::from_value(&e[1])?);
244 300
        }
245 -
        let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> { if v.is_null() { Ok(None) } else { o::oref(v, "blob").map(Some) } };
301 +
        let opt = |v: &Value| -> Result<Option<ObjectRef>, ObjectError> {
302 +
            if v.is_null() {
303 +
                Ok(None)
304 +
            } else {
305 +
                o::oref(v, "blob").map(Some)
306 +
            }
307 +
        };
246 308
        for r in o::array(o::field(m, 4, "replaces")?, "replaces")? {
247 309
            let e = o::array(r, "replace")?;
248 310
            if e.len() != 2 {
249 -
                return Err(ObjectError::Schema("replace: expected [node, records]".into()));
311 +
                return Err(ObjectError::Schema(
312 +
                    "replace: expected [node, records]".into(),
313 +
                ));
250 314
            }
251 -
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
315 +
            let node = NodeId::from_slice(o::bytes(&e[0], "node")?)
316 +
                .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))?;
252 317
            let mut recs = Vec::new();
253 318
            for rec in o::array(&e[1], "records")? {
254 319
                let x = o::array(rec, "record")?;
…
25 unchanged lines
280 345
        let mut counters: HashMap<NodeId, u32> = HashMap::new();
281 346
        for e in &c.edits {
282 347
            match e {
283 -
                Edit::Insert { node, after, before, lines } => {
348 +
                Edit::Insert {
349 +
                    node,
350 +
                    after,
351 +
                    before,
352 +
                    lines,
353 +
                } => {
284 354
                    let idx = counters.entry(*node).or_default();
285 355
                    let first = *idx;
286 356
                    *idx += lines.len() as u32;
287 -
                    self.files.entry(*node).or_default().insert(h, first, vertex(after.resolve(h)), vertex(before.resolve(h)), lines);
357 +
                    self.files.entry(*node).or_default().insert(
358 +
                        h,
359 +
                        first,
360 +
                        vertex(after.resolve(h)),
361 +
                        vertex(before.resolve(h)),
362 +
                        lines,
363 +
                    );
288 364
                }
289 365
                Edit::Delete { node, atoms } => {
290 366
                    let g = self.files.entry(*node).or_default();
…
2 unchanged lines
293 369
                    }
294 370
                }
295 371
                Edit::Replace { node, old, new } => {
296 -
                    self.replaces.entry(*node).or_default().push((h, *old, *new));
372 +
                    self.replaces
373 +
                        .entry(*node)
374 +
                        .or_default()
375 +
                        .push((h, *old, *new));
297 376
                }
298 377
            }
299 378
        }
…
5 unchanged lines
305 384
            return c.clone();
306 385
        }
307 386
        let mut out = HashSet::new();
308 -
        let mut stack: Vec<ChangeHash> = self.changes.get(h).map(|c| c.deps.clone()).unwrap_or_default();
387 +
        let mut stack: Vec<ChangeHash> = self
388 +
            .changes
389 +
            .get(h)
390 +
            .map(|c| c.deps.clone())
391 +
            .unwrap_or_default();
309 392
        while let Some(d) = stack.pop() {
310 393
            if out.insert(d) {
311 394
                if let Some(c) = self.changes.get(&d) {
…
8 unchanged lines
320 403
321 404
    /// Changes that no other change depends on directly, sorted.
322 405
    pub fn heads(&self) -> Vec<ChangeHash> {
323 -
        let depended: HashSet<&ChangeHash> = self.changes.values().flat_map(|c| c.deps.iter()).collect();
324 -
        let mut heads: Vec<ChangeHash> = self.changes.keys().filter(|h| !depended.contains(h)).copied().collect();
406 +
        let depended: HashSet<&ChangeHash> =
407 +
            self.changes.values().flat_map(|c| c.deps.iter()).collect();
408 +
        let mut heads: Vec<ChangeHash> = self
409 +
            .changes
410 +
            .keys()
411 +
            .filter(|h| !depended.contains(h))
412 +
            .copied()
413 +
            .collect();
325 414
        heads.sort();
326 415
        heads
327 416
    }
…
1 unchanged lines
329 418
    /// Maximal elements among `hashes` (those not in the closure of another).
330 419
    pub fn maximal(&self, hashes: &[ChangeHash]) -> Vec<ChangeHash> {
331 420
        let set: BTreeSet<ChangeHash> = hashes.iter().copied().collect();
332 -
        set.iter().filter(|h| !set.iter().any(|o| o != *h && self.closure(o).contains(h))).copied().collect()
421 +
        set.iter()
422 +
            .filter(|h| !set.iter().any(|o| o != *h && self.closure(o).contains(h)))
423 +
            .copied()
424 +
            .collect()
333 425
    }
334 426
335 427
    pub fn materialize_file(&self, node: &NodeId) -> Materialized {
336 -
        self.files.get(node).map(|g| g.materialize(self)).unwrap_or_default()
428 +
        self.files
429 +
            .get(node)
430 +
            .map(|g| g.materialize(self))
431 +
            .unwrap_or_default()
337 432
    }
338 433
339 434
    /// Live lines of a text node in materialized order.
340 435
    pub fn live_lines(&self, node: &NodeId) -> Vec<(AtomKey, Vec<u8>)> {
341 -
        let Some(g) = self.files.get(node) else { return vec![] };
436 +
        let Some(g) = self.files.get(node) else {
437 +
            return vec![];
438 +
        };
342 439
        let m = g.materialize(self);
343 -
        m.live_atoms().into_iter().map(|a| (a, g.content(&a).unwrap_or_default().to_vec())).collect()
440 +
        m.live_atoms()
441 +
            .into_iter()
442 +
            .map(|a| (a, g.content(&a).unwrap_or_default().to_vec()))
443 +
            .collect()
344 444
    }
345 445
346 446
    /// Live blobs of an opaque node: every `new` that no later `Replace` retired.
347 447
    pub fn live_blobs(&self, node: &NodeId) -> Vec<ObjectRef> {
348 -
        let Some(rs) = self.replaces.get(node) else { return vec![] };
448 +
        let Some(rs) = self.replaces.get(node) else {
449 +
            return vec![];
450 +
        };
349 451
        let olds: HashSet<ObjectRef> = rs.iter().filter_map(|(_, o, _)| *o).collect();
350 -
        let mut live: Vec<ObjectRef> = rs.iter().filter_map(|(_, _, n)| *n).filter(|n| !olds.contains(n)).collect();
452 +
        let mut live: Vec<ObjectRef> = rs
453 +
            .iter()
454 +
            .filter_map(|(_, _, n)| *n)
455 +
            .filter(|n| !olds.contains(n))
456 +
            .collect();
351 457
        live.sort();
352 458
        live.dedup();
353 459
        live
…
2 unchanged lines
356 462
    /// Short label for conflict markers: `changeid-prefix "message"`.
357 463
    pub fn label(&self, h: &ChangeHash) -> String {
358 464
        match self.changes.get(h) {
359 -
            Some(c) => format!("{} \"{}\"", c.change_id.short(8), c.message.lines().next().unwrap_or("")),
465 +
            Some(c) => format!(
466 +
                "{} \"{}\"",
467 +
                c.change_id.short(8),
468 +
                c.message.lines().next().unwrap_or("")
469 +
            ),
360 470
            None => format!("#{}", h.short(12)),
361 471
        }
362 472
    }
…
16 unchanged lines
379 489
380 490
    pub fn conflict_regions(&self, node: &NodeId) -> ConflictRegions {
381 491
        let mut out = ConflictRegions::default();
382 -
        let Some(g) = self.files.get(node) else { return out };
492 +
        let Some(g) = self.files.get(node) else {
493 +
            return out;
494 +
        };
383 495
        let m = g.materialize(self);
384 496
        let mut contributors = BTreeSet::new();
385 497
        for seg in &m.segments {
386 498
            match seg {
387 499
                crate::graph::Segment::Lines(atoms) => {
388 500
                    for a in atoms {
389 -
                        out.plain.push((*a, g.content(a).unwrap_or_default().to_vec()));
501 +
                        out.plain
502 +
                            .push((*a, g.content(a).unwrap_or_default().to_vec()));
390 503
                    }
391 504
                }
392 505
                crate::graph::Segment::Conflict(sides) => {
…
21 unchanged lines
414 527
    }
415 528
416 529
    pub fn render_file(&self, node: &NodeId) -> (Vec<u8>, Materialized) {
417 -
        let Some(g) = self.files.get(node) else { return (Vec::new(), Materialized::default()) };
530 +
        let Some(g) = self.files.get(node) else {
531 +
            return (Vec::new(), Materialized::default());
532 +
        };
418 533
        let m = g.materialize(self);
419 534
        let bytes = g.render(&m, &|h| self.label(h));
420 535
        (bytes, m)
…
5 unchanged lines
426 541
        for (path, e) in &tree.entries {
427 542
            let content = match e.kind {
428 543
                NodeKind::Dir => FileContent::Dir,
429 -
                NodeKind::Symlink | NodeKind::Subrepo => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
430 -
                NodeKind::File if e.mode.opaque => FileContent::Opaque { blobs: self.live_blobs(&e.node) },
544 +
                NodeKind::Symlink | NodeKind::Subrepo => FileContent::Opaque {
545 +
                    blobs: self.live_blobs(&e.node),
546 +
                },
547 +
                NodeKind::File if e.mode.opaque => FileContent::Opaque {
548 +
                    blobs: self.live_blobs(&e.node),
549 +
                },
431 550
                NodeKind::File => {
432 551
                    let (bytes, materialized) = self.render_file(&e.node);
433 -
                    FileContent::Text { bytes, materialized }
552 +
                    FileContent::Text {
553 +
                        bytes,
554 +
                        materialized,
555 +
                    }
434 556
                }
435 557
            };
436 -
            files.insert(path.clone(), FileState { node: e.node, kind: e.kind, mode: e.mode, content });
558 +
            files.insert(
559 +
                path.clone(),
560 +
                FileState {
561 +
                    node: e.node,
562 +
                    kind: e.kind,
563 +
                    mode: e.mode,
564 +
                    content,
565 +
                },
566 +
            );
437 567
        }
438 568
        Snapshot { tree, files }
439 569
    }
…
16 unchanged lines
456 586
            change_id: ChangeId::random(),
457 587
            deps,
458 588
            author: ObjectRef::ZERO,
459 -
            hlc: Hlc { wall_ms: 1, logical: 0 },
589 +
            hlc: Hlc {
590 +
                wall_ms: 1,
591 +
                logical: 0,
592 +
            },
460 593
            message: "m".into(),
461 594
            tree_ops,
462 595
            edits,
…
9 unchanged lines
472 605
        let f = NodeId::random();
473 606
        let c1 = change(
474 607
            vec![],
475 -
            vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "a.txt".into() }],
476 -
            vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"one\n".to_vec(), b"two\n".to_vec()] }],
608 +
            vec![TreeOp::AddNode {
609 +
                node: f,
610 +
                kind: NodeKind::File,
611 +
                mode: Mode::TEXT,
612 +
                parent: NodeId::ROOT,
613 +
                name: "a.txt".into(),
614 +
            }],
615 +
            vec![Edit::Insert {
616 +
                node: f,
617 +
                after: AtomRef::Start,
618 +
                before: AtomRef::End,
619 +
                lines: vec![b"one\n".to_vec(), b"two\n".to_vec()],
620 +
            }],
477 621
        );
478 622
        let h1 = st.add(c1).unwrap();
479 623
        // Depends on h1: append a line and delete "one".
…
1 unchanged lines
481 625
            vec![h1],
482 626
            vec![],
483 627
            vec![
484 -
                Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] },
485 -
                Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"three\n".to_vec()] },
628 +
                Edit::Delete {
629 +
                    node: f,
630 +
                    atoms: vec![AtomRange {
631 +
                        change: h1,
632 +
                        from: 0,
633 +
                        to: 1,
634 +
                    }],
635 +
                },
636 +
                Edit::Insert {
637 +
                    node: f,
638 +
                    after: AtomRef::Atom {
639 +
                        change: h1,
640 +
                        index: 1,
641 +
                    },
642 +
                    before: AtomRef::End,
643 +
                    lines: vec![b"three\n".to_vec()],
644 +
                },
486 645
            ],
487 646
        );
488 647
        let h2 = st.add(c2).unwrap();
…
18 unchanged lines
507 666
    fn self_references_and_multi_insert_indexing() {
508 667
        let mut st = State::new();
509 668
        let f = NodeId::random();
510 -
        let me = OpRef { change: ObjectRef::ZERO, index: 0 };
669 +
        let me = OpRef {
670 +
            change: ObjectRef::ZERO,
671 +
            index: 0,
672 +
        };
511 673
        let c1 = change(
512 674
            vec![],
513 675
            vec![
514 -
                TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "x".into() },
515 -
                TreeOp::SetMode { node: f, old: me, mode: Mode { exec: true, opaque: false } },
676 +
                TreeOp::AddNode {
677 +
                    node: f,
678 +
                    kind: NodeKind::File,
679 +
                    mode: Mode::TEXT,
680 +
                    parent: NodeId::ROOT,
681 +
                    name: "x".into(),
682 +
                },
683 +
                TreeOp::SetMode {
684 +
                    node: f,
685 +
                    old: me,
686 +
                    mode: Mode {
687 +
                        exec: true,
688 +
                        opaque: false,
689 +
                    },
690 +
                },
516 691
            ],
517 692
            vec![
518 -
                Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"a\n".to_vec(), b"c\n".to_vec()] },
693 +
                Edit::Insert {
694 +
                    node: f,
695 +
                    after: AtomRef::Start,
696 +
                    before: AtomRef::End,
697 +
                    lines: vec![b"a\n".to_vec(), b"c\n".to_vec()],
698 +
                },
519 699
                // Insert between the two lines just inserted, using self references.
520 700
                Edit::Insert {
521 701
                    node: f,
522 -
                    after: AtomRef::Atom { change: ObjectRef::ZERO, index: 0 },
523 -
                    before: AtomRef::Atom { change: ObjectRef::ZERO, index: 1 },
702 +
                    after: AtomRef::Atom {
703 +
                        change: ObjectRef::ZERO,
704 +
                        index: 0,
705 +
                    },
706 +
                    before: AtomRef::Atom {
707 +
                        change: ObjectRef::ZERO,
708 +
                        index: 1,
709 +
                    },
524 710
                    lines: vec![b"b\n".to_vec()],
525 711
                },
526 712
            ],
…
1 unchanged lines
528 714
        st.add(c1).unwrap();
529 715
        let snap = st.materialize();
530 716
        let x = &snap.files["x"];
531 -
        assert_eq!(x.mode, Mode { exec: true, opaque: false });
717 +
        assert_eq!(
718 +
            x.mode,
719 +
            Mode {
720 +
                exec: true,
721 +
                opaque: false
722 +
            }
723 +
        );
532 724
        assert!(matches!(&x.content, FileContent::Text { bytes, .. } if bytes == b"a\nb\nc\n"));
533 725
    }
534 726
…
4 unchanged lines
539 731
        let b1 = ObjectRef([1; 32]);
540 732
        let b2 = ObjectRef([2; 32]);
541 733
        let b3 = ObjectRef([3; 32]);
542 -
        let h1 = st.add(change(
734 +
        let h1 = st
735 +
            .add(change(
736 +
                vec![],
737 +
                vec![TreeOp::AddNode {
738 +
                    node: f,
739 +
                    kind: NodeKind::File,
740 +
                    mode: Mode::OPAQUE,
741 +
                    parent: NodeId::ROOT,
742 +
                    name: "img".into(),
743 +
                }],
744 +
                vec![Edit::Replace {
745 +
                    node: f,
746 +
                    old: None,
747 +
                    new: Some(b1),
748 +
                }],
749 +
            ))
750 +
            .unwrap();
751 +
        assert_eq!(st.live_blobs(&f), vec![b1]);
752 +
        st.add(change(
753 +
            vec![h1],
754 +
            vec![],
755 +
            vec![Edit::Replace {
756 +
                node: f,
757 +
                old: Some(b1),
758 +
                new: Some(b2),
759 +
            }],
760 +
        ))
761 +
        .unwrap();
762 +
        st.add(change(
763 +
            vec![h1],
543 764
            vec![],
544 -
            vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::OPAQUE, parent: NodeId::ROOT, name: "img".into() }],
545 -
            vec![Edit::Replace { node: f, old: None, new: Some(b1) }],
765 +
            vec![Edit::Replace {
766 +
                node: f,
767 +
                old: Some(b1),
768 +
                new: Some(b3),
769 +
            }],
546 770
        ))
547 771
        .unwrap();
548 -
        assert_eq!(st.live_blobs(&f), vec![b1]);
549 -
        st.add(change(vec![h1], vec![], vec![Edit::Replace { node: f, old: Some(b1), new: Some(b2) }])).unwrap();
550 -
        st.add(change(vec![h1], vec![], vec![Edit::Replace { node: f, old: Some(b1), new: Some(b3) }])).unwrap();
551 772
        let snap = st.materialize();
552 773
        assert!(snap.is_conflicted());
553 -
        assert!(matches!(&snap.files["img"].content, FileContent::Opaque { blobs } if blobs == &vec![b2, b3]));
774 +
        assert!(
775 +
            matches!(&snap.files["img"].content, FileContent::Opaque { blobs } if blobs == &vec![b2, b3])
776 +
        );
554 777
    }
555 778
556 779
    #[test]
557 780
    fn checkpoint_payload_round_trip_and_replay() {
558 781
        let mut st = State::new();
559 782
        let f = NodeId::random();
560 -
        let h1 = st.add(change(vec![], vec![TreeOp::AddNode { node: f, kind: NodeKind::File, mode: Mode::TEXT, parent: NodeId::ROOT, name: "a".into() }], vec![Edit::Insert { node: f, after: AtomRef::Start, before: AtomRef::End, lines: vec![b"1\n".to_vec(), b"2\n".to_vec()] }])).unwrap();
561 -
        let h2 = st.add(change(vec![h1], vec![], vec![Edit::Delete { node: f, atoms: vec![AtomRange { change: h1, from: 0, to: 1 }] }])).unwrap();
783 +
        let h1 = st
784 +
            .add(change(
785 +
                vec![],
786 +
                vec![TreeOp::AddNode {
787 +
                    node: f,
788 +
                    kind: NodeKind::File,
789 +
                    mode: Mode::TEXT,
790 +
                    parent: NodeId::ROOT,
791 +
                    name: "a".into(),
792 +
                }],
793 +
                vec![Edit::Insert {
794 +
                    node: f,
795 +
                    after: AtomRef::Start,
796 +
                    before: AtomRef::End,
797 +
                    lines: vec![b"1\n".to_vec(), b"2\n".to_vec()],
798 +
                }],
799 +
            ))
800 +
            .unwrap();
801 +
        let h2 = st
802 +
            .add(change(
803 +
                vec![h1],
804 +
                vec![],
805 +
                vec![Edit::Delete {
806 +
                    node: f,
807 +
                    atoms: vec![AtomRange {
808 +
                        change: h1,
809 +
                        from: 0,
810 +
                        to: 1,
811 +
                    }],
812 +
                }],
813 +
            ))
814 +
            .unwrap();
562 815
        let payload = st.checkpoint_payload();
563 816
        let back = State::from_checkpoint_payload(&payload).unwrap();
564 817
        assert_eq!(back.len(), 2);
…
1 unchanged lines
566 819
        assert_eq!(back.get(&h1).unwrap().message, "m");
567 820
        assert_eq!(back.checkpoint_payload(), payload, "deterministic");
568 821
        // Replaying a further change onto the restored state equals a full build.
569 -
        let c3 = change(vec![h2], vec![], vec![Edit::Insert { node: f, after: AtomRef::Atom { change: h1, index: 1 }, before: AtomRef::End, lines: vec![b"3\n".to_vec()] }]);
822 +
        let c3 = change(
823 +
            vec![h2],
824 +
            vec![],
825 +
            vec![Edit::Insert {
826 +
                node: f,
827 +
                after: AtomRef::Atom {
828 +
                    change: h1,
829 +
                    index: 1,
830 +
                },
831 +
                before: AtomRef::End,
832 +
                lines: vec![b"3\n".to_vec()],
833 +
            }],
834 +
        );
570 835
        let mut full = st.clone();
571 836
        full.add(c3.clone()).unwrap();
572 837
        let mut restored = back;
573 838
        restored.add(c3).unwrap();
574 839
        let (a, b) = (full.materialize(), restored.materialize());
575 -
        assert_eq!(a.files.keys().collect::<Vec<_>>(), b.files.keys().collect::<Vec<_>>());
840 +
        assert_eq!(
841 +
            a.files.keys().collect::<Vec<_>>(),
842 +
            b.files.keys().collect::<Vec<_>>()
843 +
        );
576 844
        assert_eq!(a.files["a"].content, b.files["a"].content);
577 -
        assert!(matches!(&b.files["a"].content, FileContent::Text { bytes, .. } if bytes == b"2\n3\n"));
845 +
        assert!(
846 +
            matches!(&b.files["a"].content, FileContent::Text { bytes, .. } if bytes == b"2\n3\n")
847 +
        );
578 848
        assert!(State::from_checkpoint_payload(b"nonsense").is_err());
579 849
    }
580 850
…
14 unchanged lines
modified crates/oxy-core/src/store.rs +287 −64
…
46 unchanged lines
47 47
impl PackIndex {
48 48
    fn load(idx_path: &Path, pack_path: &Path) -> Result<PackIndex> {
49 49
        let data = fs::read(idx_path)?;
50 -
        let bad = |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{}: {m}", idx_path.display()));
50 +
        let bad =
51 +
            |m: &str| StoreError::Corrupt(ObjectRef::ZERO, format!("{}: {m}", idx_path.display()));
51 52
        if data.len() < 12 || &data[..8] != IDX_MAGIC {
52 53
            return Err(bad("bad index header"));
53 54
        }
…
17 unchanged lines
71 72
                offset: u64::from_le_bytes(e[32..40].try_into().unwrap()),
72 73
                stored_len: u32::from_le_bytes(e[40..44].try_into().unwrap()),
73 74
                raw_len: u32::from_le_bytes(e[44..48].try_into().unwrap()),
74 -
                ty: ObjectType::ALL.get(e[48] as usize).copied().ok_or_else(|| bad("unknown object type"))?,
75 +
                ty: ObjectType::ALL
76 +
                    .get(e[48] as usize)
77 +
                    .copied()
78 +
                    .ok_or_else(|| bad("unknown object type"))?,
75 79
                flags: e[49],
76 80
            };
77 -
            if entry.stored_len > MAX_OBJECT_LEN || entry.raw_len > MAX_OBJECT_LEN || entry.offset.saturating_add(entry.stored_len as u64) > pack_len {
81 +
            if entry.stored_len > MAX_OBJECT_LEN
82 +
                || entry.raw_len > MAX_OBJECT_LEN
83 +
                || entry.offset.saturating_add(entry.stored_len as u64) > pack_len
84 +
            {
78 85
                return Err(bad("index entry points outside the pack"));
79 86
            }
80 87
            entries.push(entry);
81 88
        }
82 -
        Ok(PackIndex { path: pack_path.to_path_buf(), refs, entries })
89 +
        Ok(PackIndex {
90 +
            path: pack_path.to_path_buf(),
91 +
            refs,
92 +
            entries,
93 +
        })
83 94
    }
84 95
85 96
    fn find(&self, r: &ObjectRef) -> Option<PackEntry> {
…
6 unchanged lines
92 103
        let mut stored = vec![0u8; e.stored_len as usize];
93 104
        f.read_exact(&mut stored)?;
94 105
        let body = if e.flags & FLAG_ZSTD != 0 {
95 -
            zstd::bulk::decompress(&stored, e.raw_len as usize).map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
106 +
            zstd::bulk::decompress(&stored, e.raw_len as usize)
107 +
                .map_err(|err| StoreError::Corrupt(*r, format!("zstd: {err}")))?
96 108
        } else {
97 109
            stored
98 110
        };
99 111
        if body.len() != e.raw_len as usize || hash_object(e.ty, &body) != *r {
100 -
            return Err(StoreError::Corrupt(*r, "packed object does not match its reference".into()));
112 +
            return Err(StoreError::Corrupt(
113 +
                *r,
114 +
                "packed object does not match its reference".into(),
115 +
            ));
101 116
        }
102 117
        Ok((e.ty, body))
103 118
    }
…
104 unchanged lines
208 223
    pub fn open(dir: impl Into<PathBuf>) -> io::Result<Store> {
209 224
        let dir = dir.into();
210 225
        fs::create_dir_all(dir.join("loose"))?;
211 -
        let store = Store { dir, packs: RefCell::new(Vec::new()), pending: RefCell::new(None), batch_depth: std::cell::Cell::new(0) };
212 -
        store.reload_packs().map_err(|e| io::Error::other(e.to_string()))?;
226 +
        let store = Store {
227 +
            dir,
228 +
            packs: RefCell::new(Vec::new()),
229 +
            pending: RefCell::new(None),
230 +
            batch_depth: std::cell::Cell::new(0),
231 +
        };
232 +
        store
233 +
            .reload_packs()
234 +
            .map_err(|e| io::Error::other(e.to_string()))?;
213 235
        Ok(store)
214 236
    }
215 237
…
1 unchanged lines
217 239
        let mut packs = Vec::new();
218 240
        let mut names: Vec<PathBuf> = fs::read_dir(&self.dir)?
219 241
            .filter_map(|e| e.ok().map(|e| e.path()))
220 -
            .filter(|p| p.extension().is_some_and(|x| x == "idx") && p.file_name().is_some_and(|n| n.to_string_lossy().starts_with("pack-")))
242 +
            .filter(|p| {
243 +
                p.extension().is_some_and(|x| x == "idx")
244 +
                    && p.file_name()
245 +
                        .is_some_and(|n| n.to_string_lossy().starts_with("pack-"))
246 +
            })
221 247
            .collect();
222 248
        names.sort();
223 249
        for idx in names {
…
11 unchanged lines
235 261
    }
236 262
237 263
    fn find_packed(&self, r: &ObjectRef) -> Option<(usize, PackEntry)> {
238 -
        self.packs.borrow().iter().enumerate().find_map(|(i, p)| p.find(r).map(|e| (i, e)))
264 +
        self.packs
265 +
            .borrow()
266 +
            .iter()
267 +
            .enumerate()
268 +
            .find_map(|(i, p)| p.find(r).map(|e| (i, e)))
239 269
    }
240 270
241 271
    /// Loose objects on disk, with their paths.
…
26 unchanged lines
268 298
    /// deleted only after the pack and its index are durable.
269 299
    pub fn pack_loose(&self) -> Result<PackReport> {
270 300
        let loose = self.loose_objects()?;
271 -
        self.write_pack(&loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(), &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(), &[])
301 +
        self.write_pack(
302 +
            &loose.iter().map(|(r, _)| *r).collect::<Vec<_>>(),
303 +
            &loose.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>(),
304 +
            &[],
305 +
        )
272 306
    }
273 307
274 308
    /// Fold every pack into one when there are more than `MAX_PACKS`
…
4 unchanged lines
279 313
            if packs.len() <= MAX_PACKS {
280 314
                return Ok(0);
281 315
            }
282 -
            (packs.iter().flat_map(|p| p.refs.iter().copied()).collect(), packs.iter().map(|p| p.path.clone()).collect())
316 +
            (
317 +
                packs.iter().flat_map(|p| p.refs.iter().copied()).collect(),
318 +
                packs.iter().map(|p| p.path.clone()).collect(),
319 +
            )
283 320
        };
284 321
        let n = paths.len();
285 322
        self.write_pack(&refs, &[], &paths)?;
…
5 unchanged lines
291 328
    /// have recorded its references yet. Packs holding unreachable objects
292 329
    /// are rewritten without them. Signature sidecars and seal-index entries
293 330
    /// of deleted objects go too.
294 -
    pub fn prune(&self, keep: &std::collections::HashSet<ObjectRef>, grace: std::time::Duration) -> Result<PruneReport> {
331 +
    pub fn prune(
332 +
        &self,
333 +
        keep: &std::collections::HashSet<ObjectRef>,
334 +
        grace: std::time::Duration,
335 +
    ) -> Result<PruneReport> {
295 336
        let now = std::time::SystemTime::now();
296 -
        let old_enough = |m: &fs::Metadata| m.modified().map(|t| now.duration_since(t).unwrap_or_default() >= grace).unwrap_or(true);
337 +
        let old_enough = |m: &fs::Metadata| {
338 +
            m.modified()
339 +
                .map(|t| now.duration_since(t).unwrap_or_default() >= grace)
340 +
                .unwrap_or(true)
341 +
        };
297 342
        let mut report = PruneReport::default();
298 343
        // Loose objects.
299 344
        for (r, path) in self.loose_objects()? {
…
39 unchanged lines
339 384
        if rewrite {
340 385
            let dropped: Vec<ObjectRef> = {
341 386
                let packs = self.packs.borrow();
342 -
                packs.iter().filter(|p| old_paths.contains(&p.path)).flat_map(|p| p.refs.iter().copied()).filter(|r| !keep.contains(r)).collect()
387 +
                packs
388 +
                    .iter()
389 +
                    .filter(|p| old_paths.contains(&p.path))
390 +
                    .flat_map(|p| p.refs.iter().copied())
391 +
                    .filter(|r| !keep.contains(r))
392 +
                    .collect()
343 393
            };
344 394
            self.write_pack(&keep_refs, &[], &old_paths)?;
345 395
            for r in dropped {
…
19 unchanged lines
365 415
    /// Write `refs` into a new pack, then delete `loose_paths` and `old_packs`.
366 416
    /// Write the index for a complete, synced temporary pack file and move
367 417
    /// both into place; `index` must be sorted by ObjectRef.
368 -
    fn finish_pack_files(&self, id: &str, tmp_pack: &Path, index: &[(ObjectRef, PackEntry)]) -> Result<()> {
418 +
    fn finish_pack_files(
419 +
        &self,
420 +
        id: &str,
421 +
        tmp_pack: &Path,
422 +
        index: &[(ObjectRef, PackEntry)],
423 +
    ) -> Result<()> {
369 424
        let pack_path = self.dir.join(format!("pack-{id}.pack"));
370 425
        let idx_path = self.dir.join(format!("pack-{id}.idx"));
371 426
        let tmp_idx = self.dir.join(format!("tmp-{id}.idx"));
…
16 unchanged lines
388 443
        self.reload_packs()
389 444
    }
390 445
391 -
    fn write_pack(&self, refs: &[ObjectRef], loose_paths: &[PathBuf], old_packs: &[PathBuf]) -> Result<PackReport> {
446 +
    fn write_pack(
447 +
        &self,
448 +
        refs: &[ObjectRef],
449 +
        loose_paths: &[PathBuf],
450 +
        old_packs: &[PathBuf],
451 +
    ) -> Result<PackReport> {
392 452
        if refs.is_empty() {
393 453
            for p in old_packs {
394 454
                fs::remove_file(p)?;
…
20 unchanged lines
415 475
                let (flags, stored) = if self.is_nocompress(r) {
416 476
                    (0, body.clone())
417 477
                } else {
418 -
                    let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
419 -
                    if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.clone()) }
478 +
                    let compressed = zstd::bulk::compress(&body, ZSTD_LEVEL)
479 +
                        .map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
480 +
                    if compressed.len() < body.len() {
481 +
                        (FLAG_ZSTD, compressed)
482 +
                    } else {
483 +
                        (0, body.clone())
484 +
                    }
420 485
                };
421 486
                let header_len = 1 + 1 + 4 + 4 + 32;
422 487
                f.write_all(&[type_index(ty), flags])?;
…
1 unchanged lines
424 489
                f.write_all(&(body.len() as u32).to_le_bytes())?;
425 490
                f.write_all(&r.0)?;
426 491
                f.write_all(&stored)?;
427 -
                index.push((*r, PackEntry { offset: offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags }));
492 +
                index.push((
493 +
                    *r,
494 +
                    PackEntry {
495 +
                        offset: offset + header_len,
496 +
                        stored_len: stored.len() as u32,
497 +
                        raw_len: body.len() as u32,
498 +
                        ty,
499 +
                        flags,
500 +
                    },
501 +
                ));
428 502
                offset += header_len + stored.len() as u64;
429 503
                report.objects += 1;
430 504
                report.raw_bytes += body.len() as u64;
…
27 unchanged lines
458 532
    }
459 533
460 534
    pub fn has(&self, r: &ObjectRef) -> bool {
461 -
        self.path_for(r).is_file() || self.find_packed(r).is_some() || self.pending.borrow().as_ref().is_some_and(|p| p.lookup.contains_key(r))
535 +
        self.path_for(r).is_file()
536 +
            || self.find_packed(r).is_some()
537 +
            || self
538 +
                .pending
539 +
                .borrow()
540 +
                .as_ref()
541 +
                .is_some_and(|p| p.lookup.contains_key(r))
462 542
    }
463 543
464 544
    /// Start a write batch: until the matching `finish`, `put` collects
…
3 unchanged lines
468 548
    pub fn begin_batch(&self) -> Result<Batch<'_>> {
469 549
        let depth = self.batch_depth.get();
470 550
        if depth == 0 {
471 -
            *self.pending.borrow_mut() = Some(Pending { buffered: vec![], buffered_bytes: 0, lookup: Default::default(), pack: None, report: PackReport::default() });
551 +
            *self.pending.borrow_mut() = Some(Pending {
552 +
                buffered: vec![],
553 +
                buffered_bytes: 0,
554 +
                lookup: Default::default(),
555 +
                pack: None,
556 +
                report: PackReport::default(),
557 +
            });
472 558
        }
473 559
        self.batch_depth.set(depth + 1);
474 -
        Ok(Batch { store: self, done: false })
560 +
        Ok(Batch {
561 +
            store: self,
562 +
            done: false,
563 +
        })
475 564
    }
476 565
477 566
    fn finish_batch(&self) -> Result<()> {
…
5 unchanged lines
483 572
        if depth > 1 {
484 573
            return Ok(());
485 574
        }
486 -
        let Some(pending) = self.pending.borrow_mut().take() else { return Ok(()) };
575 +
        let Some(pending) = self.pending.borrow_mut().take() else {
576 +
            return Ok(());
577 +
        };
487 578
        match pending.pack {
488 579
            None => {
489 580
                for (ty, body) in &pending.buffered {
…
23 unchanged lines
513 604
                Ok((*ty, body.clone()))
514 605
            }
515 606
            PendingEntry::Packed(e) => {
516 -
                let Some(pack) = pending.pack.as_mut() else { return Some(Err(StoreError::NotFound(*r))) };
607 +
                let Some(pack) = pending.pack.as_mut() else {
608 +
                    return Some(Err(StoreError::NotFound(*r)));
609 +
                };
517 610
                match pack.writer.flush() {
518 -
                    Ok(()) => PackIndex { path: pack.tmp_path.clone(), refs: vec![], entries: vec![] }.read(r, e),
611 +
                    Ok(()) => PackIndex {
612 +
                        path: pack.tmp_path.clone(),
613 +
                        refs: vec![],
614 +
                        entries: vec![],
615 +
                    }
616 +
                    .read(r, e),
519 617
                    Err(err) => Err(err.into()),
520 618
                }
521 619
            }
…
1 unchanged lines
523 621
    }
524 622
525 623
    /// Append one object to the pending pack file.
526 -
    fn pending_pack_write(&self, pack: &mut PendingPack, r: &ObjectRef, ty: ObjectType, body: &[u8], report: &mut PackReport) -> Result<PackEntry> {
624 +
    fn pending_pack_write(
625 +
        &self,
626 +
        pack: &mut PendingPack,
627 +
        r: &ObjectRef,
628 +
        ty: ObjectType,
629 +
        body: &[u8],
630 +
        report: &mut PackReport,
631 +
    ) -> Result<PackEntry> {
527 632
        let (flags, stored) = if self.is_nocompress(r) {
528 633
            (0, body.to_vec())
529 634
        } else {
530 -
            let compressed = zstd::bulk::compress(body, ZSTD_LEVEL).map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
531 -
            if compressed.len() < body.len() { (FLAG_ZSTD, compressed) } else { (0, body.to_vec()) }
635 +
            let compressed = zstd::bulk::compress(body, ZSTD_LEVEL)
636 +
                .map_err(|e| StoreError::Corrupt(*r, format!("zstd: {e}")))?;
637 +
            if compressed.len() < body.len() {
638 +
                (FLAG_ZSTD, compressed)
639 +
            } else {
640 +
                (0, body.to_vec())
641 +
            }
532 642
        };
533 643
        let header_len = 1 + 1 + 4 + 4 + 32;
534 644
        pack.writer.write_all(&[type_index(ty), flags])?;
535 -
        pack.writer.write_all(&(stored.len() as u32).to_le_bytes())?;
645 +
        pack.writer
646 +
            .write_all(&(stored.len() as u32).to_le_bytes())?;
536 647
        pack.writer.write_all(&(body.len() as u32).to_le_bytes())?;
537 648
        pack.writer.write_all(&r.0)?;
538 649
        pack.writer.write_all(&stored)?;
539 -
        let entry = PackEntry { offset: pack.offset + header_len, stored_len: stored.len() as u32, raw_len: body.len() as u32, ty, flags };
650 +
        let entry = PackEntry {
651 +
            offset: pack.offset + header_len,
652 +
            stored_len: stored.len() as u32,
653 +
            raw_len: body.len() as u32,
654 +
            ty,
655 +
            flags,
656 +
        };
540 657
        pack.offset += header_len + stored.len() as u64;
541 658
        pack.index.push((*r, entry));
542 659
        report.objects += 1;
…
8 unchanged lines
551 668
        if pending.lookup.contains_key(&r) {
552 669
            return Ok(());
553 670
        }
554 -
        if pending.pack.is_none() && pending.buffered.len() < SPILL_OBJECTS && pending.buffered_bytes + body.len() <= SPILL_BYTES {
671 +
        if pending.pack.is_none()
672 +
            && pending.buffered.len() < SPILL_OBJECTS
673 +
            && pending.buffered_bytes + body.len() <= SPILL_BYTES
674 +
        {
555 675
            pending.buffered.push((ty, body.to_vec()));
556 676
            pending.buffered_bytes += body.len();
557 -
            pending.lookup.insert(r, PendingEntry::Buffered(pending.buffered.len() - 1));
677 +
            pending
678 +
                .lookup
679 +
                .insert(r, PendingEntry::Buffered(pending.buffered.len() - 1));
558 680
            return Ok(());
559 681
        }
560 682
        if pending.pack.is_none() {
…
4 unchanged lines
565 687
            let tmp_path = self.dir.join(format!("tmp-{id}.pack"));
566 688
            let mut writer = io::BufWriter::new(fs::File::create(&tmp_path)?);
567 689
            writer.write_all(PACK_MAGIC)?;
568 -
            let mut pack = PendingPack { id, tmp_path, writer, offset: PACK_MAGIC.len() as u64, index: vec![] };
690 +
            let mut pack = PendingPack {
691 +
                id,
692 +
                tmp_path,
693 +
                writer,
694 +
                offset: PACK_MAGIC.len() as u64,
695 +
                index: vec![],
696 +
            };
569 697
            let buffered = std::mem::take(&mut pending.buffered);
570 698
            pending.buffered_bytes = 0;
571 699
            let mut report = std::mem::take(&mut pending.report);
…
38 unchanged lines
610 738
        fs::create_dir_all(path.parent().unwrap())?;
611 739
        let mut nonce = [0u8; 8];
612 740
        rand::rngs::OsRng.fill_bytes(&mut nonce);
613 -
        let tmp = self.dir.join("loose").join(format!("tmp-{}", hex::encode(nonce)));
741 +
        let tmp = self
742 +
            .dir
743 +
            .join("loose")
744 +
            .join(format!("tmp-{}", hex::encode(nonce)));
614 745
        {
615 746
            let mut f = fs::File::create(&tmp)?;
616 747
            f.write_all(&header(ty, body.len()))?;
…
17 unchanged lines
634 765
            }
635 766
            Err(e) => return Err(e.into()),
636 767
        };
637 -
        let nul = data.iter().position(|&b| b == 0).ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?;
638 -
        let head = std::str::from_utf8(&data[..nul]).map_err(|_| StoreError::Corrupt(*r, "header not UTF-8".into()))?;
768 +
        let nul = data
769 +
            .iter()
770 +
            .position(|&b| b == 0)
771 +
            .ok_or_else(|| StoreError::Corrupt(*r, "no header".into()))?;
772 +
        let head = std::str::from_utf8(&data[..nul])
773 +
            .map_err(|_| StoreError::Corrupt(*r, "header not UTF-8".into()))?;
639 774
        let mut parts = head.split(' ');
640 -
        let (Some("oxy1"), Some(ty), Some(len), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else {
775 +
        let (Some("oxy1"), Some(ty), Some(len), None) =
776 +
            (parts.next(), parts.next(), parts.next(), parts.next())
777 +
        else {
641 778
            return Err(StoreError::Corrupt(*r, format!("bad header {head:?}")));
642 779
        };
643 -
        let ty = ObjectType::from_name(ty).ok_or_else(|| StoreError::Corrupt(*r, format!("unknown type {ty}")))?;
644 -
        let len: usize = len.parse().map_err(|_| StoreError::Corrupt(*r, "bad length".into()))?;
780 +
        let ty = ObjectType::from_name(ty)
781 +
            .ok_or_else(|| StoreError::Corrupt(*r, format!("unknown type {ty}")))?;
782 +
        let len: usize = len
783 +
            .parse()
784 +
            .map_err(|_| StoreError::Corrupt(*r, "bad length".into()))?;
645 785
        let body = &data[nul + 1..];
646 786
        if body.len() != len {
647 -
            return Err(StoreError::Corrupt(*r, format!("length {} != header {len}", body.len())));
787 +
            return Err(StoreError::Corrupt(
788 +
                *r,
789 +
                format!("length {} != header {len}", body.len()),
790 +
            ));
648 791
        }
649 792
        if hash_object(ty, body) != *r {
650 793
            return Err(StoreError::Corrupt(*r, "hash mismatch".into()));
…
148 unchanged lines
799 942
        assert_eq!(s.get(&r).unwrap(), (ObjectType::Chunk, b"hello".to_vec()));
800 943
        assert_eq!(s.put(ObjectType::Chunk, b"hello").unwrap(), r);
801 944
        assert_eq!(s.list().unwrap(), vec![r]);
802 -
        assert!(matches!(s.get_typed(&r, ObjectType::Blob), Err(StoreError::WrongType(..))));
803 -
        assert!(matches!(s.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
945 +
        assert!(matches!(
946 +
            s.get_typed(&r, ObjectType::Blob),
947 +
            Err(StoreError::WrongType(..))
948 +
        ));
949 +
        assert!(matches!(
950 +
            s.get(&ObjectRef([9; 32])),
951 +
            Err(StoreError::NotFound(_))
952 +
        ));
804 953
805 954
        // Tamper: flip a byte, read must fail.
806 955
        let path = s.path_for(&r);
…
23 unchanged lines
830 979
        assert_eq!(s.pack_count(), 1);
831 980
        for (r, body) in &refs {
832 981
            assert!(s.has(r));
833 -
            assert_eq!(s.get(r).unwrap(), (ObjectType::Chunk, body.as_bytes().to_vec()));
982 +
            assert_eq!(
983 +
                s.get(r).unwrap(),
984 +
                (ObjectType::Chunk, body.as_bytes().to_vec())
985 +
            );
834 986
        }
835 987
        assert_eq!(s.get_change(&cr).unwrap(), c);
836 988
        assert_eq!(s.list().unwrap().len(), 301);
837 989
        // Re-putting a packed object is a no-op; new objects go loose and a
838 990
        // second pack coexists with the first.
839 -
        assert_eq!(s.put(ObjectType::Chunk, refs[0].1.as_bytes()).unwrap(), refs[0].0);
991 +
        assert_eq!(
992 +
            s.put(ObjectType::Chunk, refs[0].1.as_bytes()).unwrap(),
993 +
            refs[0].0
994 +
        );
840 995
        assert_eq!(s.loose_count().unwrap(), 0);
841 996
        let extra = s.put(ObjectType::Chunk, b"after packing").unwrap();
842 997
        s.pack_loose().unwrap();
…
3 unchanged lines
846 1001
        let s2 = Store::open(dir.path()).unwrap();
847 1002
        assert_eq!(s2.pack_count(), 2);
848 1003
        assert_eq!(s2.get(&refs[5].0).unwrap().1, refs[5].1.as_bytes());
849 -
        assert!(matches!(s2.get(&ObjectRef([9; 32])), Err(StoreError::NotFound(_))));
1004 +
        assert!(matches!(
1005 +
            s2.get(&ObjectRef([9; 32])),
1006 +
            Err(StoreError::NotFound(_))
1007 +
        ));
850 1008
        // Corrupting an object's body inside the pack is detected on read.
851 1009
        let target = refs[5].0;
852 1010
        let (pack_path, offset) = {
…
6 unchanged lines
859 1017
        fs::write(&pack_path, data).unwrap();
860 1018
        let s3 = Store::open(dir.path()).unwrap();
861 1019
        assert!(matches!(s3.get(&target), Err(StoreError::Corrupt(..))));
862 -
        assert_eq!(s3.get(&refs[6].0).unwrap().1, refs[6].1.as_bytes(), "other objects are unaffected");
1020 +
        assert_eq!(
1021 +
            s3.get(&refs[6].0).unwrap().1,
1022 +
            refs[6].1.as_bytes(),
1023 +
            "other objects are unaffected"
1024 +
        );
863 1025
    }
864 1026
865 1027
    #[test]
…
19 unchanged lines
885 1047
        let s = Store::open(dir.path()).unwrap();
886 1048
        let mut refs = Vec::new();
887 1049
        for i in 0..(MAX_PACKS + 2) {
888 -
            refs.push(s.put(ObjectType::Chunk, format!("pack {i}").as_bytes()).unwrap());
1050 +
            refs.push(
1051 +
                s.put(ObjectType::Chunk, format!("pack {i}").as_bytes())
1052 +
                    .unwrap(),
1053 +
            );
889 1054
            s.pack_loose().unwrap();
890 1055
        }
891 1056
        assert_eq!(s.pack_count(), MAX_PACKS + 2);
…
16 unchanged lines
908 1073
        s.pack_loose().unwrap();
909 1074
        let keep_b = s.put(ObjectType::Chunk, b"keep b").unwrap();
910 1075
        let drop_b = s.put(ObjectType::Chunk, b"drop b").unwrap();
911 -
        let sk = crate::identity::SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
1076 +
        let sk = crate::identity::SecretKey {
1077 +
            ed_seed: [1; 32],
1078 +
            ml_seed: [2; 32],
1079 +
        };
912 1080
        let id = sk.identity("a", "b", 0);
913 1081
        let key = s.put_identity(&id).unwrap();
914 -
        s.put_signature(&drop_b, &sk.sign(crate::identity::Domain::Change, &key, &drop_b)).unwrap();
1082 +
        s.put_signature(
1083 +
            &drop_b,
1084 +
            &sk.sign(crate::identity::Domain::Change, &key, &drop_b),
1085 +
        )
1086 +
        .unwrap();
915 1087
        s.index_seal(&drop_a, &ObjectRef([3; 32])).unwrap();
916 1088
        let keep: HashSet<ObjectRef> = [keep_a, keep_b, key].into_iter().collect();
917 1089
        // Everything is too young under a grace period.
918 -
        let r = s.prune(&keep, std::time::Duration::from_secs(3600)).unwrap();
1090 +
        let r = s
1091 +
            .prune(&keep, std::time::Duration::from_secs(3600))
1092 +
            .unwrap();
919 1093
        assert_eq!((r.objects, r.deferred), (0, 2));
920 1094
        let r = s.prune(&keep, std::time::Duration::ZERO).unwrap();
921 1095
        assert_eq!(r.objects, 2, "{r:?}");
…
13 unchanged lines
935 1109
        let s = Store::open(dir.path()).unwrap();
936 1110
        let r = s.put(ObjectType::Chunk, b"x").unwrap();
937 1111
        assert!(s.signatures(&r).unwrap().is_empty());
938 -
        let sk = SecretKey { ed_seed: [1; 32], ml_seed: [2; 32] };
1112 +
        let sk = SecretKey {
1113 +
            ed_seed: [1; 32],
1114 +
            ml_seed: [2; 32],
1115 +
        };
939 1116
        let id = sk.identity("a", "b", 0);
940 1117
        let key = s.put_identity(&id).unwrap();
941 1118
        assert_eq!(s.get_identity(&key).unwrap(), id);
…
36 unchanged lines
978 1155
            let len = (xorshift(&mut seed) % 5000) as usize;
979 1156
            let mut body = vec![0u8; len];
980 1157
            for b in body.iter_mut() {
981 -
                *b = if i % 2 == 0 { (xorshift(&mut seed) & 0xff) as u8 } else { b'a' + (i % 26) as u8 };
1158 +
                *b = if i % 2 == 0 {
1159 +
                    (xorshift(&mut seed) & 0xff) as u8
1160 +
                } else {
1161 +
                    b'a' + (i % 26) as u8
1162 +
                };
982 1163
            }
983 1164
            refs.push(s.put(ObjectType::Chunk, &body).unwrap());
984 1165
        }
985 1166
        s.pack_loose().unwrap();
986 -
        let pack = fs::read_dir(&objects).unwrap().filter_map(|e| e.ok().map(|e| e.path())).find(|p| p.extension().is_some_and(|x| x == "pack")).unwrap();
1167 +
        let pack = fs::read_dir(&objects)
1168 +
            .unwrap()
1169 +
            .filter_map(|e| e.ok().map(|e| e.path()))
1170 +
            .find(|p| p.extension().is_some_and(|x| x == "pack"))
1171 +
            .unwrap();
987 1172
        let idx = pack.with_extension("idx");
988 1173
        let pristine_pack = fs::read(&pack).unwrap();
989 1174
        let pristine_idx = fs::read(&idx).unwrap();
990 1175
991 1176
        for round in 0..300u32 {
992 -
            let (path, pristine) = if round % 2 == 0 { (&pack, &pristine_pack) } else { (&idx, &pristine_idx) };
1177 +
            let (path, pristine) = if round % 2 == 0 {
1178 +
                (&pack, &pristine_pack)
1179 +
            } else {
1180 +
                (&idx, &pristine_idx)
1181 +
            };
993 1182
            let mut data = pristine.clone();
994 1183
            match xorshift(&mut seed) % 4 {
995 1184
                0 => {
…
22 unchanged lines
1018 1207
            if let Ok(s2) = Store::open(&objects) {
1019 1208
                for r in &refs {
1020 1209
                    match s2.get(r) {
1021 -
                        Ok((ty, body)) => assert_eq!(hash_object(ty, &body), *r, "a returned object always matches its reference"),
1022 -
                        Err(StoreError::Corrupt(..)) | Err(StoreError::NotFound(_)) | Err(StoreError::Io(_)) => {}
1210 +
                        Ok((ty, body)) => assert_eq!(
1211 +
                            hash_object(ty, &body),
1212 +
                            *r,
1213 +
                            "a returned object always matches its reference"
1214 +
                        ),
1215 +
                        Err(StoreError::Corrupt(..))
1216 +
                        | Err(StoreError::NotFound(_))
1217 +
                        | Err(StoreError::Io(_)) => {}
1023 1218
                        Err(e) => panic!("unexpected error {e}"),
1024 1219
                    }
1025 1220
                }
…
22 unchanged lines
1048 1243
            assert!(s.has(&b));
1049 1244
            assert_eq!(s.get(&b).unwrap().1, b"small b");
1050 1245
            inner.finish().unwrap();
1051 -
            assert_eq!(s.loose_count().unwrap(), 0, "the inner batch defers to the outer one");
1246 +
            assert_eq!(
1247 +
                s.loose_count().unwrap(),
1248 +
                0,
1249 +
                "the inner batch defers to the outer one"
1250 +
            );
1052 1251
        }
1053 1252
        assert_eq!(s.get(&a).unwrap().1, b"small a");
1054 -
        assert_eq!(s.put(ObjectType::Chunk, b"small a").unwrap(), a, "idempotent while pending");
1253 +
        assert_eq!(
1254 +
            s.put(ObjectType::Chunk, b"small a").unwrap(),
1255 +
            a,
1256 +
            "idempotent while pending"
1257 +
        );
1055 1258
        outer.finish().unwrap();
1056 1259
        assert_eq!(s.loose_count().unwrap(), 2);
1057 1260
        assert_eq!(s.pack_count(), 0);
…
11 unchanged lines
1069 1272
                }
1070 1273
            }
1071 1274
            body[..4].copy_from_slice(&i.to_le_bytes());
1072 -
            let r = if i % 5 == 0 { s.put_chunk_nocompress(&body).unwrap() } else { s.put(ObjectType::Chunk, &body).unwrap() };
1073 -
            assert_eq!(s.get(&r).unwrap().1, body, "readable while pending (object {i})");
1275 +
            let r = if i % 5 == 0 {
1276 +
                s.put_chunk_nocompress(&body).unwrap()
1277 +
            } else {
1278 +
                s.put(ObjectType::Chunk, &body).unwrap()
1279 +
            };
1280 +
            assert_eq!(
1281 +
                s.get(&r).unwrap().1,
1282 +
                body,
1283 +
                "readable while pending (object {i})"
1284 +
            );
1074 1285
            refs.push((r, body));
1075 1286
        }
1076 1287
        assert!(s.has(&refs[0].0));
1077 1288
        big.finish().unwrap();
1078 1289
        assert_eq!(s.pack_count(), 1);
1079 -
        assert_eq!(s.loose_count().unwrap(), 2, "nothing from the big batch is loose");
1080 -
        assert!(fs::read_dir(dir.path().join("objects")).unwrap().flatten().all(|e| !e.file_name().to_string_lossy().starts_with("tmp-")));
1290 +
        assert_eq!(
1291 +
            s.loose_count().unwrap(),
1292 +
            2,
1293 +
            "nothing from the big batch is loose"
1294 +
        );
1295 +
        assert!(fs::read_dir(dir.path().join("objects"))
1296 +
            .unwrap()
1297 +
            .flatten()
1298 +
            .all(|e| !e.file_name().to_string_lossy().starts_with("tmp-")));
1081 1299
        let s2 = Store::open(dir.path().join("objects")).unwrap();
1082 1300
        for (r, body) in &refs {
1083 1301
            assert_eq!(s2.get(r).unwrap().1, *body);
…
3 unchanged lines
1087 1305
            let _b = s2.begin_batch().unwrap();
1088 1306
            s2.put(ObjectType::Chunk, b"dropped").unwrap();
1089 1307
        }
1090 -
        assert_eq!(s2.get(&hash_object(ObjectType::Chunk, b"dropped")).unwrap().1, b"dropped");
1308 +
        assert_eq!(
1309 +
            s2.get(&hash_object(ObjectType::Chunk, b"dropped"))
1310 +
                .unwrap()
1311 +
                .1,
1312 +
            b"dropped"
1313 +
        );
1091 1314
    }
1092 1315
}
modified crates/oxy-core/src/subrepo.rs +116 −26
…
6 unchanged lines
7 7
8 8
use crate::cbor::{self, Value};
9 9
use crate::hash::ChangeHash;
10 -
use crate::objects::{array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION};
10 +
use crate::objects::{
11 +
    array, bytes, check_version, field, oref, text, uint, ObjectError, FORMAT_VERSION,
12 +
};
11 13
use std::collections::BTreeMap;
12 14
13 15
#[derive(Clone, Debug, PartialEq, Eq)]
14 16
pub enum Pin {
15 -
    Oxy { repo: [u8; 16], heads: Vec<ChangeHash>, bookmark: String },
16 -
    Git { sha: String, branch: String },
17 +
    Oxy {
18 +
        repo: [u8; 16],
19 +
        heads: Vec<ChangeHash>,
20 +
        bookmark: String,
21 +
    },
22 +
    Git {
23 +
        sha: String,
24 +
        branch: String,
25 +
    },
17 26
}
18 27
19 28
impl Pin {
20 29
    pub fn encode(&self) -> Vec<u8> {
21 30
        let v = match self {
22 -
            Pin::Oxy { repo, heads, bookmark } => {
31 +
            Pin::Oxy {
32 +
                repo,
33 +
                heads,
34 +
                bookmark,
35 +
            } => {
23 36
                let mut heads = heads.clone();
24 37
                heads.sort();
25 38
                heads.dedup();
…
1 unchanged lines
27 40
                    (0, Value::Uint(FORMAT_VERSION)),
28 41
                    (1, Value::Uint(0)),
29 42
                    (2, Value::Bytes(repo.to_vec())),
30 -
                    (3, Value::Array(heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())),
43 +
                    (
44 +
                        3,
45 +
                        Value::Array(heads.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()),
46 +
                    ),
31 47
                    (4, Value::Text(bookmark.clone())),
32 48
                ])
33 49
            }
…
10 unchanged lines
44 60
45 61
    pub fn decode(b: &[u8]) -> Result<Pin, ObjectError> {
46 62
        let v = cbor::decode(b)?;
47 -
        let m = v.as_map().ok_or_else(|| ObjectError::Schema("pin: expected map".into()))?;
63 +
        let m = v
64 +
            .as_map()
65 +
            .ok_or_else(|| ObjectError::Schema("pin: expected map".into()))?;
48 66
        check_version(m)?;
49 67
        let bookmark = text(field(m, 4, "bookmark")?, "bookmark")?.to_owned();
50 68
        match uint(field(m, 1, "kind")?, "kind")? {
51 69
            0 => {
52 -
                let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?.try_into().map_err(|_| ObjectError::Schema("pin: repo id must be 16 bytes".into()))?;
53 -
                let heads = array(field(m, 3, "heads")?, "heads")?.iter().map(|h| oref(h, "head")).collect::<Result<Vec<_>, _>>()?;
54 -
                Ok(Pin::Oxy { repo, heads, bookmark })
70 +
                let repo: [u8; 16] = bytes(field(m, 2, "repo")?, "repo")?
71 +
                    .try_into()
72 +
                    .map_err(|_| ObjectError::Schema("pin: repo id must be 16 bytes".into()))?;
73 +
                let heads = array(field(m, 3, "heads")?, "heads")?
74 +
                    .iter()
75 +
                    .map(|h| oref(h, "head"))
76 +
                    .collect::<Result<Vec<_>, _>>()?;
77 +
                Ok(Pin::Oxy {
78 +
                    repo,
79 +
                    heads,
80 +
                    bookmark,
81 +
                })
55 82
            }
56 -
            1 => Ok(Pin::Git { sha: text(field(m, 3, "sha")?, "sha")?.to_owned(), branch: bookmark }),
83 +
            1 => Ok(Pin::Git {
84 +
                sha: text(field(m, 3, "sha")?, "sha")?.to_owned(),
85 +
                branch: bookmark,
86 +
            }),
57 87
            k => Err(ObjectError::Schema(format!("pin: unknown kind {k}"))),
58 88
        }
59 89
    }
…
1 unchanged lines
61 91
    /// A one-line description for listings.
62 92
    pub fn summary(&self) -> String {
63 93
        match self {
64 -
            Pin::Oxy { heads, bookmark, .. } => format!("oxy {bookmark} {}", heads.iter().map(|h| h.short(12)).collect::<Vec<_>>().join(" ")),
94 +
            Pin::Oxy {
95 +
                heads, bookmark, ..
96 +
            } => format!(
97 +
                "oxy {bookmark} {}",
98 +
                heads
99 +
                    .iter()
100 +
                    .map(|h| h.short(12))
101 +
                    .collect::<Vec<_>>()
102 +
                    .join(" ")
103 +
            ),
65 104
            Pin::Git { sha, branch } => format!("git {branch} {}", &sha[..sha.len().min(12)]),
66 105
        }
67 106
    }
…
29 unchanged lines
97 136
                continue;
98 137
            }
99 138
            if let Some(inner) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {
100 -
                let path = inner.trim().trim_matches('"').trim_end_matches('/').to_string();
139 +
                let path = inner
140 +
                    .trim()
141 +
                    .trim_matches('"')
142 +
                    .trim_end_matches('/')
143 +
                    .to_string();
101 144
                out.entries.entry(path.clone()).or_default();
102 145
                current = Some(path);
103 146
                continue;
104 147
            }
105 -
            let (Some(path), Some((k, v))) = (&current, line.split_once('=')) else { continue };
148 +
            let (Some(path), Some((k, v))) = (&current, line.split_once('=')) else {
149 +
                continue;
150 +
            };
106 151
            let v = v.trim().trim_matches('"').to_string();
107 152
            let m = out.entries.get_mut(path).unwrap();
108 153
            match k.trim() {
…
21 unchanged lines
130 175
                s.push_str("kind = git\n");
131 176
            }
132 177
            if let Some(b) = &m.bookmark {
133 -
                s.push_str(&format!("{} = {b}\n", if m.git { "branch" } else { "bookmark" }));
178 +
                s.push_str(&format!(
179 +
                    "{} = {b}\n",
180 +
                    if m.git { "branch" } else { "bookmark" }
181 +
                ));
134 182
            }
135 183
            s.push('\n');
136 184
        }
…
6 unchanged lines
143 191
        let mut path: Option<String> = None;
144 192
        let mut url = String::new();
145 193
        let mut branch: Option<String> = None;
146 -
        let flush = |out: &mut Modules, path: &Option<String>, url: &str, branch: &Option<String>| {
147 -
            if let Some(p) = path {
148 -
                out.entries.insert(p.clone(), Module { url: url.to_string(), bookmark: branch.clone(), git: true });
149 -
            }
150 -
        };
194 +
        let flush =
195 +
            |out: &mut Modules, path: &Option<String>, url: &str, branch: &Option<String>| {
196 +
                if let Some(p) = path {
197 +
                    out.entries.insert(
198 +
                        p.clone(),
199 +
                        Module {
200 +
                            url: url.to_string(),
201 +
                            bookmark: branch.clone(),
202 +
                            git: true,
203 +
                        },
204 +
                    );
205 +
                }
206 +
            };
151 207
        for line in text.lines() {
152 208
            let line = line.trim();
153 209
            if line.starts_with("[submodule") {
…
24 unchanged lines
178 234
            if !m.git {
179 235
                continue;
180 236
            }
181 -
            s.push_str(&format!("[submodule \"{path}\"]\n\tpath = {path}\n\turl = {}\n", m.url));
237 +
            s.push_str(&format!(
238 +
                "[submodule \"{path}\"]\n\tpath = {path}\n\turl = {}\n",
239 +
                m.url
240 +
            ));
182 241
            if let Some(b) = &m.bookmark {
183 242
                s.push_str(&format!("\tbranch = {b}\n"));
184 243
            }
…
9 unchanged lines
194 253
195 254
    #[test]
196 255
    fn pins_round_trip() {
197 -
        let o = Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([2; 32]), ObjectRef([1; 32])], bookmark: "main".into() };
256 +
        let o = Pin::Oxy {
257 +
            repo: [7; 16],
258 +
            heads: vec![ObjectRef([2; 32]), ObjectRef([1; 32])],
259 +
            bookmark: "main".into(),
260 +
        };
198 261
        let back = Pin::decode(&o.encode()).unwrap();
199 -
        assert_eq!(back, Pin::Oxy { repo: [7; 16], heads: vec![ObjectRef([1; 32]), ObjectRef([2; 32])], bookmark: "main".into() });
200 -
        let g = Pin::Git { sha: "0cb4742f64cf204e5fafd0963749d84afc561892".into(), branch: "main".into() };
262 +
        assert_eq!(
263 +
            back,
264 +
            Pin::Oxy {
265 +
                repo: [7; 16],
266 +
                heads: vec![ObjectRef([1; 32]), ObjectRef([2; 32])],
267 +
                bookmark: "main".into()
268 +
            }
269 +
        );
270 +
        let g = Pin::Git {
271 +
            sha: "0cb4742f64cf204e5fafd0963749d84afc561892".into(),
272 +
            branch: "main".into(),
273 +
        };
201 274
        assert_eq!(Pin::decode(&g.encode()).unwrap(), g);
202 275
        assert!(g.is_git() && !o.is_git());
203 276
    }
…
2 unchanged lines
206 279
    fn modules_files() {
207 280
        let text = "[libs/sub]\nurl = ../sub\nbookmark = main\n\n[vendor/x]\nurl = https://example.org/x.git\nkind = git\nbranch = trunk\n";
208 281
        let m = Modules::parse(text);
209 -
        assert_eq!(m.entries["libs/sub"], Module { url: "../sub".into(), bookmark: Some("main".into()), git: false });
210 -
        assert_eq!(m.entries["vendor/x"], Module { url: "https://example.org/x.git".into(), bookmark: Some("trunk".into()), git: true });
282 +
        assert_eq!(
283 +
            m.entries["libs/sub"],
284 +
            Module {
285 +
                url: "../sub".into(),
286 +
                bookmark: Some("main".into()),
287 +
                git: false
288 +
            }
289 +
        );
290 +
        assert_eq!(
291 +
            m.entries["vendor/x"],
292 +
            Module {
293 +
                url: "https://example.org/x.git".into(),
294 +
                bookmark: Some("trunk".into()),
295 +
                git: true
296 +
            }
297 +
        );
211 298
        assert_eq!(Modules::parse(&m.to_text()), m);
212 299
        let gm = "[submodule \"rstar-api\"]\n\tpath = rstar-api\n\turl = https://github.com/x/rstar-api.git\n[submodule \"c\"]\n\tpath = cstar-api\n\turl = https://github.com/x/cstar-api.git\n\tbranch = dev\n";
213 300
        let g = Modules::from_gitmodules(gm);
214 301
        assert_eq!(g.entries.len(), 2);
215 302
        assert!(g.entries["rstar-api"].git && g.entries["rstar-api"].bookmark.is_none());
216 303
        assert_eq!(g.entries["cstar-api"].bookmark.as_deref(), Some("dev"));
217 -
        assert!(g.to_gitmodules().contains("[submodule \"cstar-api\"]") && g.to_gitmodules().contains("branch = dev"));
304 +
        assert!(
305 +
            g.to_gitmodules().contains("[submodule \"cstar-api\"]")
306 +
                && g.to_gitmodules().contains("branch = dev")
307 +
        );
218 308
    }
219 309
}
modified crates/oxy-core/src/tree.rs +375 −53
…
33 unchanged lines
34 34
#[derive(Clone, Debug, PartialEq, Eq)]
35 35
pub enum TreeConflict {
36 36
    /// Two live nodes share `(parent, name)`.
37 -
    Name { parent: NodeId, name: String, nodes: Vec<NodeId> },
37 +
    Name {
38 +
        parent: NodeId,
39 +
        name: String,
40 +
        nodes: Vec<NodeId>,
41 +
    },
38 42
    /// One node has several live placements.
39 -
    MultiPlacement { node: NodeId, placements: Vec<OpKey> },
43 +
    MultiPlacement {
44 +
        node: NodeId,
45 +
        placements: Vec<OpKey>,
46 +
    },
40 47
    /// A live node's parent is not live.
41 48
    Orphan { node: NodeId, parent: NodeId },
42 49
    /// A directory is placed inside its own descendant.
…
41 unchanged lines
84 91
    pub fn apply(&mut self, change: ChangeHash, index: u32, op: &TreeOp) {
85 92
        let key = (change, index);
86 93
        match op {
87 -
            TreeOp::AddNode { node, kind, mode, parent, name } => {
94 +
            TreeOp::AddNode {
95 +
                node,
96 +
                kind,
97 +
                mode,
98 +
                parent,
99 +
                name,
100 +
            } => {
88 101
                self.kinds.insert(*node, *kind);
89 102
                self.introduced.insert(*node, change);
90 -
                self.placements.insert(key, Placement { node: *node, parent: *parent, name: name.clone() });
103 +
                self.placements.insert(
104 +
                    key,
105 +
                    Placement {
106 +
                        node: *node,
107 +
                        parent: *parent,
108 +
                        name: name.clone(),
109 +
                    },
110 +
                );
91 111
                self.placements_of.entry(*node).or_default().insert(key);
92 112
                if *kind != NodeKind::Dir {
93 113
                    self.modes.insert(key, (*node, *mode));
…
1 unchanged lines
95 115
                }
96 116
            }
97 117
            TreeOp::RemoveNode { placement, .. } => {
98 -
                self.deleted_placements.entry(op_key(*placement)).or_default().insert(change);
118 +
                self.deleted_placements
119 +
                    .entry(op_key(*placement))
120 +
                    .or_default()
121 +
                    .insert(change);
99 122
            }
100 -
            TreeOp::Move { node, old, parent, name } => {
101 -
                self.deleted_placements.entry(op_key(*old)).or_default().insert(change);
102 -
                self.placements.insert(key, Placement { node: *node, parent: *parent, name: name.clone() });
123 +
            TreeOp::Move {
124 +
                node,
125 +
                old,
126 +
                parent,
127 +
                name,
128 +
            } => {
129 +
                self.deleted_placements
130 +
                    .entry(op_key(*old))
131 +
                    .or_default()
132 +
                    .insert(change);
133 +
                self.placements.insert(
134 +
                    key,
135 +
                    Placement {
136 +
                        node: *node,
137 +
                        parent: *parent,
138 +
                        name: name.clone(),
139 +
                    },
140 +
                );
103 141
                self.placements_of.entry(*node).or_default().insert(key);
104 142
            }
105 143
            TreeOp::SetMode { node, old, mode } => {
106 -
                self.deleted_modes.entry(op_key(*old)).or_default().insert(change);
144 +
                self.deleted_modes
145 +
                    .entry(op_key(*old))
146 +
                    .or_default()
147 +
                    .insert(change);
107 148
                self.modes.insert(key, (*node, *mode));
108 149
                self.modes_of.entry(*node).or_default().insert(key);
109 150
            }
…
8 unchanged lines
118 159
    }
119 160
120 161
    pub fn kind(&self, node: &NodeId) -> Option<NodeKind> {
121 -
        if node.is_root() { Some(NodeKind::Dir) } else { self.kinds.get(node).copied() }
162 +
        if node.is_root() {
163 +
            Some(NodeKind::Dir)
164 +
        } else {
165 +
            self.kinds.get(node).copied()
166 +
        }
122 167
    }
123 168
124 169
    /// The change whose `AddNode` introduced `node`.
…
27 unchanged lines
152 197
153 198
    /// Every node that has at least one live placement.
154 199
    pub fn placed_nodes(&self) -> impl Iterator<Item = NodeId> + '_ {
155 -
        self.placements_of.keys().copied().filter(|n| !self.live_placements(n).is_empty())
200 +
        self.placements_of
201 +
            .keys()
202 +
            .copied()
203 +
            .filter(|n| !self.live_placements(n).is_empty())
156 204
    }
157 205
158 206
    /// Resolve the tree: assign paths, find conflicts.
…
17 unchanged lines
176 224
        let mut multi: HashSet<NodeId> = HashSet::new();
177 225
        for n in &nodes {
178 226
            let lp = self.live_placements(n);
179 -
            let distinct_places: BTreeSet<(&NodeId, &str)> = lp.iter().map(|(_, p)| (&p.parent, p.name.as_str())).collect();
227 +
            let distinct_places: BTreeSet<(&NodeId, &str)> = lp
228 +
                .iter()
229 +
                .map(|(_, p)| (&p.parent, p.name.as_str()))
230 +
                .collect();
180 231
            if distinct_places.len() > 1 {
181 232
                multi.insert(*n);
182 -
                view.conflicts.push(TreeConflict::MultiPlacement { node: *n, placements: lp.iter().map(|(k, _)| *k).collect() });
233 +
                view.conflicts.push(TreeConflict::MultiPlacement {
234 +
                    node: *n,
235 +
                    placements: lp.iter().map(|(k, _)| *k).collect(),
236 +
                });
183 237
            }
184 238
            let lm = self.live_modes(n);
185 239
            let distinct_modes: BTreeSet<Mode> = lm.iter().map(|(_, m)| *m).collect();
186 240
            if distinct_modes.len() > 1 {
187 -
                view.conflicts.push(TreeConflict::Mode { node: *n, modes: distinct_modes.into_iter().collect() });
241 +
                view.conflicts.push(TreeConflict::Mode {
242 +
                    node: *n,
243 +
                    modes: distinct_modes.into_iter().collect(),
244 +
                });
188 245
            }
189 246
        }
190 247
…
3 unchanged lines
194 251
        let mut queue: Vec<(NodeId, String)> = vec![(NodeId::ROOT, String::new())];
195 252
        let mut on_path: HashSet<NodeId> = HashSet::new();
196 253
        while let Some((dir, dir_path)) = queue.pop() {
197 -
            let Some(kids) = children.get(&dir) else { continue };
254 +
            let Some(kids) = children.get(&dir) else {
255 +
                continue;
256 +
            };
198 257
            // Name conflicts within this directory.
199 258
            let mut by_name: BTreeMap<&str, Vec<(OpKey, &Placement)>> = BTreeMap::new();
200 259
            for (k, p) in kids {
…
2 unchanged lines
203 262
            for (name, group) in by_name {
204 263
                let distinct: BTreeSet<NodeId> = group.iter().map(|(_, p)| p.node).collect();
205 264
                if distinct.len() > 1 {
206 -
                    view.conflicts.push(TreeConflict::Name { parent: dir, name: name.to_string(), nodes: distinct.iter().copied().collect() });
265 +
                    view.conflicts.push(TreeConflict::Name {
266 +
                        parent: dir,
267 +
                        name: name.to_string(),
268 +
                        nodes: distinct.iter().copied().collect(),
269 +
                    });
207 270
                }
208 271
                let suffixed = distinct.len() > 1;
209 272
                for (i, (k, p)) in group.iter().enumerate() {
…
3 unchanged lines
213 276
                        continue;
214 277
                    }
215 278
                    let needs_suffix = suffixed || multi.contains(&node);
216 -
                    let leaf = if needs_suffix { format!("{name}.oxy-conflict-{}", i + 1) } else { name.to_string() };
217 -
                    let path = if dir_path.is_empty() { leaf } else { format!("{dir_path}/{leaf}") };
279 +
                    let leaf = if needs_suffix {
280 +
                        format!("{name}.oxy-conflict-{}", i + 1)
281 +
                    } else {
282 +
                        name.to_string()
283 +
                    };
284 +
                    let path = if dir_path.is_empty() {
285 +
                        leaf
286 +
                    } else {
287 +
                        format!("{dir_path}/{leaf}")
288 +
                    };
218 289
                    let kind = self.kind(&node).unwrap_or(NodeKind::File);
219 290
                    let modes = self.live_modes(&node);
220 291
                    let mode_atom = modes.iter().map(|(k, _)| *k).max();
221 -
                    let mode = mode_atom.and_then(|k| self.modes.get(&k).map(|(_, m)| *m)).unwrap_or_default();
222 -
                    view.entries.insert(path.clone(), TreeEntry { path: path.clone(), node, kind, mode, placement: *k, mode_atom });
292 +
                    let mode = mode_atom
293 +
                        .and_then(|k| self.modes.get(&k).map(|(_, m)| *m))
294 +
                        .unwrap_or_default();
295 +
                    view.entries.insert(
296 +
                        path.clone(),
297 +
                        TreeEntry {
298 +
                            path: path.clone(),
299 +
                            node,
300 +
                            kind,
301 +
                            mode,
302 +
                            placement: *k,
303 +
                            mode_atom,
304 +
                        },
305 +
                    );
223 306
                    if kind == NodeKind::Dir && reached.insert(node) {
224 307
                        on_path.insert(dir);
225 308
                        queue.push((node, path));
…
21 unchanged lines
247 330
                }
248 331
                let lp = self.live_placements(&cur);
249 332
                let Some((_, p)) = lp.first() else {
250 -
                    break TreeConflict::Orphan { node: *n, parent: cur };
333 +
                    break TreeConflict::Orphan {
334 +
                        node: *n,
335 +
                        parent: cur,
336 +
                    };
251 337
                };
252 338
                if reached.contains(&p.parent) {
253 339
                    // Parent reached but this node was not: the parent is not a
254 340
                    // directory, so this is a name/kind problem surfaced as orphan.
255 -
                    break TreeConflict::Orphan { node: *n, parent: p.parent };
341 +
                    break TreeConflict::Orphan {
342 +
                        node: *n,
343 +
                        parent: p.parent,
344 +
                    };
256 345
                }
257 346
                cur = p.parent;
258 347
            };
…
17 unchanged lines
276 365
                ks.into_iter()
277 366
                    .map(|k| {
278 367
                        let mut v = opkey(k);
279 -
                        v.push(Value::Array(m[k].iter().map(|d| Value::Bytes(d.0.to_vec())).collect()));
368 +
                        v.push(Value::Array(
369 +
                            m[k].iter().map(|d| Value::Bytes(d.0.to_vec())).collect(),
370 +
                        ));
280 371
                        Value::Array(v)
281 372
                    })
282 373
                    .collect(),
…
15 unchanged lines
298 389
                            Value::Array(vec![
299 390
                                Value::Bytes(n.0.to_vec()),
300 391
                                Value::Uint(self.kinds[n].tag()),
301 -
                                self.introduced.get(n).map(|h| Value::Bytes(h.0.to_vec())).unwrap_or(Value::Null),
392 +
                                self.introduced
393 +
                                    .get(n)
394 +
                                    .map(|h| Value::Bytes(h.0.to_vec()))
395 +
                                    .unwrap_or(Value::Null),
302 396
                            ])
303 397
                        })
304 398
                        .collect(),
…
6 unchanged lines
311 405
                        .map(|k| {
312 406
                            let p = &self.placements[k];
313 407
                            let mut v = opkey(k);
314 -
                            v.extend([Value::Bytes(p.node.0.to_vec()), Value::Bytes(p.parent.0.to_vec()), Value::Text(p.name.clone())]);
408 +
                            v.extend([
409 +
                                Value::Bytes(p.node.0.to_vec()),
410 +
                                Value::Bytes(p.parent.0.to_vec()),
411 +
                                Value::Text(p.name.clone()),
412 +
                            ]);
315 413
                            Value::Array(v)
316 414
                        })
317 415
                        .collect(),
…
20 unchanged lines
338 436
    pub fn from_value(v: &crate::cbor::Value) -> Result<TreeGraph, crate::objects::ObjectError> {
339 437
        use crate::objects::{array, bytes, field, map, oref, text, u32_, uint, ObjectError};
340 438
        let m = map(v, "tree graph")?;
341 -
        let node = |v: &crate::cbor::Value| NodeId::from_slice(bytes(v, "node")?).ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()));
342 -
        let opkey = |e: &[crate::cbor::Value]| -> Result<OpKey, ObjectError> { Ok((oref(&e[0], "op change")?, u32_(&e[1], "op index")?)) };
439 +
        let node = |v: &crate::cbor::Value| {
440 +
            NodeId::from_slice(bytes(v, "node")?)
441 +
                .ok_or_else(|| ObjectError::Schema("node: expected 16 bytes".into()))
442 +
        };
443 +
        let opkey = |e: &[crate::cbor::Value]| -> Result<OpKey, ObjectError> {
444 +
            Ok((oref(&e[0], "op change")?, u32_(&e[1], "op index")?))
445 +
        };
343 446
        let mut g = TreeGraph::default();
344 447
        for n in array(field(m, 0, "nodes")?, "nodes")? {
345 448
            let e = array(n, "node entry")?;
…
1 unchanged lines
347 450
                return Err(ObjectError::Schema("node entry: expected 3 fields".into()));
348 451
            }
349 452
            let id = node(&e[0])?;
350 -
            g.kinds.insert(id, NodeKind::from_tag(uint(&e[1], "kind")?)?);
453 +
            g.kinds
454 +
                .insert(id, NodeKind::from_tag(uint(&e[1], "kind")?)?);
351 455
            if !e[2].is_null() {
352 456
                g.introduced.insert(id, oref(&e[2], "introducer")?);
353 457
            }
…
4 unchanged lines
358 462
                return Err(ObjectError::Schema("placement: expected 5 fields".into()));
359 463
            }
360 464
            let k = opkey(e)?;
361 -
            let pl = Placement { node: node(&e[2])?, parent: node(&e[3])?, name: text(&e[4], "name")?.to_owned() };
465 +
            let pl = Placement {
466 +
                node: node(&e[2])?,
467 +
                parent: node(&e[3])?,
468 +
                name: text(&e[4], "name")?.to_owned(),
469 +
            };
362 470
            g.placements_of.entry(pl.node).or_default().insert(k);
363 471
            g.placements.insert(k, pl);
364 472
        }
365 -
        let deleters = |v: &crate::cbor::Value, what: &str| -> Result<HashMap<OpKey, BTreeSet<ChangeHash>>, ObjectError> {
473 +
        let deleters = |v: &crate::cbor::Value,
474 +
                        what: &str|
475 +
         -> Result<HashMap<OpKey, BTreeSet<ChangeHash>>, ObjectError> {
366 476
            let mut out = HashMap::new();
367 477
            for d in array(v, what)? {
368 478
                let e = array(d, what)?;
369 479
                if e.len() != 3 {
370 480
                    return Err(ObjectError::Schema(format!("{what}: expected 3 fields")));
371 481
                }
372 -
                out.insert(opkey(e)?, array(&e[2], "deleters")?.iter().map(|x| oref(x, "deleter")).collect::<Result<BTreeSet<_>, _>>()?);
482 +
                out.insert(
483 +
                    opkey(e)?,
484 +
                    array(&e[2], "deleters")?
485 +
                        .iter()
486 +
                        .map(|x| oref(x, "deleter"))
487 +
                        .collect::<Result<BTreeSet<_>, _>>()?,
488 +
                );
373 489
            }
374 490
            Ok(out)
375 491
        };
…
5 unchanged lines
381 497
            }
382 498
            let k = opkey(e)?;
383 499
            let n = node(&e[2])?;
384 -
            g.modes.insert(k, (n, Mode::from_bits(uint(&e[3], "mode bits")?)?));
500 +
            g.modes
501 +
                .insert(k, (n, Mode::from_bits(uint(&e[3], "mode bits")?)?));
385 502
            g.modes_of.entry(n).or_default().insert(k);
386 503
        }
387 504
        g.deleted_modes = deleters(field(m, 4, "deleted modes")?, "deleted mode")?;
…
17 unchanged lines
405 522
        NodeId([n; 16])
406 523
    }
407 524
    fn add(g: &mut TreeGraph, c: u8, i: u32, node: u8, kind: NodeKind, parent: NodeId, name: &str) {
408 -
        g.apply(h(c), i, &TreeOp::AddNode { node: nid(node), kind, mode: Mode::TEXT, parent, name: name.into() });
525 +
        g.apply(
526 +
            h(c),
527 +
            i,
528 +
            &TreeOp::AddNode {
529 +
                node: nid(node),
530 +
                kind,
531 +
                mode: Mode::TEXT,
532 +
                parent,
533 +
                name: name.into(),
534 +
            },
535 +
        );
409 536
    }
410 537
    fn paths(v: &TreeView) -> Vec<String> {
411 538
        v.entries.keys().cloned().collect()
…
10 unchanged lines
422 549
        assert!(!v.is_conflicted());
423 550
424 551
        // Rename src/main.rs → src/lib.rs, move src → lib (subtree follows).
425 -
        g.apply(h(2), 0, &TreeOp::Move { node: nid(2), old: OpRef { change: h(1), index: 1 }, parent: nid(1), name: "lib.rs".into() });
426 -
        g.apply(h(2), 1, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "lib".into() });
552 +
        g.apply(
553 +
            h(2),
554 +
            0,
555 +
            &TreeOp::Move {
556 +
                node: nid(2),
557 +
                old: OpRef {
558 +
                    change: h(1),
559 +
                    index: 1,
560 +
                },
561 +
                parent: nid(1),
562 +
                name: "lib.rs".into(),
563 +
            },
564 +
        );
565 +
        g.apply(
566 +
            h(2),
567 +
            1,
568 +
            &TreeOp::Move {
569 +
                node: nid(1),
570 +
                old: OpRef {
571 +
                    change: h(1),
572 +
                    index: 0,
573 +
                },
574 +
                parent: NodeId::ROOT,
575 +
                name: "lib".into(),
576 +
            },
577 +
        );
427 578
        let v = g.view();
428 579
        assert_eq!(paths(&v), ["README", "lib", "lib/lib.rs"]);
429 580
        assert_eq!(v.entries["lib/lib.rs"].placement, (h(2), 0));
430 581
431 -
        g.apply(h(3), 0, &TreeOp::RemoveNode { node: nid(3), placement: OpRef { change: h(1), index: 2 } });
582 +
        g.apply(
583 +
            h(3),
584 +
            0,
585 +
            &TreeOp::RemoveNode {
586 +
                node: nid(3),
587 +
                placement: OpRef {
588 +
                    change: h(1),
589 +
                    index: 2,
590 +
                },
591 +
            },
592 +
        );
432 593
        assert_eq!(paths(&g.view()), ["lib", "lib/lib.rs"]);
433 594
    }
434 595
…
4 unchanged lines
439 600
        add(&mut g, 2, 0, 2, NodeKind::File, NodeId::ROOT, "a.txt");
440 601
        let v = g.view();
441 602
        assert_eq!(paths(&v), ["a.txt.oxy-conflict-1", "a.txt.oxy-conflict-2"]);
442 -
        assert_eq!(v.conflicts, vec![TreeConflict::Name { parent: NodeId::ROOT, name: "a.txt".into(), nodes: vec![nid(1), nid(2)] }]);
603 +
        assert_eq!(
604 +
            v.conflicts,
605 +
            vec![TreeConflict::Name {
606 +
                parent: NodeId::ROOT,
607 +
                name: "a.txt".into(),
608 +
                nodes: vec![nid(1), nid(2)]
609 +
            }]
610 +
        );
443 611
    }
444 612
445 613
    #[test]
446 614
    fn concurrent_renames_are_multi_placement() {
447 615
        let mut g = TreeGraph::new();
448 616
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "a");
449 -
        g.apply(h(2), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "b".into() });
450 -
        g.apply(h(3), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: NodeId::ROOT, name: "c".into() });
617 +
        g.apply(
618 +
            h(2),
619 +
            0,
620 +
            &TreeOp::Move {
621 +
                node: nid(1),
622 +
                old: OpRef {
623 +
                    change: h(1),
624 +
                    index: 0,
625 +
                },
626 +
                parent: NodeId::ROOT,
627 +
                name: "b".into(),
628 +
            },
629 +
        );
630 +
        g.apply(
631 +
            h(3),
632 +
            0,
633 +
            &TreeOp::Move {
634 +
                node: nid(1),
635 +
                old: OpRef {
636 +
                    change: h(1),
637 +
                    index: 0,
638 +
                },
639 +
                parent: NodeId::ROOT,
640 +
                name: "c".into(),
641 +
            },
642 +
        );
451 643
        let v = g.view();
452 644
        assert_eq!(paths(&v), ["b.oxy-conflict-1", "c.oxy-conflict-1"]);
453 -
        assert!(matches!(v.conflicts[0], TreeConflict::MultiPlacement { node, .. } if node == nid(1)));
645 +
        assert!(
646 +
            matches!(v.conflicts[0], TreeConflict::MultiPlacement { node, .. } if node == nid(1))
647 +
        );
454 648
    }
455 649
456 650
    #[test]
…
2 unchanged lines
459 653
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "d");
460 654
        add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "f");
461 655
        // Concurrently: 2 removes d, 3 adds d/g.
462 -
        g.apply(h(2), 0, &TreeOp::RemoveNode { node: nid(1), placement: OpRef { change: h(1), index: 0 } });
656 +
        g.apply(
657 +
            h(2),
658 +
            0,
659 +
            &TreeOp::RemoveNode {
660 +
                node: nid(1),
661 +
                placement: OpRef {
662 +
                    change: h(1),
663 +
                    index: 0,
664 +
                },
665 +
            },
666 +
        );
463 667
        add(&mut g, 3, 0, 3, NodeKind::File, nid(1), "g");
464 668
        let v = g.view();
465 669
        assert_eq!(paths(&v), Vec::<String>::new());
466 -
        assert!(v.conflicts.contains(&TreeConflict::Orphan { node: nid(2), parent: nid(1) }));
467 -
        assert!(v.conflicts.contains(&TreeConflict::Orphan { node: nid(3), parent: nid(1) }));
670 +
        assert!(v.conflicts.contains(&TreeConflict::Orphan {
671 +
            node: nid(2),
672 +
            parent: nid(1)
673 +
        }));
674 +
        assert!(v.conflicts.contains(&TreeConflict::Orphan {
675 +
            node: nid(3),
676 +
            parent: nid(1)
677 +
        }));
468 678
469 679
        // Cycle: a/b, then concurrently move a under b and b under a's parent... simplest: move a into b.
470 680
        let mut g = TreeGraph::new();
471 681
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "a");
472 682
        add(&mut g, 1, 1, 2, NodeKind::Dir, nid(1), "b");
473 -
        g.apply(h(2), 0, &TreeOp::Move { node: nid(1), old: OpRef { change: h(1), index: 0 }, parent: nid(2), name: "a".into() });
683 +
        g.apply(
684 +
            h(2),
685 +
            0,
686 +
            &TreeOp::Move {
687 +
                node: nid(1),
688 +
                old: OpRef {
689 +
                    change: h(1),
690 +
                    index: 0,
691 +
                },
692 +
                parent: nid(2),
693 +
                name: "a".into(),
694 +
            },
695 +
        );
474 696
        let v = g.view();
475 -
        assert!(v.conflicts.iter().any(|c| matches!(c, TreeConflict::Cycle { .. })), "{:?}", v.conflicts);
697 +
        assert!(
698 +
            v.conflicts
699 +
                .iter()
700 +
                .any(|c| matches!(c, TreeConflict::Cycle { .. })),
701 +
            "{:?}",
702 +
            v.conflicts
703 +
        );
476 704
    }
477 705
478 706
    #[test]
…
1 unchanged lines
480 708
        let mut g = TreeGraph::new();
481 709
        add(&mut g, 1, 0, 1, NodeKind::Dir, NodeId::ROOT, "src");
482 710
        add(&mut g, 1, 1, 2, NodeKind::File, nid(1), "main.rs");
483 -
        g.apply(h(2), 0, &TreeOp::Move { node: nid(2), old: OpRef { change: h(1), index: 1 }, parent: nid(1), name: "lib.rs".into() });
484 -
        g.apply(h(3), 0, &TreeOp::SetMode { node: nid(2), old: OpRef { change: h(1), index: 1 }, mode: Mode { exec: true, opaque: false } });
711 +
        g.apply(
712 +
            h(2),
713 +
            0,
714 +
            &TreeOp::Move {
715 +
                node: nid(2),
716 +
                old: OpRef {
717 +
                    change: h(1),
718 +
                    index: 1,
719 +
                },
720 +
                parent: nid(1),
721 +
                name: "lib.rs".into(),
722 +
            },
723 +
        );
724 +
        g.apply(
725 +
            h(3),
726 +
            0,
727 +
            &TreeOp::SetMode {
728 +
                node: nid(2),
729 +
                old: OpRef {
730 +
                    change: h(1),
731 +
                    index: 1,
732 +
                },
733 +
                mode: Mode {
734 +
                    exec: true,
735 +
                    opaque: false,
736 +
                },
737 +
            },
738 +
        );
485 739
        let bytes = crate::cbor::encode(&g.to_value());
486 740
        let back = TreeGraph::from_value(&crate::cbor::decode(&bytes).unwrap()).unwrap();
487 741
        assert_eq!(back.view(), g.view());
…
5 unchanged lines
493 747
    fn mode_conflict_and_resolution() {
494 748
        let mut g = TreeGraph::new();
495 749
        add(&mut g, 1, 0, 1, NodeKind::File, NodeId::ROOT, "run.sh");
496 -
        g.apply(h(2), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(1), index: 0 }, mode: Mode { exec: true, opaque: false } });
497 -
        g.apply(h(3), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(1), index: 0 }, mode: Mode { exec: false, opaque: true } });
750 +
        g.apply(
751 +
            h(2),
752 +
            0,
753 +
            &TreeOp::SetMode {
754 +
                node: nid(1),
755 +
                old: OpRef {
756 +
                    change: h(1),
757 +
                    index: 0,
758 +
                },
759 +
                mode: Mode {
760 +
                    exec: true,
761 +
                    opaque: false,
762 +
                },
763 +
            },
764 +
        );
765 +
        g.apply(
766 +
            h(3),
767 +
            0,
768 +
            &TreeOp::SetMode {
769 +
                node: nid(1),
770 +
                old: OpRef {
771 +
                    change: h(1),
772 +
                    index: 0,
773 +
                },
774 +
                mode: Mode {
775 +
                    exec: false,
776 +
                    opaque: true,
777 +
                },
778 +
            },
779 +
        );
498 780
        let v = g.view();
499 781
        assert!(matches!(v.conflicts[0], TreeConflict::Mode { .. }));
500 782
        // Materialized with the greatest mode atom.
501 -
        assert_eq!(v.entries["run.sh"].mode, Mode { exec: false, opaque: true });
783 +
        assert_eq!(
784 +
            v.entries["run.sh"].mode,
785 +
            Mode {
786 +
                exec: false,
787 +
                opaque: true
788 +
            }
789 +
        );
502 790
        // The placement introduced by the same AddNode is unaffected.
503 791
        assert_eq!(v.entries["run.sh"].placement, (h(1), 0));
504 792
        // Resolution: retire both live mode atoms; identical live values do not conflict.
505 -
        g.apply(h(4), 0, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(2), index: 0 }, mode: Mode { exec: true, opaque: false } });
506 -
        g.apply(h(4), 1, &TreeOp::SetMode { node: nid(1), old: OpRef { change: h(3), index: 0 }, mode: Mode { exec: true, opaque: false } });
793 +
        g.apply(
794 +
            h(4),
795 +
            0,
796 +
            &TreeOp::SetMode {
797 +
                node: nid(1),
798 +
                old: OpRef {
799 +
                    change: h(2),
800 +
                    index: 0,
801 +
                },
802 +
                mode: Mode {
803 +
                    exec: true,
804 +
                    opaque: false,
805 +
                },
806 +
            },
807 +
        );
808 +
        g.apply(
809 +
            h(4),
810 +
            1,
811 +
            &TreeOp::SetMode {
812 +
                node: nid(1),
813 +
                old: OpRef {
814 +
                    change: h(3),
815 +
                    index: 0,
816 +
                },
817 +
                mode: Mode {
818 +
                    exec: true,
819 +
                    opaque: false,
820 +
                },
821 +
            },
822 +
        );
507 823
        let v = g.view();
508 824
        assert!(!v.is_conflicted(), "{:?}", v.conflicts);
509 -
        assert_eq!(v.entries["run.sh"].mode, Mode { exec: true, opaque: false });
825 +
        assert_eq!(
826 +
            v.entries["run.sh"].mode,
827 +
            Mode {
828 +
                exec: true,
829 +
                opaque: false
830 +
            }
831 +
        );
510 832
    }
511 833
}
modified crates/oxy-server/src/lib.rs +96 −18
…
150 unchanged lines
151 151
pub fn valid_repo_name(name: &str) -> bool {
152 152
    !name.is_empty()
153 153
        && !name.starts_with('.')
154 -
        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
154 +
        && name
155 +
            .chars()
156 +
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
155 157
}
156 158
157 159
impl Server {
158 160
    pub fn new(backend: impl Backend) -> Arc<Server> {
159 -
        Arc::new(Server { backend: Box::new(backend), locks: Mutex::new(HashMap::new()) })
161 +
        Arc::new(Server {
162 +
            backend: Box::new(backend),
163 +
            locks: Mutex::new(HashMap::new()),
164 +
        })
160 165
    }
161 166
162 167
    pub fn backend(&self) -> &dyn Backend {
…
1 unchanged lines
164 169
    }
165 170
166 171
    fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
167 -
        self.locks.lock().unwrap().entry(name.to_string()).or_default().clone()
172 +
        self.locks
173 +
            .lock()
174 +
            .unwrap()
175 +
            .entry(name.to_string())
176 +
            .or_default()
177 +
            .clone()
168 178
    }
169 179
170 180
    /// Answer one protocol request for repository `name`. `method` is
171 181
    /// checked against the endpoint; the body is the raw CBOR request.
172 -
    pub async fn handle(self: &Arc<Self>, name: String, endpoint: Endpoint, method: &Method, headers: &HeaderMap, body: Bytes) -> Response {
182 +
    pub async fn handle(
183 +
        self: &Arc<Self>,
184 +
        name: String,
185 +
        endpoint: Endpoint,
186 +
        method: &Method,
187 +
        headers: &HeaderMap,
188 +
        body: Bytes,
189 +
    ) -> Response {
173 190
        if *method != endpoint.method() {
174 -
            return Failure(StatusCode::METHOD_NOT_ALLOWED, format!("{} is {}", endpoint.name(), endpoint.method())).into_response();
191 +
            return Failure(
192 +
                StatusCode::METHOD_NOT_ALLOWED,
193 +
                format!("{} is {}", endpoint.name(), endpoint.method()),
194 +
            )
195 +
            .into_response();
175 196
        }
176 197
        if let Err(f) = self.backend.authorize(&name, endpoint.access(), headers) {
177 198
            return f.into_response();
178 199
        }
200 +
        let claimed = headers
201 +
            .get(oxy_core::protocol::REPO_ID_HEADER)
202 +
            .and_then(|v| v.to_str().ok())
203 +
            .and_then(hex_id);
179 204
        let server = Arc::clone(self);
180 -
        let result = tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body)).await;
205 +
        let result =
206 +
            tokio::task::spawn_blocking(move || server.respond(&name, endpoint, body, claimed)).await;
181 207
        match result {
182 208
            Ok(Ok(r)) => r,
183 209
            Ok(Err(f)) => f.into_response(),
…
1 unchanged lines
185 211
        }
186 212
    }
187 213
188 -
    fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes) -> Result<Response, Failure> {
214 +
    fn respond(&self, name: &str, endpoint: Endpoint, body: Bytes, claimed: Option<[u8; 16]>) -> Result<Response, Failure> {
189 215
        let r = self.backend.open(name)?;
190 216
        Ok(match endpoint {
191 217
            Endpoint::Capabilities => ok(wire::encode_capabilities(r.id()?)),
…
4 unchanged lines
196 222
            }
197 223
            Endpoint::Fetch => {
198 224
                let (changes, max_blob) = wire::decode_fetch(&body)?;
199 -
                ok(wire::encode_bundle(&r.bundle_for_filtered(&changes, max_blob)?))
225 +
                ok(wire::encode_bundle(
226 +
                    &r.bundle_for_filtered(&changes, max_blob)?,
227 +
                ))
200 228
            }
201 229
            Endpoint::Objects => {
202 230
                let refs = wire::decode_hashes(&body)?;
203 -
                let bundle = oxy_core::protocol::Bundle { objects: r.objects_for(&refs)?, ..Default::default() };
231 +
                let bundle = oxy_core::protocol::Bundle {
232 +
                    objects: r.objects_for(&refs)?,
233 +
                    ..Default::default()
234 +
                };
204 235
                ok(wire::encode_bundle(&bundle))
205 236
            }
206 237
            Endpoint::Checkpoint => {
…
10 unchanged lines
217 248
                let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
218 249
                let lock = self.lock_for(name);
219 250
                let _guard = lock.lock().unwrap();
251 +
                if r.is_unbound() {
252 +
                    // The first push binds the repository to the pusher's id (spec §16.1).
253 +
                    match claimed {
254 +
                        Some(id) => r.bind_id(id)?,
255 +
                        None => {
256 +
                            return Err(Failure(
257 +
                                StatusCode::BAD_REQUEST,
258 +
                                format!("this repository is unbound; push with a client that sends {}", oxy_core::protocol::REPO_ID_HEADER),
259 +
                            ))
260 +
                        }
261 +
                    }
262 +
                }
220 263
                let results = r.receive_push(bundle, updates, policy, tags)?;
221 264
                self.backend.after_push(name, &results);
222 265
                ok(wire::encode_push_response(&results))
…
27 unchanged lines
250 293
        }
251 294
        match bearer_token(headers) {
252 295
            Some(t) if self.tokens.iter().any(|x| x == t) => Ok(()),
253 -
            _ => Err(Failure(StatusCode::UNAUTHORIZED, "a valid bearer token is required to push".into())),
296 +
            _ => Err(Failure(
297 +
                StatusCode::UNAUTHORIZED,
298 +
                "a valid bearer token is required to push".into(),
299 +
            )),
254 300
        }
255 301
    }
256 302
}
257 303
304 +
fn hex_id(h: &str) -> Option<[u8; 16]> {
305 +
    let h = h.trim();
306 +
    if h.len() != 32 {
307 +
        return None;
308 +
    }
309 +
    let mut out = [0u8; 16];
310 +
    for (i, byte) in out.iter_mut().enumerate() {
311 +
        *byte = u8::from_str_radix(&h[2 * i..2 * i + 2], 16).ok()?;
312 +
    }
313 +
    Some(out)
314 +
}
315 +
258 316
/// The token of an `Authorization: Bearer` header, if any.
259 317
pub fn bearer_token(headers: &HeaderMap) -> Option<&str> {
260 -
    headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).map(str::trim)
318 +
    headers
319 +
        .get(header::AUTHORIZATION)
320 +
        .and_then(|v| v.to_str().ok())
321 +
        .and_then(|v| v.strip_prefix("Bearer "))
322 +
        .map(str::trim)
261 323
}
262 324
263 -
async fn dispatch(State(server): State<Arc<Server>>, Path((repo, endpoint)): Path<(String, String)>, method: Method, headers: HeaderMap, body: Bytes) -> Response {
325 +
async fn dispatch(
326 +
    State(server): State<Arc<Server>>,
327 +
    Path((repo, endpoint)): Path<(String, String)>,
328 +
    method: Method,
329 +
    headers: HeaderMap,
330 +
    body: Bytes,
331 +
) -> Response {
264 332
    match Endpoint::parse(&endpoint) {
265 333
        Some(e) => server.handle(repo, e, &method, &headers, body).await,
266 334
        None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
…
2 unchanged lines
269 337
270 338
/// The router of `oxy serve`: `/{repo}/oxy/v1/{endpoint}` over a [`DirBackend`].
271 339
pub fn router(cfg: ServerConfig) -> Router {
272 -
    let server = Server::new(DirBackend { root: cfg.root, tokens: cfg.tokens });
273 -
    Router::new().route("/{repo}/oxy/v1/{endpoint}", any(dispatch)).with_state(server)
340 +
    let server = Server::new(DirBackend {
341 +
        root: cfg.root,
342 +
        tokens: cfg.tokens,
343 +
    });
344 +
    Router::new()
345 +
        .route("/{repo}/oxy/v1/{endpoint}", any(dispatch))
346 +
        .with_state(server)
274 347
}
275 348
276 349
/// Serve on an already-bound listener until the process ends; HTTPS when
…
3 unchanged lines
280 353
    match cfg.tls.clone() {
281 354
        Some(tls) => {
282 355
            let _ = rustls::crypto::ring::default_provider().install_default();
283 -
            let rustls_cfg = axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
356 +
            let rustls_cfg =
357 +
                axum_server::tls_rustls::RustlsConfig::from_pem_file(&tls.cert, &tls.key)
358 +
                    .await
359 +
                    .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
360 +
            axum_server::from_tcp_rustls(listener, rustls_cfg)?
361 +
                .serve(router(cfg).into_make_service())
284 362
                .await
285 -
                .map_err(|e| std::io::Error::other(format!("loading TLS files: {e}")))?;
286 -
            axum_server::from_tcp_rustls(listener, rustls_cfg)?.serve(router(cfg).into_make_service()).await
287 363
        }
288 364
        None => {
289 365
            let listener = tokio::net::TcpListener::from_std(listener)?;
…
4 unchanged lines
294 370
295 371
/// Build a runtime and serve; for the CLI and for tests on a background thread.
296 372
pub fn run_blocking(listener: std::net::TcpListener, cfg: ServerConfig) -> std::io::Result<()> {
297 -
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
373 +
    let rt = tokio::runtime::Builder::new_multi_thread()
374 +
        .enable_all()
375 +
        .build()?;
298 376
    rt.block_on(serve(listener, cfg))
299 377
}
modified crates/oxy-server/tests/http.rs +181 −32
…
30 unchanged lines
31 31
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
32 32
    let port = listener.local_addr().unwrap().port();
33 33
    let scheme = if tls.is_some() { "https" } else { "http" };
34 -
    let cfg = oxy_server::ServerConfig { root: root.to_path_buf(), tokens, tls };
34 +
    let cfg = oxy_server::ServerConfig {
35 +
        root: root.to_path_buf(),
36 +
        tokens,
37 +
        tls,
38 +
    };
35 39
    std::thread::spawn(move || oxy_server::run_blocking(listener, cfg).unwrap());
36 40
    // Give the acceptor a moment; the first request otherwise races the bind.
37 41
    std::thread::sleep(std::time::Duration::from_millis(50));
…
3 unchanged lines
41 45
/// A self-signed certificate for 127.0.0.1 written to `dir`: (cert, key).
42 46
fn self_signed(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
43 47
    fs::create_dir_all(dir).unwrap();
44 -
    let ck = rcgen::generate_simple_self_signed(vec!["127.0.0.1".into(), "localhost".into()]).unwrap();
48 +
    let ck =
49 +
        rcgen::generate_simple_self_signed(vec!["127.0.0.1".into(), "localhost".into()]).unwrap();
45 50
    let cert = dir.join("cert.pem");
46 51
    let key = dir.join("key.pem");
47 52
    fs::write(&cert, ck.cert.pem()).unwrap();
…
11 unchanged lines
59 64
    let base = start(&served, vec![]);
60 65
    let url = format!("{base}/origin");
61 66
62 -
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
63 -
    let (key, _) = a.create_identity("Ada", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap();
67 +
    let mut a = Repo::clone(&url, &root.join("a"))
68 +
        .unwrap()
69 +
        .with_home(root.join("home"));
70 +
    let (key, _) = a
71 +
        .create_identity(
72 +
            "Ada",
73 +
            "[email protected]",
74 +
            oxy_core::repo::IdentityScope::Repo,
75 +
        )
76 +
        .unwrap();
64 77
    write(&root.join("a"), "f.txt", b"hello\n");
65 78
    let h1 = a.commit("hello", &[]).unwrap();
66 79
    let r = a.push("origin", false).unwrap();
…
1 unchanged lines
68 81
69 82
    let origin = Repo::open(&served.join("origin")).unwrap();
70 83
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
71 -
    assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key: k, sealed: true, .. } if k == key));
84 +
    assert!(
85 +
        matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key: k, sealed: true, .. } if k == key)
86 +
    );
72 87
73 88
    let b = Repo::clone(&url, &root.join("b")).unwrap();
74 89
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"hello\n");
…
11 unchanged lines
86 101
    assert_eq!(heads, want);
87 102
88 103
    // Unknown repositories and names the server refuses are surfaced as errors.
89 -
    let err = Repo::clone(&format!("{base}/nope"), &root.join("c")).err().unwrap().to_string();
104 +
    let err = Repo::clone(&format!("{base}/nope"), &root.join("c"))
105 +
        .err()
106 +
        .unwrap()
107 +
        .to_string();
90 108
    assert!(err.contains("404"), "{err}");
91 109
    fs::create_dir_all(served.join(".hidden/.oxy")).unwrap();
92 -
    assert!(Repo::clone(&format!("{base}/.hidden"), &root.join("d")).err().unwrap().to_string().contains("404"));
110 +
    assert!(Repo::clone(&format!("{base}/.hidden"), &root.join("d"))
111 +
        .err()
112 +
        .unwrap()
113 +
        .to_string()
114 +
        .contains("404"));
93 115
}
94 116
95 117
#[test]
…
10 unchanged lines
106 128
    let base = start(&served, vec![]);
107 129
    let url = format!("{base}/origin");
108 130
    let a = Repo::clone_with(&url, &root.join("a"), Some(1000), &[]).unwrap();
109 -
    assert_eq!(fs::read(root.join("a/big.bin")).unwrap(), big, "fetched through the objects endpoint during checkout");
131 +
    assert_eq!(
132 +
        fs::read(root.join("a/big.bin")).unwrap(),
133 +
        big,
134 +
        "fetched through the objects endpoint during checkout"
135 +
    );
110 136
    assert!(!a.snapshot("status").unwrap().changed);
111 137
}
112 138
…
16 unchanged lines
129 155
130 156
    a.config.set("remote.origin.token", "wrong").unwrap();
131 157
    a.save_config().unwrap();
132 -
    assert!(a.push("origin", false).err().unwrap().to_string().contains("401"));
158 +
    assert!(a
159 +
        .push("origin", false)
160 +
        .err()
161 +
        .unwrap()
162 +
        .to_string()
163 +
        .contains("401"));
133 164
134 165
    a.config.set("remote.origin.token", "s3cret").unwrap();
135 166
    a.save_config().unwrap();
…
10 unchanged lines
146 177
    let served = root.join("served");
147 178
    Repo::init(&served.join("origin")).unwrap();
148 179
    let (cert, key) = self_signed(root);
149 -
    let base = start_with(&served, vec![], Some(oxy_server::TlsFiles { cert: cert.clone(), key }));
180 +
    let base = start_with(
181 +
        &served,
182 +
        vec![],
183 +
        Some(oxy_server::TlsFiles {
184 +
            cert: cert.clone(),
185 +
            key,
186 +
        }),
187 +
    );
150 188
    let url = format!("{base}/origin");
151 189
152 190
    // Without trusting the certificate the clone fails; with `OXY_CA` it works.
153 -
    let err = Repo::clone(&url, &root.join("untrusted")).err().unwrap().to_string();
154 -
    assert!(err.contains("certificate") || err.contains("tls") || err.contains("TLS") || err.contains("handshake"), "{err}");
191 +
    let err = Repo::clone(&url, &root.join("untrusted"))
192 +
        .err()
193 +
        .unwrap()
194 +
        .to_string();
195 +
    assert!(
196 +
        err.contains("certificate")
197 +
            || err.contains("tls")
198 +
            || err.contains("TLS")
199 +
            || err.contains("handshake"),
200 +
        "{err}"
201 +
    );
155 202
    std::env::set_var("OXY_CA", &cert);
156 203
    let mut a = Repo::clone(&url, &root.join("a")).unwrap();
157 204
    std::env::remove_var("OXY_CA");
158 205
    // Persist the trust for this remote so later commands need no env var.
159 -
    a.config.set("remote.origin.ca", cert.to_str().unwrap()).unwrap();
206 +
    a.config
207 +
        .set("remote.origin.ca", cert.to_str().unwrap())
208 +
        .unwrap();
160 209
    a.save_config().unwrap();
161 210
    write(&root.join("a"), "f.txt", b"tls\n");
162 211
    let h = a.commit("over tls", &[]).unwrap();
…
2 unchanged lines
165 214
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h]);
166 215
    // A wrong CA is refused.
167 216
    let (other_cert, _) = self_signed(&root.join("other"));
168 -
    a.config.set("remote.origin.ca", other_cert.to_str().unwrap()).unwrap();
217 +
    a.config
218 +
        .set("remote.origin.ca", other_cert.to_str().unwrap())
219 +
        .unwrap();
169 220
    a.save_config().unwrap();
170 221
    assert!(a.fetch("origin").is_err());
171 222
}
…
23 unchanged lines
195 246
        let barrier = barrier.clone();
196 247
        handles.push(std::thread::spawn(move || {
197 248
            let repo = Repo::open(&path).unwrap();
198 -
            write(&path, &format!("file-{i}.txt"), format!("from clone {i}\n").as_bytes());
249 +
            write(
250 +
                &path,
251 +
                &format!("file-{i}.txt"),
252 +
                format!("from clone {i}\n").as_bytes(),
253 +
            );
199 254
            let h = repo.commit(&format!("change {i}"), &[]).unwrap();
200 255
            barrier.wait();
201 256
            // A push is refused either up front (the remote moved since the
…
3 unchanged lines
205 260
                match repo.push("origin", false) {
206 261
                    Ok(r) if r.iter().all(|x| x.ok) => return (h, attempt),
207 262
                    Ok(_) => {}
208 -
                    Err(e) if e.to_string().contains("pull first") || e.to_string().contains("moved") => {}
263 +
                    Err(e)
264 +
                        if e.to_string().contains("pull first")
265 +
                            || e.to_string().contains("moved") => {}
209 266
                    Err(e) => panic!("clone {i}: {e}"),
210 267
                }
211 -
                repo.pull("origin").unwrap_or_else(|e| panic!("clone {i} pull: {e}"));
268 +
                repo.pull("origin")
269 +
                    .unwrap_or_else(|e| panic!("clone {i} pull: {e}"));
212 270
            }
213 271
            panic!("clone {i} never managed to push");
214 272
        }));
…
20 unchanged lines
235 293
    let ops = origin.op_log().unwrap();
236 294
    assert!(ops.len() > N);
237 295
}
238 -
239 296
240 297
#[test]
241 298
fn a_bare_repository_is_served_and_browsed_without_a_working_copy() {
…
3 unchanged lines
245 302
    let served = root.join("served");
246 303
    let hosted = Repo::init_bare(&served.join("lib")).unwrap();
247 304
    assert!(hosted.is_bare());
248 -
    assert!(hosted.working_copy().is_err(), "a bare repository has no working copy");
305 +
    assert!(
306 +
        hosted.working_copy().is_err(),
307 +
        "a bare repository has no working copy"
308 +
    );
249 309
    assert!(!served.join("lib").join(".oxy").join("workspace").exists());
250 310
    let base = start(&served, vec![]);
251 311
    let url = format!("{base}/lib");
…
19 unchanged lines
271 331
    assert_eq!(log.iter().map(|e| e.hash).collect::<Vec<_>>(), vec![c2, c1]);
272 332
    assert!(log.iter().all(|e| !e.is_working_copy));
273 333
    assert_eq!(hosted.log_state(&[c1]).unwrap().len(), 1);
274 -
    assert_eq!(hosted.file_at("src/lib.rs", Some(&heads)).unwrap(), b"pub fn one() {}\npub fn two() {}\n");
334 +
    assert_eq!(
335 +
        hosted.file_at("src/lib.rs", Some(&heads)).unwrap(),
336 +
        b"pub fn one() {}\npub fn two() {}\n"
337 +
    );
275 338
    let blame = hosted.blame_in("src/lib.rs", &heads).unwrap();
276 -
    assert_eq!(blame.iter().map(|l| l.hash).collect::<Vec<_>>(), vec![c1, c2]);
339 +
    assert_eq!(
340 +
        blame.iter().map(|l| l.hash).collect::<Vec<_>>(),
341 +
        vec![c1, c2]
342 +
    );
277 343
    assert_eq!(hosted.log_for_path("src/lib.rs").unwrap().len(), 2);
278 344
    assert_eq!(hosted.diff_files(Some(&c2)).unwrap().len(), 1);
279 -
    assert!(matches!(hosted.verify_change(&c2).unwrap(), SignatureStatus::Unsigned | SignatureStatus::Valid { .. }));
345 +
    assert!(matches!(
346 +
        hosted.verify_change(&c2).unwrap(),
347 +
        SignatureStatus::Unsigned | SignatureStatus::Valid { .. }
348 +
    ));
280 349
281 350
    // Snapshotting a bare repository is refused rather than inventing a working copy.
282 351
    assert!(hosted.snapshot("test").is_err());
…
15 unchanged lines
298 367
    }
299 368
    impl Backend for Hub {
300 369
        fn open(&self, name: &str) -> Result<Repo, Failure> {
301 -
            let Some((owner, repo)) = name.split_once('/') else { return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into())) };
370 +
            let Some((owner, repo)) = name.split_once('/') else {
371 +
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
372 +
            };
302 373
            let dir = self.root.join(owner).join(repo);
303 374
            if !dir.join(".oxy").is_dir() {
304 375
                return Err(Failure(StatusCode::NOT_FOUND, "no such repository".into()));
305 376
            }
306 377
            Repo::open(&dir).map_err(|e| Failure(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
307 378
        }
308 -
        fn authorize(&self, name: &str, access: Access, headers: &HeaderMap) -> Result<(), Failure> {
379 +
        fn authorize(
380 +
            &self,
381 +
            name: &str,
382 +
            access: Access,
383 +
            headers: &HeaderMap,
384 +
        ) -> Result<(), Failure> {
309 385
            // `secret/*` is private: unknown to anyone without the token, even for reads.
310 386
            let token = oxy_server::bearer_token(headers);
311 387
            if name.starts_with("secret/") && token != Some("s3cret") {
…
5 unchanged lines
317 393
            Ok(())
318 394
        }
319 395
        fn after_push(&self, name: &str, results: &[oxy_core::protocol::RefResult]) {
320 -
            self.pushes.lock().unwrap().push(format!("{name}: {}", results.iter().filter(|r| r.ok).count()));
396 +
            self.pushes.lock().unwrap().push(format!(
397 +
                "{name}: {}",
398 +
                results.iter().filter(|r| r.ok).count()
399 +
            ));
321 400
        }
322 401
    }
323 -
    async fn route(State(server): State<Arc<Server>>, AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>, method: Method, headers: HeaderMap, body: axum::body::Bytes) -> Response {
402 +
    async fn route(
403 +
        State(server): State<Arc<Server>>,
404 +
        AxPath((owner, repo, endpoint)): AxPath<(String, String, String)>,
405 +
        method: Method,
406 +
        headers: HeaderMap,
407 +
        body: axum::body::Bytes,
408 +
    ) -> Response {
324 409
        match Endpoint::parse(&endpoint) {
325 -
            Some(e) => server.handle(format!("{owner}/{repo}"), e, &method, &headers, body).await,
410 +
            Some(e) => {
411 +
                server
412 +
                    .handle(format!("{owner}/{repo}"), e, &method, &headers, body)
413 +
                    .await
414 +
            }
326 415
            None => Failure(StatusCode::NOT_FOUND, "no such endpoint".into()).into_response(),
327 416
        }
328 417
    }
…
4 unchanged lines
333 422
    Repo::init_bare(&root.join("repos/andy/app")).unwrap();
334 423
    Repo::init_bare(&root.join("repos/secret/plans")).unwrap();
335 424
    let pushes = Arc::new(Mutex::new(Vec::new()));
336 -
    let server = Server::new(Hub { root: root.join("repos"), pushes: pushes.clone() });
425 +
    let server = Server::new(Hub {
426 +
        root: root.join("repos"),
427 +
        pushes: pushes.clone(),
428 +
    });
337 429
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
338 430
    let port = listener.local_addr().unwrap().port();
339 431
    std::thread::spawn(move || {
340 -
        let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap();
432 +
        let rt = tokio::runtime::Builder::new_multi_thread()
433 +
            .enable_all()
434 +
            .build()
435 +
            .unwrap();
341 436
        rt.block_on(async move {
342 437
            listener.set_nonblocking(true).unwrap();
343 -
            let app = axum::Router::new().route("/{owner}/{repo}/oxy/v1/{endpoint}", axum::routing::any(route)).with_state(server);
344 -
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app).await.unwrap();
438 +
            let app = axum::Router::new()
439 +
                .route(
440 +
                    "/{owner}/{repo}/oxy/v1/{endpoint}",
441 +
                    axum::routing::any(route),
442 +
                )
443 +
                .with_state(server);
444 +
            axum::serve(tokio::net::TcpListener::from_std(listener).unwrap(), app)
445 +
                .await
446 +
                .unwrap();
345 447
        });
346 448
    });
347 449
    std::thread::sleep(std::time::Duration::from_millis(50));
…
8 unchanged lines
356 458
    write(&work, "a.txt", b"a\n");
357 459
    client.snapshot("test").unwrap();
358 460
    client.commit("a", &[]).unwrap();
359 -
    assert!(client.push("origin", false).is_err(), "anonymous push must be refused");
461 +
    assert!(
462 +
        client.push("origin", false).is_err(),
463 +
        "anonymous push must be refused"
464 +
    );
360 465
    let mut client = client;
361 466
    client.config.set("remote.origin.token", "s3cret").unwrap();
362 467
    let results = client.push("origin", false).unwrap();
…
3 unchanged lines
366 471
    let hosted = Repo::open(&root.join("repos/andy/app")).unwrap();
367 472
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
368 473
}
474 +
475 +
/// A hosted repository created unbound adopts the id of the first
476 +
/// repository pushed to it; before that it cannot be cloned or fetched.
477 +
#[test]
478 +
fn an_unbound_repository_adopts_the_first_pushers_id() {
479 +
    hermetic();
480 +
    let dir = tempfile::tempdir().unwrap();
481 +
    let root = dir.path();
482 +
    let served = root.join("served");
483 +
    let hosted = Repo::init_bare_unbound(&served.join("lib")).unwrap();
484 +
    assert!(hosted.is_unbound());
485 +
    assert_eq!(hosted.id().unwrap(), [0; 16]);
486 +
    let base = start(&served, vec![]);
487 +
    let url = format!("{base}/lib");
488 +
    assert!(Repo::clone(&url, &root.join("early")).err().unwrap().to_string().contains("unbound"));
489 +
490 +
    // An existing repository with its own id and history.
491 +
    let work = root.join("work");
492 +
    let mut local = Repo::init(&work).unwrap();
493 +
    let my_id = local.id().unwrap();
494 +
    write(&work, "a.txt", b"a\n");
495 +
    local.snapshot("test").unwrap();
496 +
    local.commit("a", &[]).unwrap();
497 +
    local.config.set("remote.origin.url", &url).unwrap();
498 +
    local.save_config().unwrap();
499 +
    assert!(local.fetch("origin").err().unwrap().to_string().contains("unbound"), "fetching an unbound remote is refused");
500 +
    let results = local.push("origin", false).unwrap();
501 +
    assert!(results.iter().all(|r| r.ok), "{results:?}");
502 +
503 +
    let hosted = Repo::open(&served.join("lib")).unwrap();
504 +
    assert!(!hosted.is_unbound());
505 +
    assert_eq!(hosted.id().unwrap(), my_id, "bound to the pusher's id");
506 +
    assert_eq!(hosted.bookmark_heads().unwrap().len(), 1);
507 +
    // From now on it is an ordinary remote: clones share the id, other repositories are refused.
508 +
    let cloned = Repo::clone(&url, &root.join("late")).unwrap();
509 +
    assert_eq!(cloned.id().unwrap(), my_id);
510 +
    let mut other = Repo::init(&root.join("other")).unwrap();
511 +
    other.config.set("remote.origin.url", &url).unwrap();
512 +
    other.save_config().unwrap();
513 +
    write(&root.join("other"), "b.txt", b"b\n");
514 +
    other.snapshot("test").unwrap();
515 +
    other.commit("b", &[]).unwrap();
516 +
    assert!(other.push("origin", false).err().unwrap().to_string().contains("different repository"));
517 +
}
modified docs/plan.md +1 −0
…
82 unchanged lines
83 83
84 84
- [x] Bare repositories: `Repo::init_bare`, `is_bare`, `oxy init --bare`; `working_copy` refuses; `log`, `log_for_path` work from the bookmark state; `log_state`, `blame_in`, `log_for_path_in` for any state.
85 85
- [x] Embeddable server: `oxy_server::{Backend, Server, Endpoint, Access, Failure, DirBackend, bearer_token}`; `router` is `DirBackend` on `/{repo}/oxy/v1/{endpoint}`; `after_push` hook.
86 +
- [x] Unbound hosted repositories (`Repo::init_bare_unbound`, `bind_id`, `Oxy-Repo-Id` header): an existing repository pushed to an empty hosted one keeps its id (protocol.md §2).
86 87
- [ ] `oxy identity sign-challenge` and `verify_challenge` (hub 0.2).
87 88
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
88 89
- [ ] `oxy proposal show|land URL` (hub 0.3).
…
3 unchanged lines
modified docs/protocol.md +9 −0
…
56 unchanged lines
57 57
  plus an optional PEM file (`remote.<name>.ca` or `OXY_CA`); servers with
58 58
  self-signed certificates are expected to hand that file out.
59 59
- Pushes to one repository are serialised by the server; reads are not.
60 +
- A hosted repository may be **unbound**: created empty with no id, so an
61 +
  existing repository can be pushed to it and keep its identity. Its
62 +
  `capabilities` reports an all-zero id. A client refuses to clone or fetch
63 +
  from an unbound remote and accepts it for a push, sending its own id in
64 +
  the `Oxy-Repo-Id` request header (32 hex characters); the server binds
65 +
  the repository to that id under the push lock before validating the
66 +
  push, and refuses a push to an unbound repository that carries no header
67 +
  with `400`. Clients send the header on every request when they have an
68 +
  id; servers ignore it on bound repositories.
60 69
61 70
Embedding: `oxy_server::Server` answers any endpoint for a repository name
62 71
a host's `Backend` resolves and authorises (`open`, `authorize`, and an
…
132 unchanged lines
modified docs/spec.md +3 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8c |
5 +
| **Status** | Draft 0.8d |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
684 unchanged lines
693 693
694 694
| Endpoint | Request | Response |
695 695
|---|---|---|
696 -
| `GET capabilities` | — | `{ versions: [1], features }`; feature names are `seal`, and, when offered, `lock`; `max_frame` and `transports` are reserved for framed transports |
696 +
| `GET capabilities` | — | `{ versions: [1], features }`; feature names are `seal`, and, when offered, `lock`; `max_frame` and `transports` are reserved for framed transports. The repository id is all zero for an **unbound** hosted repository, one created empty that adopts the id of the first repository pushed to it; a client MUST NOT clone or fetch from one and MAY push to one, sending its id in the `Oxy-Repo-Id` header, which the server binds before validating the push |
697 697
| `GET refs` | `{ patterns }` | `[ { name, state: ObjectRef, heads, attestation: Signed<Attest> } ]` plus the current `Signed<Policy>` |
698 698
| `POST negotiate` | `{ have: [ChangeHash], want: [ChangeHash], filter }` | stream of `ChangeHash` the client lacks, in dependency order |
699 699
| `POST fetch` | `{ changes: [ChangeHash], include_blobs: bool, filter }` | stream of `Signed<Change>`, then every `Signed<Attest kind=seal>` covering them, then referenced `blob`/`chunk` objects |
…
215 unchanged lines
915 915
916 916
---
917 917
918 -
*Changelog.* 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.
918 +
*Changelog.* 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.
919 919
920 920
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
921 921
…
59 unchanged lines
modified src/cli/format.rs +37 −6
…
35 unchanged lines
36 36
    let d = doy - (153 * mp + 2) / 5 + 1;
37 37
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
38 38
    let y = if m <= 2 { y + 1 } else { y };
39 -
    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)
39 +
    format!(
40 +
        "{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02}Z",
41 +
        rem / 3600,
42 +
        (rem % 3600) / 60,
43 +
        rem % 60
44 +
    )
40 45
}
41 46
42 47
/// One line describing how a change is authenticated.
43 48
pub fn signature_line(repo: &Repo, h: &ChangeHash) -> Result<String> {
44 49
    Ok(match repo.authenticate_change(h)? {
45 50
        SignatureStatus::Unsigned => "unsigned".to_string(),
46 -
        SignatureStatus::Valid { key, name, email, sealed, revoked } => {
47 -
            format!("{} by {name} <{email}> key {}{}", if sealed { "sealed" } else { "signed" }, key.short(12), if revoked { " (KEY REVOKED)" } else { "" })
51 +
        SignatureStatus::Valid {
52 +
            key,
53 +
            name,
54 +
            email,
55 +
            sealed,
56 +
            revoked,
57 +
        } => {
58 +
            format!(
59 +
                "{} by {name} <{email}> key {}{}",
60 +
                if sealed { "sealed" } else { "signed" },
61 +
                key.short(12),
62 +
                if revoked { " (KEY REVOKED)" } else { "" }
63 +
            )
48 64
        }
49 65
        SignatureStatus::Invalid(why) => format!("INVALID signature: {why}"),
50 66
    })
…
3 unchanged lines
54 70
pub fn signature_mark(repo: &Repo, h: &ChangeHash) -> Result<String> {
55 71
    Ok(match repo.authenticate_change(h)? {
56 72
        SignatureStatus::Unsigned => ui::dim(format!("{} unsigned", ui::glyph("·", "-"))),
57 -
        SignatureStatus::Valid { sealed, revoked: true, .. } => ui::warn(format!("{} {} (key revoked)", ui::glyph("⚠", "!"), if sealed { "sealed" } else { "signed" })),
58 -
        SignatureStatus::Valid { sealed, .. } => ui::ok(format!("{} {}", ui::glyph("✔", "v"), if sealed { "sealed" } else { "signed" })),
73 +
        SignatureStatus::Valid {
74 +
            sealed,
75 +
            revoked: true,
76 +
            ..
77 +
        } => ui::warn(format!(
78 +
            "{} {} (key revoked)",
79 +
            ui::glyph("⚠", "!"),
80 +
            if sealed { "sealed" } else { "signed" }
81 +
        )),
82 +
        SignatureStatus::Valid { sealed, .. } => ui::ok(format!(
83 +
            "{} {}",
84 +
            ui::glyph("✔", "v"),
85 +
            if sealed { "sealed" } else { "signed" }
86 +
        )),
59 87
        SignatureStatus::Invalid(why) => ui::err(format!("{} INVALID: {why}", ui::glyph("✖", "x"))),
60 88
    })
61 89
}
…
28 unchanged lines
90 118
}
91 119
92 120
pub fn deps_of(deps: &[oxy_core::hash::ChangeHash]) -> String {
93 -
    deps.iter().map(|d| format!("#{}", d.short(12))).collect::<Vec<_>>().join(" ")
121 +
    deps.iter()
122 +
        .map(|d| format!("#{}", d.short(12)))
123 +
        .collect::<Vec<_>>()
124 +
        .join(" ")
94 125
}
95 126
96 127
#[cfg(test)]
…
11 unchanged lines
modified src/cli/identity.rs +319 −49
1 1
//! Identity, verification, and configuration commands.
2 2
3 3
use super::format::{short_id, signature_line, when};
4 +
use super::json;
4 5
use super::open;
5 -
use super::json;
6 6
use super::ui;
7 7
use anyhow::Result;
8 8
use oxy_core::hash::ChangeHash;
9 +
use oxy_core::hash::KeyId;
9 10
use oxy_core::identity::Identity;
10 -
use oxy_core::hash::KeyId;
11 11
use oxy_core::objects::Hlc;
12 12
use oxy_core::protocol::SignedPolicy;
13 13
use oxy_core::repo::{self, IdentityScope, PolicyUpdate, Repo, Rotation, SignatureStatus};
…
23 unchanged lines
37 37
    }
38 38
    candidates.sort();
39 39
    candidates.dedup();
40 -
    let matches: Vec<_> = candidates.into_iter().filter(|k| k.starts_with_hex(prefix)).collect();
40 +
    let matches: Vec<_> = candidates
41 +
        .into_iter()
42 +
        .filter(|k| k.starts_with_hex(prefix))
43 +
        .collect();
41 44
    match matches.len() {
42 45
        1 => Ok(matches[0]),
43 46
        0 => anyhow::bail!("no known key starts with {prefix:?}"),
…
5 unchanged lines
49 52
/// other root holders sign with `oxy policy sign`.
50 53
fn report_update(u: &PolicyUpdate, out: Option<PathBuf>) -> Result<()> {
51 54
    if u.applied {
52 -
        println!("{} policy serial {} {}", ui::done("Applied"), u.policy.serial, ui::dim("(push to publish it)"));
55 +
        println!(
56 +
            "{} policy serial {} {}",
57 +
            ui::done("Applied"),
58 +
            u.policy.serial,
59 +
            ui::dim("(push to publish it)")
60 +
        );
53 61
        return Ok(());
54 62
    }
55 63
    let path = out.unwrap_or_else(|| PathBuf::from(format!("policy-{}.proposal", u.policy.serial)));
…
12 unchanged lines
68 76
}
69 77
70 78
fn report_rotation(r: &Rotation, verb: &str) -> Result<()> {
71 -
    println!("{} identity {} <{}>", ui::done(verb), ui::author(&r.identity.name), r.identity.email);
79 +
    println!(
80 +
        "{} identity {} <{}>",
81 +
        ui::done(verb),
82 +
        ui::author(&r.identity.name),
83 +
        r.identity.email
84 +
    );
72 85
    println!("{}", ui::kv("old key", ui::dim(r.old.to_hex())));
73 86
    println!("{}", ui::kv("new key", r.new.to_hex()));
74 87
    if !r.identity.delegates.is_empty() {
75 -
        println!("{}", ui::kv("delegates", r.identity.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" ")));
88 +
        println!(
89 +
            "{}",
90 +
            ui::kv(
91 +
                "delegates",
92 +
                r.identity
93 +
                    .delegates
94 +
                    .iter()
95 +
                    .map(|d| d.short(12))
96 +
                    .collect::<Vec<_>>()
97 +
                    .join(" ")
98 +
            )
99 +
        );
76 100
    }
77 101
    if let Some(e) = r.identity.expires {
78 102
        println!("{}", ui::kv("expires", when(e)));
79 103
    }
80 104
    match &r.policy {
81 105
        Some(u) => report_update(u, None)?,
82 -
        None => println!("  {} {}", ui::note(), ui::dim("no policy names the old key; export the new identity for policy holders")),
106 +
        None => println!(
107 +
            "  {} {}",
108 +
            ui::note(),
109 +
            ui::dim("no policy names the old key; export the new identity for policy holders")
110 +
        ),
83 111
    }
84 112
    Ok(())
85 113
}
86 114
87 -
pub fn create(name: Option<String>, email: Option<String>, local: bool, expires: Option<u64>) -> Result<i32> {
115 +
pub fn create(
116 +
    name: Option<String>,
117 +
    email: Option<String>,
118 +
    local: bool,
119 +
    expires: Option<u64>,
120 +
) -> Result<i32> {
88 121
    let mut repo = open()?;
89 122
    let name = name.unwrap_or_else(|| repo.config.author_name.clone());
90 123
    let email = email.unwrap_or_else(|| repo.config.author_email.clone());
91 -
    let scope = if local { IdentityScope::Repo } else { IdentityScope::User };
92 -
    let (key, _) = repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
93 -
    println!("{} identity {} <{email}>", ui::done("Created"), ui::author(&name));
124 +
    let scope = if local {
125 +
        IdentityScope::Repo
126 +
    } else {
127 +
        IdentityScope::User
128 +
    };
129 +
    let (key, _) =
130 +
        repo.create_identity_with(&name, &email, scope, expires.and_then(expiry_from_days))?;
131 +
    println!(
132 +
        "{} identity {} <{email}>",
133 +
        ui::done("Created"),
134 +
        ui::author(&name)
135 +
    );
94 136
    println!("{}", ui::kv("key", key));
95 -
    println!("{}", ui::kv("secret", ui::path(repo::default_keys_dir().display())));
137 +
    println!(
138 +
        "{}",
139 +
        ui::kv("secret", ui::path(repo::default_keys_dir().display()))
140 +
    );
96 141
    match scope {
97 -
        IdentityScope::User => println!("{}", ui::kv("scope", format!("default author in {}", ui::path(repo.user_config_path().display())))),
98 -
        IdentityScope::Repo => println!("{}", ui::kv("scope", "this repository's .oxy/config only")),
142 +
        IdentityScope::User => println!(
143 +
            "{}",
144 +
            ui::kv(
145 +
                "scope",
146 +
                format!(
147 +
                    "default author in {}",
148 +
                    ui::path(repo.user_config_path().display())
149 +
                )
150 +
            )
151 +
        ),
152 +
        IdentityScope::Repo => {
153 +
            println!("{}", ui::kv("scope", "this repository's .oxy/config only"))
154 +
        }
99 155
    }
100 156
    if !repo.config.sign_changes {
101 157
        println!("  {} {}", ui::note(), ui::dim("changes will carry this author; run oxy config --user core.sign change to sign each commit"));
…
8 unchanged lines
110 166
        return Ok(0);
111 167
    };
112 168
    let id = repo.identity(&key)?;
113 -
    println!("{} {} <{}>", ui::section("identity"), ui::author(&id.name), id.email);
169 +
    println!(
170 +
        "{} {} <{}>",
171 +
        ui::section("identity"),
172 +
        ui::author(&id.name),
173 +
        id.email
174 +
    );
114 175
    println!("{}", ui::kv("key", key));
115 -
    println!("{}", ui::kv("created", format!("{} {}", when(id.created), ui::dim(format!("({})", ui::ago(id.created))))));
176 +
    println!(
177 +
        "{}",
178 +
        ui::kv(
179 +
            "created",
180 +
            format!(
181 +
                "{} {}",
182 +
                when(id.created),
183 +
                ui::dim(format!("({})", ui::ago(id.created)))
184 +
            )
185 +
        )
186 +
    );
116 187
    if let Some(e) = id.expires {
117 188
        let expired = id.is_expired_at(Hlc::now().wall_ms);
118 -
        println!("{}", ui::kv("expires", if expired { ui::err(format!("{} EXPIRED", when(e))) } else { when(e) }));
189 +
        println!(
190 +
            "{}",
191 +
            ui::kv(
192 +
                "expires",
193 +
                if expired {
194 +
                    ui::err(format!("{} EXPIRED", when(e)))
195 +
                } else {
196 +
                    when(e)
197 +
                }
198 +
            )
199 +
        );
119 200
    }
120 201
    if let Some(p) = id.previous {
121 202
        println!("{}", ui::kv("previous", ui::dim(p.short(12))));
122 203
    }
123 204
    if !id.delegates.is_empty() {
124 -
        println!("{}", ui::kv("delegates", id.delegates.iter().map(|d| d.short(12)).collect::<Vec<_>>().join(" ")));
205 +
        println!(
206 +
            "{}",
207 +
            ui::kv(
208 +
                "delegates",
209 +
                id.delegates
210 +
                    .iter()
211 +
                    .map(|d| d.short(12))
212 +
                    .collect::<Vec<_>>()
213 +
                    .join(" ")
214 +
            )
215 +
        );
125 216
    }
126 217
    if repo.is_revoked(&key)? {
127 -
        println!("{}", ui::kv("status", ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))));
218 +
        println!(
219 +
            "{}",
220 +
            ui::kv(
221 +
                "status",
222 +
                ui::err(format!("{} REVOKED", ui::glyph("✖", "x")))
223 +
            )
224 +
        );
128 225
    }
129 -
    println!("{}", ui::kv("secret", if repo.secret_key()?.is_some() { ui::ok(format!("{} available", ui::glyph("✔", "*"))) } else { ui::err(format!("{} not found in the key directory", ui::glyph("✖", "x"))) }));
130 -
    println!("{}", ui::kv("signing", if repo.config.sign_changes { "every change (core.sign = change)" } else { "at push via seals (core.sign = seal)" }));
226 +
    println!(
227 +
        "{}",
228 +
        ui::kv(
229 +
            "secret",
230 +
            if repo.secret_key()?.is_some() {
231 +
                ui::ok(format!("{} available", ui::glyph("✔", "*")))
232 +
            } else {
233 +
                ui::err(format!(
234 +
                    "{} not found in the key directory",
235 +
                    ui::glyph("✖", "x")
236 +
                ))
237 +
            }
238 +
        )
239 +
    );
240 +
    println!(
241 +
        "{}",
242 +
        ui::kv(
243 +
            "signing",
244 +
            if repo.config.sign_changes {
245 +
                "every change (core.sign = change)"
246 +
            } else {
247 +
                "at push via seals (core.sign = seal)"
248 +
            }
249 +
        )
250 +
    );
131 251
    Ok(0)
132 252
}
133 253
…
1 unchanged lines
135 255
    let repo = open()?;
136 256
    let hashes: Vec<ChangeHash> = match change {
137 257
        Some(c) => vec![repo.resolve_change(c)?],
138 -
        None => repo.log()?.into_iter().filter(|e| !e.is_working_copy).map(|e| e.hash).collect(),
258 +
        None => repo
259 +
            .log()?
260 +
            .into_iter()
261 +
            .filter(|e| !e.is_working_copy)
262 +
            .map(|e| e.hash)
263 +
            .collect(),
139 264
    };
140 265
    let mut bad = 0;
141 266
    if as_json {
142 267
        let mut items = Vec::new();
143 268
        for h in &hashes {
144 -
            let Ok(c) = repo.store().get_change(h) else { continue };
269 +
            let Ok(c) = repo.store().get_change(h) else {
270 +
                continue;
271 +
            };
145 272
            let (status, key, name, revoked, reason) = match repo.authenticate_change(h)? {
146 273
                SignatureStatus::Unsigned => ("unsigned", None, None, false, None),
147 -
                SignatureStatus::Valid { key, name, sealed, revoked, .. } => (if sealed { "sealed" } else { "signed" }, Some(key.to_hex()), Some(name), revoked, None),
274 +
                SignatureStatus::Valid {
275 +
                    key,
276 +
                    name,
277 +
                    sealed,
278 +
                    revoked,
279 +
                    ..
280 +
                } => (
281 +
                    if sealed { "sealed" } else { "signed" },
282 +
                    Some(key.to_hex()),
283 +
                    Some(name),
284 +
                    revoked,
285 +
                    None,
286 +
                ),
148 287
                SignatureStatus::Invalid(why) => {
149 288
                    bad += 1;
150 289
                    ("invalid", None, None, false, Some(why))
…
14 unchanged lines
165 304
    }
166 305
    for h in hashes {
167 306
        let Ok(c) = repo.store().get_change(&h) else {
168 -
            println!("{}  {}", ui::hash(format!("#{}", h.short(12))), ui::dim("not present (shallow)"));
307 +
            println!(
308 +
                "{}  {}",
309 +
                ui::hash(format!("#{}", h.short(12))),
310 +
                ui::dim("not present (shallow)")
311 +
            );
169 312
            continue;
170 313
        };
171 314
        let line = signature_line(&repo, &h)?;
…
5 unchanged lines
177 320
        } else {
178 321
            ui::ok(format!("{} {line}", ui::glyph("✔", "v")))
179 322
        };
180 -
        println!("{} {}  {styled}", ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))));
323 +
        println!(
324 +
            "{} {}  {styled}",
325 +
            ui::id(short_id(&c)),
326 +
            ui::hash(format!("#{}", h.short(12)))
327 +
        );
181 328
    }
182 329
    Ok(if bad > 0 { 1 } else { 0 })
183 330
}
…
2 unchanged lines
186 333
    if user {
187 334
        let path = repo::default_user_config();
188 335
        repo::Config::set_user(&path, key, value)?;
189 -
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim(format!("(in {})", path.display())));
336 +
        println!(
337 +
            "{} {key} = {value} {}",
338 +
            ui::done("Set"),
339 +
            ui::dim(format!("(in {})", path.display()))
340 +
        );
190 341
    } else {
191 342
        let mut repo = open()?;
192 343
        repo.config.set(key, value)?;
193 344
        repo.save_config()?;
194 -
        println!("{} {key} = {value} {}", ui::done("Set"), ui::dim("(in this repository)"));
345 +
        println!(
346 +
            "{} {key} = {value} {}",
347 +
            ui::done("Set"),
348 +
            ui::dim("(in this repository)")
349 +
        );
195 350
    }
196 351
    Ok(0)
197 352
}
…
6 unchanged lines
204 359
    let id = repo.identity(&key)?;
205 360
    let path = path.unwrap_or_else(|| PathBuf::from(format!("{}.identity", key.short(16))));
206 361
    std::fs::write(&path, id.encode())?;
207 -
    println!("{} identity {} <{}> ({}) to {}", ui::done("Wrote"), ui::author(&id.name), id.email, key.short(12), ui::path(path.display()));
362 +
    println!(
363 +
        "{} identity {} <{}> ({}) to {}",
364 +
        ui::done("Wrote"),
365 +
        ui::author(&id.name),
366 +
        id.email,
367 +
        key.short(12),
368 +
        ui::path(path.display())
369 +
    );
208 370
    Ok(0)
209 371
}
210 372
…
15 unchanged lines
226 388
    let repo = open()?;
227 389
    let key = match key {
228 390
        Some(k) => resolve_key(&repo, k)?,
229 -
        None => repo.config.author_key.ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
391 +
        None => repo
392 +
            .config
393 +
            .author_key
394 +
            .ok_or_else(|| anyhow::anyhow!("no identity configured"))?,
230 395
    };
231 396
    let update = repo.revoke_identity(&key, reason)?;
232 -
    println!("{} key {}{}", ui::done("Revoked"), ui::hash(key.short(12)), if reason.is_empty() { String::new() } else { format!(": {reason}") });
397 +
    println!(
398 +
        "{} key {}{}",
399 +
        ui::done("Revoked"),
400 +
        ui::hash(key.short(12)),
401 +
        if reason.is_empty() {
402 +
            String::new()
403 +
        } else {
404 +
            format!(": {reason}")
405 +
        }
406 +
    );
233 407
    match update {
234 408
        Some(u) => report_update(&u, None)?,
235 -
        None => println!("  {} {}", ui::note(), ui::dim("recorded locally; no policy named the key")),
409 +
        None => println!(
410 +
            "  {} {}",
411 +
            ui::note(),
412 +
            ui::dim("recorded locally; no policy named the key")
413 +
        ),
236 414
    }
237 415
    if repo.config.author_key == Some(key) {
238 -
        println!("  {} {}", ui::caution(), ui::warn("this was your configured key; run oxy identity create or rotate"));
416 +
        println!(
417 +
            "  {} {}",
418 +
            ui::caution(),
419 +
            ui::warn("this was your configured key; run oxy identity create or rotate")
420 +
        );
239 421
    }
240 422
    Ok(0)
241 423
}
…
25 unchanged lines
267 449
        println!("{}", ui::dim("No delegates."));
268 450
    }
269 451
    for d in &id.delegates {
270 -
        let who = repo.identity(d).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|_| "(unknown identity)".into());
271 -
        println!("  {} {}  {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(d.short(12)), ui::author(who));
452 +
        let who = repo
453 +
            .identity(d)
454 +
            .map(|i| format!("{} <{}>", i.name, i.email))
455 +
            .unwrap_or_else(|_| "(unknown identity)".into());
456 +
        println!(
457 +
            "  {} {}  {}",
458 +
            ui::ok(ui::glyph("🔑", "*")),
459 +
            ui::hash(d.short(12)),
460 +
            ui::author(who)
461 +
        );
272 462
    }
273 463
    Ok(0)
274 464
}
…
3 unchanged lines
278 468
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
279 469
    let key = id.hash();
280 470
    let u = repo.policy_root_add(id.clone())?;
281 -
    println!("{} root {} <{}> ({})", ui::done("Added"), ui::author(&id.name), id.email, key.short(12));
471 +
    println!(
472 +
        "{} root {} <{}> ({})",
473 +
        ui::done("Added"),
474 +
        ui::author(&id.name),
475 +
        id.email,
476 +
        key.short(12)
477 +
    );
282 478
    report_update(&u, out)?;
283 479
    Ok(0)
284 480
}
…
27 unchanged lines
312 508
pub fn policy_init() -> Result<i32> {
313 509
    let repo = open()?;
314 510
    let sp = repo.policy_init()?;
315 -
    println!("{} policy with root key {}", ui::done("Created"), sp.policy.roots[0].short(12));
511 +
    println!(
512 +
        "{} policy with root key {}",
513 +
        ui::done("Created"),
514 +
        sp.policy.roots[0].short(12)
515 +
    );
316 516
    report_update(&sp, None)?;
317 517
    Ok(0)
318 518
}
…
4 unchanged lines
323 523
        if as_json {
324 524
            println!("null");
325 525
        } else {
326 -
            println!("No policy: this repository is unverified. Run `oxy policy init` to create one.");
526 +
            println!(
527 +
                "No policy: this repository is unverified. Run `oxy policy init` to create one."
528 +
            );
327 529
        }
328 530
        return Ok(0);
329 531
    };
330 532
    let p = &sp.policy;
331 533
    if as_json {
332 534
        let ident = |k: &oxy_core::hash::KeyId| {
333 -
            let (name, email) = p.identity(k).map(|i| (i.name.clone(), i.email.clone())).unwrap_or_default();
334 -
            json::obj([("key", json::s(&k.to_hex())), ("name", json::s(&name)), ("email", json::s(&email))])
535 +
            let (name, email) = p
536 +
                .identity(k)
537 +
                .map(|i| (i.name.clone(), i.email.clone()))
538 +
                .unwrap_or_default();
539 +
            json::obj([
540 +
                ("key", json::s(&k.to_hex())),
541 +
                ("name", json::s(&name)),
542 +
                ("email", json::s(&email)),
543 +
            ])
335 544
        };
336 545
        println!(
337 546
            "{}",
…
1 unchanged lines
339 548
                ("hash", json::s(&r.to_hex())),
340 549
                ("serial", json::n(p.serial)),
341 550
                ("expires_ms", json::n(p.expires)),
342 -
                ("expired", json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))),
551 +
                (
552 +
                    "expired",
553 +
                    json::b(p.is_expired(oxy_core::objects::Hlc::now().wall_ms))
554 +
                ),
343 555
                ("root_threshold", json::n(p.root_threshold)),
344 556
                ("roots", json::arr(p.roots.iter().map(ident))),
345 557
                (
…
13 unchanged lines
359 571
        );
360 572
        return Ok(0);
361 573
    }
362 -
    let name = |k: &oxy_core::hash::KeyId| p.identity(k).map(|i| format!("{} <{}>", i.name, i.email)).unwrap_or_else(|| "(unknown identity)".into());
574 +
    let name = |k: &oxy_core::hash::KeyId| {
575 +
        p.identity(k)
576 +
            .map(|i| format!("{} <{}>", i.name, i.email))
577 +
            .unwrap_or_else(|| "(unknown identity)".into())
578 +
    };
363 579
    let expired = p.is_expired(oxy_core::objects::Hlc::now().wall_ms);
364 -
    println!("{} {}{}serial {}{}expires {}{}", ui::section("policy"), ui::hash(format!("#{}", r.short(12))), ui::sep(), p.serial, ui::sep(), when(p.expires), if expired { ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x"))) } else { String::new() });
365 -
    println!("{}", ui::section(format!("roots {}", ui::dim(format!("(threshold {})", p.root_threshold)))));
580 +
    println!(
581 +
        "{} {}{}serial {}{}expires {}{}",
582 +
        ui::section("policy"),
583 +
        ui::hash(format!("#{}", r.short(12))),
584 +
        ui::sep(),
585 +
        p.serial,
586 +
        ui::sep(),
587 +
        when(p.expires),
588 +
        if expired {
589 +
            ui::err(format!("  {} EXPIRED", ui::glyph("✖", "x")))
590 +
        } else {
591 +
            String::new()
592 +
        }
593 +
    );
594 +
    println!(
595 +
        "{}",
596 +
        ui::section(format!(
597 +
            "roots {}",
598 +
            ui::dim(format!("(threshold {})", p.root_threshold))
599 +
        ))
600 +
    );
366 601
    for k in &p.roots {
367 -
        println!("  {} {}  {}", ui::ok(ui::glyph("🔑", "*")), ui::hash(k.short(12)), ui::author(name(k)));
602 +
        println!(
603 +
            "  {} {}  {}",
604 +
            ui::ok(ui::glyph("🔑", "*")),
605 +
            ui::hash(k.short(12)),
606 +
            ui::author(name(k))
607 +
        );
368 608
    }
369 609
    println!("{}", ui::section("rules"));
370 610
    for rule in &p.rules {
371 -
        println!("  {} {:12} {}", ui::ok(ui::glyph("▸", ">")), ui::bookmark(&rule.pattern), ui::dim(format!("threshold {}, require {}, conflicts {}", rule.threshold, rule.require.name(), if rule.allow_conflicts { "allowed" } else { "refused" })));
611 +
        println!(
612 +
            "  {} {:12} {}",
613 +
            ui::ok(ui::glyph("▸", ">")),
614 +
            ui::bookmark(&rule.pattern),
615 +
            ui::dim(format!(
616 +
                "threshold {}, require {}, conflicts {}",
617 +
                rule.threshold,
618 +
                rule.require.name(),
619 +
                if rule.allow_conflicts {
620 +
                    "allowed"
621 +
                } else {
622 +
                    "refused"
623 +
                }
624 +
            ))
625 +
        );
372 626
        for k in &rule.keys {
373 627
            println!("      {}  {}", ui::hash(k.short(12)), ui::author(name(k)));
374 628
        }
…
1 unchanged lines
376 630
    if !p.revoked.is_empty() {
377 631
        println!("{}", ui::section("revoked"));
378 632
        for k in &p.revoked {
379 -
            println!("  {} {}  {}", ui::err(ui::glyph("✖", "x")), ui::hash(k.short(12)), ui::author(name(k)));
633 +
            println!(
634 +
                "  {} {}  {}",
635 +
                ui::err(ui::glyph("✖", "x")),
636 +
                ui::hash(k.short(12)),
637 +
                ui::author(name(k))
638 +
            );
380 639
        }
381 640
    }
382 641
    Ok(0)
383 642
}
384 643
385 -
pub fn policy_grant(identity_file: &Path, pattern: Option<&str>, out: Option<PathBuf>) -> Result<i32> {
644 +
pub fn policy_grant(
645 +
    identity_file: &Path,
646 +
    pattern: Option<&str>,
647 +
    out: Option<PathBuf>,
648 +
) -> Result<i32> {
386 649
    let repo = open()?;
387 650
    let id = Identity::decode(&std::fs::read(identity_file)?)?;
388 651
    let key = id.hash();
389 652
    let u = repo.policy_grant(id.clone(), pattern)?;
390 -
    println!("{} {} <{}> ({}) on {}", ui::done("Granted"), ui::author(&id.name), id.email, key.short(12), ui::bookmark(pattern.unwrap_or("*")));
653 +
    println!(
654 +
        "{} {} <{}> ({}) on {}",
655 +
        ui::done("Granted"),
656 +
        ui::author(&id.name),
657 +
        id.email,
658 +
        key.short(12),
659 +
        ui::bookmark(pattern.unwrap_or("*"))
660 +
    );
391 661
    report_update(&u, out)?;
392 662
    Ok(0)
393 663
}
…
12 unchanged lines
modified src/cli/json.rs +4 −1
…
3 unchanged lines
4 4
pub use oxy_core::repo::{json_array as arr, json_str as s};
5 5
6 6
pub fn obj<I: IntoIterator<Item = (&'static str, String)>>(pairs: I) -> String {
7 -
    let inner: Vec<String> = pairs.into_iter().map(|(k, v)| format!("{}:{v}", s(k))).collect();
7 +
    let inner: Vec<String> = pairs
8 +
        .into_iter()
9 +
        .map(|(k, v)| format!("{}:{v}", s(k)))
10 +
        .collect();
8 11
    format!("{{{}}}", inner.join(","))
9 12
}
10 13
pub fn b(v: bool) -> String {
…
14 unchanged lines
modified src/cli/mod.rs +96 −24
1 1
//! Command-line definitions and dispatch.
2 2
3 3
mod format;
4 +
mod identity;
4 5
mod json;
5 -
mod identity;
6 6
mod sync;
7 7
pub mod ui;
8 8
mod workspace;
…
4 unchanged lines
13 13
use std::path::PathBuf;
14 14
15 15
#[derive(Parser)]
16 -
#[command(name = "oxy", version, about = "Oxygen: breathing fresh air into version control")]
16 +
#[command(
17 +
    name = "oxy",
18 +
    version,
19 +
    about = "Oxygen: breathing fresh air into version control"
20 +
)]
17 21
struct Cli {
18 22
    /// Plain output: no colour, no spinner (also implied by NO_COLOR or a non-terminal)
19 23
    #[arg(long, global = true)]
…
157 unchanged lines
177 181
        into: Option<String>,
178 182
    },
179 183
    /// Split a head: the named paths become a separate first change
180 -
    Split {
181 -
        change: String,
182 -
        paths: Vec<String>,
183 -
    },
184 +
    Split { change: String, paths: Vec<String> },
184 185
    /// Drop a head from the bookmark; its content stays on disk as uncommitted work
185 186
    Abandon { change: String },
186 187
    /// Show one change (ChangeId prefix, #hash prefix, or @)
…
242 unchanged lines
429 430
430 431
#[derive(Subcommand)]
431 432
enum RemoteCmd {
432 -
    Add { name: String, url: String },
433 -
    Remove { name: String },
433 +
    Add {
434 +
        name: String,
435 +
        url: String,
436 +
    },
437 +
    Remove {
438 +
        name: String,
439 +
    },
434 440
    List {
435 441
        #[arg(long)]
436 442
        json: bool,
…
154 unchanged lines
591 597
    match cli.cmd {
592 598
        Cmd::Init { path, bare } => workspace::init(path, bare),
593 599
        Cmd::Status { json } => workspace::status(json),
594 -
        Cmd::Prompt { format, glyphs, color } => workspace::prompt(&format, glyphs, color.as_deref()),
595 -
        Cmd::Commit { message, depends_on, paths, interactive, hunks } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks),
600 +
        Cmd::Prompt {
601 +
            format,
602 +
            glyphs,
603 +
            color,
604 +
        } => workspace::prompt(&format, glyphs, color.as_deref()),
605 +
        Cmd::Commit {
606 +
            message,
607 +
            depends_on,
608 +
            paths,
609 +
            interactive,
610 +
            hunks,
611 +
        } => workspace::commit(&message, &depends_on, &paths, interactive, &hunks),
596 612
        Cmd::Cat { path, change } => workspace::cat(&path, change.as_deref()),
597 613
        Cmd::Root { json } => workspace::root(json),
598 614
        Cmd::Version { json } => workspace::version(json),
…
11 unchanged lines
610 626
            BisectCmd::Status { json } => workspace::bisect_status(json),
611 627
            BisectCmd::Reset => workspace::bisect_reset(),
612 628
        },
613 -
        Cmd::Log { limit, verbose, path, json } => workspace::log(limit, verbose, path.as_deref(), json),
629 +
        Cmd::Log {
630 +
            limit,
631 +
            verbose,
632 +
            path,
633 +
            json,
634 +
        } => workspace::log(limit, verbose, path.as_deref(), json),
614 635
        Cmd::Diff { change, json } => workspace::diff(change.as_deref(), json),
615 636
        Cmd::Describe { message, change } => workspace::describe(&message, change.as_deref()),
616 637
        Cmd::Amend { message, into } => workspace::amend(message.as_deref(), into.as_deref()),
…
5 unchanged lines
622 643
        Cmd::Undo => workspace::undo(),
623 644
        Cmd::Gc { now } => workspace::gc(now),
624 645
        Cmd::Checkpoint => workspace::checkpoint(),
625 -
        Cmd::Op { cmd: OpCmd::Log { json } } => workspace::op_log(json),
626 -
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
646 +
        Cmd::Op {
647 +
            cmd: OpCmd::Log { json },
648 +
        } => workspace::op_log(json),
649 +
        Cmd::Clone {
650 +
            url,
651 +
            dest,
652 +
            max_blob,
653 +
            sparse,
654 +
            depth,
655 +
        } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
627 656
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
628 657
        Cmd::Sparse { paths } => workspace::sparse(&paths),
629 658
        Cmd::Lock { path } => workspace::lock(&path),
…
12 unchanged lines
642 671
        },
643 672
        Cmd::Merge { bookmark } => workspace::merge(&bookmark),
644 673
        Cmd::Submodule { cmd } => match cmd {
645 -
            SubmoduleCmd::Add { url, path, bookmark } => sync::submodule_add(&url, &path, bookmark.as_deref()),
674 +
            SubmoduleCmd::Add {
675 +
                url,
676 +
                path,
677 +
                bookmark,
678 +
            } => sync::submodule_add(&url, &path, bookmark.as_deref()),
646 679
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
647 680
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
648 681
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
649 682
        },
650 683
        Cmd::Tag { cmd } => match cmd {
651 -
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
684 +
            TagCmd::Create {
685 +
                name,
686 +
                changes,
687 +
                message,
688 +
            } => workspace::tag_create(&name, &changes, &message),
652 689
            TagCmd::List { json } => workspace::tag_list(json),
653 690
        },
654 691
        Cmd::Remote { cmd } => match cmd {
…
3 unchanged lines
658 695
        },
659 696
        Cmd::Fetch { remote, json } => sync::fetch(remote.as_deref(), json),
660 697
        Cmd::Pull { remote, json } => sync::pull(remote.as_deref(), json),
661 -
        Cmd::Push { remote, force, bookmark, json } => sync::push(remote.as_deref(), force, bookmark.as_deref(), json),
662 -
        Cmd::Serve { root, bind, tokens, tls_cert, tls_key } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
698 +
        Cmd::Push {
699 +
            remote,
700 +
            force,
701 +
            bookmark,
702 +
            json,
703 +
        } => sync::push(remote.as_deref(), force, bookmark.as_deref(), json),
704 +
        Cmd::Serve {
705 +
            root,
706 +
            bind,
707 +
            tokens,
708 +
            tls_cert,
709 +
            tls_key,
710 +
        } => sync::serve(root, &bind, tokens, tls_cert.zip(tls_key)),
663 711
        Cmd::Identity { cmd } => match cmd {
664 -
            IdentityCmd::Create { name, email, local, expires } => identity::create(name, email, local, expires),
712 +
            IdentityCmd::Create {
713 +
                name,
714 +
                email,
715 +
                local,
716 +
                expires,
717 +
            } => identity::create(name, email, local, expires),
665 718
            IdentityCmd::Show => identity::show(),
666 719
            IdentityCmd::Export { path } => identity::export(path),
667 720
            IdentityCmd::Rotate => identity::rotate(),
…
5 unchanged lines
673 726
                DelegateCmd::List => identity::delegate_list(),
674 727
            },
675 728
        },
676 -
        Cmd::Git { cmd: GitCmd::Export { git_dir, branch } } => sync::git_export(&git_dir, branch.as_deref()),
677 -
        Cmd::Git { cmd: GitCmd::Fetch { url, branch } } => sync::git_fetch(&url, &branch),
678 -
        Cmd::Git { cmd: GitCmd::Push { url, branch } } => sync::git_push(&url, &branch),
679 -
        Cmd::Git { cmd: GitCmd::Import { git_dir, dest, branch } } => sync::git_import(&git_dir, &dest, &branch),
729 +
        Cmd::Git {
730 +
            cmd: GitCmd::Export { git_dir, branch },
731 +
        } => sync::git_export(&git_dir, branch.as_deref()),
732 +
        Cmd::Git {
733 +
            cmd: GitCmd::Fetch { url, branch },
734 +
        } => sync::git_fetch(&url, &branch),
735 +
        Cmd::Git {
736 +
            cmd: GitCmd::Push { url, branch },
737 +
        } => sync::git_push(&url, &branch),
738 +
        Cmd::Git {
739 +
            cmd:
740 +
                GitCmd::Import {
741 +
                    git_dir,
742 +
                    dest,
743 +
                    branch,
744 +
                },
745 +
        } => sync::git_import(&git_dir, &dest, &branch),
680 746
        Cmd::Policy { cmd } => match cmd {
681 747
            PolicyCmd::Init => identity::policy_init(),
682 748
            PolicyCmd::Show { json } => identity::policy_show(json),
683 -
            PolicyCmd::Grant { identity_file, pattern, out } => identity::policy_grant(&identity_file, pattern.as_deref(), out),
749 +
            PolicyCmd::Grant {
750 +
                identity_file,
751 +
                pattern,
752 +
                out,
753 +
            } => identity::policy_grant(&identity_file, pattern.as_deref(), out),
684 754
            PolicyCmd::Revoke { key, out } => identity::policy_revoke(&key, out),
685 755
            PolicyCmd::Root { cmd } => match cmd {
686 -
                RootCmd::Add { identity_file, out } => identity::policy_root_add(&identity_file, out),
756 +
                RootCmd::Add { identity_file, out } => {
757 +
                    identity::policy_root_add(&identity_file, out)
758 +
                }
687 759
                RootCmd::Remove { key, out } => identity::policy_root_remove(&key, out),
688 760
                RootCmd::Threshold { n, out } => identity::policy_threshold(n, out),
689 761
            },
…
6 unchanged lines
modified src/cli/sync.rs +292 −45
1 1
//! Commands that talk to remotes, and the reference server.
2 2
3 3
use super::format::plural;
4 +
use super::json;
4 5
use super::open;
5 -
use super::json;
6 6
use super::ui;
7 7
use anyhow::{Context, Result};
8 8
use oxy_core::repo::Repo;
9 9
use std::path::PathBuf;
10 10
11 -
pub fn clone(url: &str, dest: Option<PathBuf>, max_blob: Option<&str>, sparse: &[String], depth: Option<u32>) -> Result<i32> {
11 +
pub fn clone(
12 +
    url: &str,
13 +
    dest: Option<PathBuf>,
14 +
    max_blob: Option<&str>,
15 +
    sparse: &[String],
16 +
    depth: Option<u32>,
17 +
) -> Result<i32> {
12 18
    let dest = dest.unwrap_or_else(|| {
13 -
        let base = url.trim_end_matches('/').rsplit('/').next().unwrap_or("repo");
19 +
        let base = url
20 +
            .trim_end_matches('/')
21 +
            .rsplit('/')
22 +
            .next()
23 +
            .unwrap_or("repo");
14 24
        PathBuf::from(base)
15 25
    });
16 26
    let max_blob = max_blob.map(oxy_core::repo::parse_size).transpose()?;
…
3 unchanged lines
20 30
        None => Repo::clone_with(url, &dest, max_blob, sparse)?,
21 31
    };
22 32
    let heads = repo.bookmark_heads()?;
23 -
    sp.done(&format!("{} into {} {}", ui::done("Cloned"), ui::path(repo.root().display()), ui::dim(format!("({url}, {} with {})", ui::bookmark(&repo.config.bookmark), plural(heads.len(), "head")))));
33 +
    sp.done(&format!(
34 +
        "{} into {} {}",
35 +
        ui::done("Cloned"),
36 +
        ui::path(repo.root().display()),
37 +
        ui::dim(format!(
38 +
            "({url}, {} with {})",
39 +
            ui::bookmark(&repo.config.bookmark),
40 +
            plural(heads.len(), "head")
41 +
        ))
42 +
    ));
24 43
    Ok(0)
25 44
}
26 45
…
1 unchanged lines
28 47
    let mut repo = open()?;
29 48
    repo.config.set(&format!("remote.{name}.url"), url)?;
30 49
    repo.save_config()?;
31 -
    println!("{} remote {} {} {url}", ui::done("Added"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")));
50 +
    println!(
51 +
        "{} remote {} {} {url}",
52 +
        ui::done("Added"),
53 +
        ui::bookmark(name),
54 +
        ui::dim(ui::glyph("→", "->"))
55 +
    );
32 56
    Ok(0)
33 57
}
34 58
…
12 unchanged lines
47 71
        let items = repo.config.remotes.iter().map(|(name, url)| {
48 72
            let bms = view.remotes.get(name).map(|b| {
49 73
                json::arr(b.iter().map(|(bm, st)| {
50 -
                    let heads = repo.store().get_state(st).map(|s| s.heads).unwrap_or_default();
74 +
                    let heads = repo
75 +
                        .store()
76 +
                        .get_state(st)
77 +
                        .map(|s| s.heads)
78 +
                        .unwrap_or_default();
51 79
                    json::obj([("name", json::s(bm)), ("heads", json::hashes(&heads))])
52 80
                }))
53 81
            });
54 -
            json::obj([("name", json::s(name)), ("url", json::s(url)), ("bookmarks", bms.unwrap_or_else(|| "[]".into()))])
82 +
            json::obj([
83 +
                ("name", json::s(name)),
84 +
                ("url", json::s(url)),
85 +
                ("bookmarks", bms.unwrap_or_else(|| "[]".into())),
86 +
            ])
55 87
        });
56 88
        println!("{}", json::arr(items));
57 89
        return Ok(0);
58 90
    }
59 91
    if repo.config.remotes.is_empty() {
60 -
        println!("{}", ui::dim("No remotes; add one with oxy remote add <name> <url>."));
92 +
        println!(
93 +
            "{}",
94 +
            ui::dim("No remotes; add one with oxy remote add <name> <url>.")
95 +
        );
61 96
    }
62 97
    for (name, url) in &repo.config.remotes {
63 -
        println!("{} {}{}{}", ui::ok(ui::glyph("⇄", "<>")), ui::bookmark(name), ui::sep(), ui::dim(url));
98 +
        println!(
99 +
            "{} {}{}{}",
100 +
            ui::ok(ui::glyph("⇄", "<>")),
101 +
            ui::bookmark(name),
102 +
            ui::sep(),
103 +
            ui::dim(url)
104 +
        );
64 105
        if let Some(bms) = view.remotes.get(name) {
65 106
            for (bm, st) in bms {
66 -
                let heads = repo.store().get_state(st).map(|s| s.heads.len()).unwrap_or(0);
67 -
                println!("    {} {}{}{}", ui::dim(ui::glyph("↳", "->")), ui::id(format!("{name}/{bm}")), ui::sep(), ui::dim(plural(heads, "head")));
107 +
                let heads = repo
108 +
                    .store()
109 +
                    .get_state(st)
110 +
                    .map(|s| s.heads.len())
111 +
                    .unwrap_or(0);
112 +
                println!(
113 +
                    "    {} {}{}{}",
114 +
                    ui::dim(ui::glyph("↳", "->")),
115 +
                    ui::id(format!("{name}/{bm}")),
116 +
                    ui::sep(),
117 +
                    ui::dim(plural(heads, "head"))
118 +
                );
68 119
            }
69 120
        }
70 121
    }
…
15 unchanged lines
86 137
            "{}",
87 138
            json::obj([
88 139
                ("remote", json::s(&remote)),
89 -
                ("refs", json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([("name", json::s(name)), ("heads", json::hashes(heads)), ("new", json::n(*n))])))),
140 +
                (
141 +
                    "refs",
142 +
                    json::arr(report.refs.iter().map(|(name, heads, n)| json::obj([
143 +
                        ("name", json::s(name)),
144 +
                        ("heads", json::hashes(heads)),
145 +
                        ("new", json::n(*n))
146 +
                    ])))
147 +
                ),
90 148
                ("new_tags", json::n(report.new_tags)),
91 149
                ("unverified", json::b(report.unverified)),
92 150
            ])
…
1 unchanged lines
94 152
        return Ok(0);
95 153
    }
96 154
    if report.refs.is_empty() {
97 -
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {remote}")));
155 +
        println!(
156 +
            "{} {}",
157 +
            ui::done("Fetched"),
158 +
            ui::dim(format!("nothing new from {remote}"))
159 +
        );
98 160
    }
99 161
    for (name, heads, n) in &report.refs {
100 -
        let what = if *n == 0 { ui::dim("up to date") } else { ui::ok(plural(*n, "new change")) };
101 -
        println!("{} {}{}{}{}{what}", ui::done("Fetched"), ui::id(format!("{remote}/{name}")), ui::sep(), ui::dim(plural(heads.len(), "head")), ui::sep());
162 +
        let what = if *n == 0 {
163 +
            ui::dim("up to date")
164 +
        } else {
165 +
            ui::ok(plural(*n, "new change"))
166 +
        };
167 +
        println!(
168 +
            "{} {}{}{}{}{what}",
169 +
            ui::done("Fetched"),
170 +
            ui::id(format!("{remote}/{name}")),
171 +
            ui::sep(),
172 +
            ui::dim(plural(heads.len(), "head")),
173 +
            ui::sep()
174 +
        );
102 175
    }
103 176
    if report.new_tags > 0 {
104 -
        println!("  {} {}", ui::note(), ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
177 +
        println!(
178 +
            "  {} {}",
179 +
            ui::note(),
180 +
            ui::dim(format!("{} adopted", plural(report.new_tags, "tag")))
181 +
        );
105 182
    }
106 183
    if report.unverified {
107 -
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::caution());
184 +
        eprintln!(
185 +
            "{} {remote} has no trust policy; nothing fetched from it was verified",
186 +
            ui::caution()
187 +
        );
108 188
    }
109 189
    Ok(0)
110 190
}
…
6 unchanged lines
117 197
    if as_json {
118 198
        sp.clear();
119 199
        let conflicted = repo.snapshot("oxy pull")?.base_snapshot.is_conflicted();
120 -
        println!("{}", json::obj([("remote", json::s(&remote)), ("heads", json::hashes(&r.heads)), ("changed", json::b(r.changed)), ("checked_out", json::b(r.checked_out)), ("conflicted", json::b(conflicted))]));
200 +
        println!(
201 +
            "{}",
202 +
            json::obj([
203 +
                ("remote", json::s(&remote)),
204 +
                ("heads", json::hashes(&r.heads)),
205 +
                ("changed", json::b(r.changed)),
206 +
                ("checked_out", json::b(r.checked_out)),
207 +
                ("conflicted", json::b(conflicted))
208 +
            ])
209 +
        );
121 210
        return Ok(0);
122 211
    }
123 212
    if !r.changed {
124 -
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("Already up to date.")));
213 +
        sp.done(&format!(
214 +
            "{} {}",
215 +
            ui::ok(ui::glyph("✔", "*")),
216 +
            ui::dim("Already up to date.")
217 +
        ));
125 218
        return Ok(0);
126 219
    }
127 220
    sp.done(&format!(
…
1 unchanged lines
129 222
        ui::done("Pulled:"),
130 223
        ui::bookmark(&repo.config.bookmark),
131 224
        plural(r.heads.len(), "head"),
132 -
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
225 +
        ui::dim(if r.checked_out {
226 +
            "; working copy updated"
227 +
        } else {
228 +
            "; run oxy checkout to update the working copy"
229 +
        })
133 230
    ));
134 231
    let snap = repo.snapshot("oxy pull")?;
135 232
    if snap.base_snapshot.is_conflicted() {
136 -
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
233 +
        println!(
234 +
            "  {} {}",
235 +
            ui::caution(),
236 +
            ui::warn("the result has conflicts; see oxy status")
237 +
        );
137 238
    }
138 239
    Ok(0)
139 240
}
140 241
141 -
pub fn push(remote: Option<&str>, force: bool, bookmark: Option<&str>, as_json: bool) -> Result<i32> {
242 +
pub fn push(
243 +
    remote: Option<&str>,
244 +
    force: bool,
245 +
    bookmark: Option<&str>,
246 +
    as_json: bool,
247 +
) -> Result<i32> {
142 248
    let repo = open()?;
143 249
    let remote = remote_name(remote);
144 250
    let sp = ui::spinner(&format!("Pushing to {remote}…"));
…
3 unchanged lines
148 254
    };
149 255
    sp.clear();
150 256
    if as_json {
151 -
        println!("{}", json::arr(results.iter().map(|r| json::obj([("name", json::s(&r.name)), ("ok", json::b(r.ok)), ("reason", json::s(&r.reason))]))));
257 +
        println!(
258 +
            "{}",
259 +
            json::arr(results.iter().map(|r| json::obj([
260 +
                ("name", json::s(&r.name)),
261 +
                ("ok", json::b(r.ok)),
262 +
                ("reason", json::s(&r.reason))
263 +
            ])))
264 +
        );
152 265
        return Ok(if results.iter().all(|r| r.ok) { 0 } else { 1 });
153 266
    }
154 267
    let mut code = 0;
155 268
    for r in results {
156 269
        if r.ok {
157 -
            println!("{} {}{}{}", ui::done("Pushed"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), ui::dim(if r.reason.is_empty() { "updated" } else { r.reason.as_str() }));
270 +
            println!(
271 +
                "{} {}{}{}",
272 +
                ui::done("Pushed"),
273 +
                ui::id(format!("{remote}/{}", r.name)),
274 +
                ui::sep(),
275 +
                ui::dim(if r.reason.is_empty() {
276 +
                    "updated"
277 +
                } else {
278 +
                    r.reason.as_str()
279 +
                })
280 +
            );
158 281
        } else {
159 -
            println!("{} {} {}{}{}", ui::err(ui::glyph("✖", "x")), ui::err("Rejected"), ui::id(format!("{remote}/{}", r.name)), ui::sep(), r.reason);
282 +
            println!(
283 +
                "{} {} {}{}{}",
284 +
                ui::err(ui::glyph("✖", "x")),
285 +
                ui::err("Rejected"),
286 +
                ui::id(format!("{remote}/{}", r.name)),
287 +
                ui::sep(),
288 +
                r.reason
289 +
            );
160 290
            code = 1;
161 291
        }
162 292
    }
163 293
    Ok(code)
164 294
}
165 295
166 -
pub fn serve(root: PathBuf, bind: &str, tokens: Vec<String>, tls: Option<(PathBuf, PathBuf)>) -> Result<i32> {
296 +
pub fn serve(
297 +
    root: PathBuf,
298 +
    bind: &str,
299 +
    tokens: Vec<String>,
300 +
    tls: Option<(PathBuf, PathBuf)>,
301 +
) -> Result<i32> {
167 302
    let listener = std::net::TcpListener::bind(bind).with_context(|| format!("binding {bind}"))?;
168 303
    let root = root.canonicalize()?;
169 304
    let tls = tls.map(|(cert, key)| oxy_server::TlsFiles { cert, key });
…
1 unchanged lines
171 306
        "Serving repositories under {} on {}://{bind}/<repo>/oxy/v1/ ({})",
172 307
        root.display(),
173 308
        if tls.is_some() { "https" } else { "http" },
174 -
        if tokens.is_empty() { "open: no push tokens configured" } else { "push requires a token" }
309 +
        if tokens.is_empty() {
310 +
            "open: no push tokens configured"
311 +
        } else {
312 +
            "push requires a token"
313 +
        }
175 314
    );
176 -
    if tls.is_none() && !bind.starts_with("127.") && !bind.starts_with("localhost") && !bind.starts_with("[::1]") {
315 +
    if tls.is_none()
316 +
        && !bind.starts_with("127.")
317 +
        && !bind.starts_with("localhost")
318 +
        && !bind.starts_with("[::1]")
319 +
    {
177 320
        eprintln!("warning: serving plain HTTP on a non-loopback address; put a TLS terminator in front or pass --tls-cert/--tls-key");
178 321
    }
179 322
    oxy_server::run_blocking(listener, oxy_server::ServerConfig { root, tokens, tls })?;
…
14 unchanged lines
194 337
        plural(r.exported, "new state"),
195 338
        ui::path(git_dir.display()),
196 339
        ui::bookmark(&r.branch),
197 -
        ui::dim(format!("(currently {}; {} already exported)", plural(r.heads, "head"), r.skipped))
340 +
        ui::dim(format!(
341 +
            "(currently {}; {} already exported)",
342 +
            plural(r.heads, "head"),
343 +
            r.skipped
344 +
        ))
198 345
    );
199 346
    Ok(0)
200 347
}
…
4 unchanged lines
205 352
    let r = repo.git_fetch(url, branch)?;
206 353
    sp.clear();
207 354
    if r.commits == 0 {
208 -
        println!("{} {}", ui::done("Fetched"), ui::dim(format!("nothing new from {url} {branch}")));
355 +
        println!(
356 +
            "{} {}",
357 +
            ui::done("Fetched"),
358 +
            ui::dim(format!("nothing new from {url} {branch}"))
359 +
        );
209 360
    } else {
210 361
        println!(
211 362
            "{} {} ({} merges) from {} {}{}{} now has {}",
…
9 unchanged lines
221 372
        if !repo.snapshot("oxy git fetch")?.changed {
222 373
            println!("  {} {}", ui::note(), ui::dim("working copy updated"));
223 374
        } else {
224 -
            println!("  {} {}", ui::note(), ui::dim("run oxy checkout to update the working copy"));
375 +
            println!(
376 +
                "  {} {}",
377 +
                ui::note(),
378 +
                ui::dim("run oxy checkout to update the working copy")
379 +
            );
225 380
        }
226 381
    }
227 382
    if r.submodules > 0 {
228 -
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
383 +
        println!(
384 +
            "  {} {}",
385 +
            ui::note(),
386 +
            ui::dim(format!(
387 +
                "{} submodule {} imported as subrepos with Git pins",
388 +
                r.submodules,
389 +
                if r.submodules == 1 {
390 +
                    "entry"
391 +
                } else {
392 +
                    "entries"
393 +
                }
394 +
            ))
395 +
        );
229 396
    }
230 397
    Ok(0)
231 398
}
…
28 unchanged lines
260 427
        ui::path(git_dir.display()),
261 428
        ui::bookmark(branch),
262 429
        ui::path(repo.root().display()),
263 -
        ui::dim(format!("in {:.1?}; {} now has {}", started.elapsed(), repo.config.bookmark, plural(r.heads, "head")))
430 +
        ui::dim(format!(
431 +
            "in {:.1?}; {} now has {}",
432 +
            started.elapsed(),
433 +
            repo.config.bookmark,
434 +
            plural(r.heads, "head")
435 +
        ))
264 436
    );
265 437
    if r.submodules > 0 {
266 -
        println!("  {} {}", ui::note(), ui::dim(format!("{} submodule {} imported as subrepos with Git pins", r.submodules, if r.submodules == 1 { "entry" } else { "entries" })));
438 +
        println!(
439 +
            "  {} {}",
440 +
            ui::note(),
441 +
            ui::dim(format!(
442 +
                "{} submodule {} imported as subrepos with Git pins",
443 +
                r.submodules,
444 +
                if r.submodules == 1 {
445 +
                    "entry"
446 +
                } else {
447 +
                    "entries"
448 +
                }
449 +
            ))
450 +
        );
267 451
    }
268 452
    Ok(0)
269 453
}
…
2 unchanged lines
272 456
    let repo = open()?;
273 457
    let sp = ui::spinner(&format!("Cloning {url} into {path}…"));
274 458
    let pin = repo.submodule_add(url, path, bookmark)?;
275 -
    sp.done(&format!("{} subrepo {} {} {}", ui::done("Added"), ui::path(path), ui::dim(ui::glyph("→", "->")), ui::dim(pin.summary())));
276 -
    println!("  {} {}", ui::note(), ui::dim("recorded in .oxymodules; commit to pin it"));
459 +
    sp.done(&format!(
460 +
        "{} subrepo {} {} {}",
461 +
        ui::done("Added"),
462 +
        ui::path(path),
463 +
        ui::dim(ui::glyph("→", "->")),
464 +
        ui::dim(pin.summary())
465 +
    ));
466 +
    println!(
467 +
        "  {} {}",
468 +
        ui::note(),
469 +
        ui::dim("recorded in .oxymodules; commit to pin it")
470 +
    );
277 471
    Ok(0)
278 472
}
279 473
…
13 unchanged lines
293 487
            UpToDate => ui::ok(format!("{} up to date", ui::glyph("✔", "*"))),
294 488
            Missing => {
295 489
                code = 1;
296 -
                ui::warn(format!("{} missing (pass --init to clone)", ui::glyph("⚠", "!")))
490 +
                ui::warn(format!(
491 +
                    "{} missing (pass --init to clone)",
492 +
                    ui::glyph("⚠", "!")
493 +
                ))
297 494
            }
298 495
            Dirty => {
299 496
                code = 1;
300 -
                ui::warn(format!("{} has uncommitted changes; left as is", ui::glyph("⚠", "!")))
497 +
                ui::warn(format!(
498 +
                    "{} has uncommitted changes; left as is",
499 +
                    ui::glyph("⚠", "!")
500 +
                ))
301 501
            }
302 502
            Failed(e) => {
303 503
                code = 1;
…
7 unchanged lines
311 511
312 512
fn pin_json(p: &oxy_core::subrepo::Pin) -> String {
313 513
    match p {
314 -
        oxy_core::subrepo::Pin::Oxy { repo, heads, bookmark } => json::obj([("kind", json::s("oxy")), ("repo", json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>())), ("heads", json::hashes(heads)), ("bookmark", json::s(bookmark))]),
315 -
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([("kind", json::s("git")), ("sha", json::s(sha)), ("branch", json::s(branch))]),
514 +
        oxy_core::subrepo::Pin::Oxy {
515 +
            repo,
516 +
            heads,
517 +
            bookmark,
518 +
        } => json::obj([
519 +
            ("kind", json::s("oxy")),
520 +
            (
521 +
                "repo",
522 +
                json::s(&repo.iter().map(|b| format!("{b:02x}")).collect::<String>()),
523 +
            ),
524 +
            ("heads", json::hashes(heads)),
525 +
            ("bookmark", json::s(bookmark)),
526 +
        ]),
527 +
        oxy_core::subrepo::Pin::Git { sha, branch } => json::obj([
528 +
            ("kind", json::s("git")),
529 +
            ("sha", json::s(sha)),
530 +
            ("branch", json::s(branch)),
531 +
        ]),
316 532
    }
317 533
}
318 534
…
23 unchanged lines
342 558
        let state = match (&s.actual, s.dirty) {
343 559
            (None, _) => ui::warn(format!("{} missing", ui::glyph("⚠", "!"))),
344 560
            (Some(a), dirty) if *a == s.pin => {
345 -
                if dirty { ui::warn(format!("{} at pin, uncommitted changes", ui::glyph("✎", "~"))) } else { ui::ok(format!("{} at pin", ui::glyph("✔", "*"))) }
561 +
                if dirty {
562 +
                    ui::warn(format!(
563 +
                        "{} at pin, uncommitted changes",
564 +
                        ui::glyph("✎", "~")
565 +
                    ))
566 +
                } else {
567 +
                    ui::ok(format!("{} at pin", ui::glyph("✔", "*")))
568 +
                }
346 569
            }
347 570
            (Some(a), _) => ui::warn(format!("{} at {}", ui::glyph("↕", "<>"), a.summary())),
348 571
        };
349 -
        println!("{} {}{}{}{}{state}", ui::ok(ui::glyph("⧉", "#")), ui::path(&s.path), ui::sep(), ui::dim(s.pin.summary()), ui::sep());
572 +
        println!(
573 +
            "{} {}{}{}{}{state}",
574 +
            ui::ok(ui::glyph("⧉", "#")),
575 +
            ui::path(&s.path),
576 +
            ui::sep(),
577 +
            ui::dim(s.pin.summary()),
578 +
            ui::sep()
579 +
        );
350 580
        if let Some(u) = s.url {
351 581
            println!("    {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(u));
352 582
        }
…
4 unchanged lines
357 587
pub fn submodule_remove(path: &str) -> Result<i32> {
358 588
    let repo = open()?;
359 589
    repo.submodule_remove(path)?;
360 -
    println!("{} subrepo {} {}", ui::done("Removed"), ui::path(path), ui::dim("(directory left in place; commit to record the removal)"));
590 +
    println!(
591 +
        "{} subrepo {} {}",
592 +
        ui::done("Removed"),
593 +
        ui::path(path),
594 +
        ui::dim("(directory left in place; commit to record the removal)")
595 +
    );
361 596
    Ok(0)
362 597
}
363 598
364 599
pub fn deepen(remote: &str, depth: u32) -> Result<i32> {
365 600
    let repo = open()?;
366 601
    if !repo.is_shallow() {
367 -
        println!("{}", ui::dim("This repository is not shallow; nothing to deepen."));
602 +
        println!(
603 +
            "{}",
604 +
            ui::dim("This repository is not shallow; nothing to deepen.")
605 +
        );
368 606
        return Ok(0);
369 607
    }
370 608
    let sp = ui::spinner(&format!("Fetching {depth} more levels from {remote}…"));
371 609
    let n = repo.deepen(remote, depth)?;
372 -
    sp.done(&format!("{} {}; {}", ui::ok("Deepened by"), plural(n, "change"), if repo.is_shallow() { "still shallow".to_string() } else { ui::ok("history is now complete") }));
610 +
    sp.done(&format!(
611 +
        "{} {}; {}",
612 +
        ui::ok("Deepened by"),
613 +
        plural(n, "change"),
614 +
        if repo.is_shallow() {
615 +
            "still shallow".to_string()
616 +
        } else {
617 +
            ui::ok("history is now complete")
618 +
        }
619 +
    ));
373 620
    Ok(0)
374 621
}
modified src/cli/ui.rs +26 −6
…
22 unchanged lines
23 23
            .iter()
24 24
            .filter_map(|k| std::env::var(k).ok())
25 25
            .find(|v| !v.is_empty())
26 -
            .map(|v| v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8"))
26 +
            .map(|v| {
27 +
                v.to_ascii_uppercase().contains("UTF-8") || v.to_ascii_uppercase().contains("UTF8")
28 +
            })
27 29
            .unwrap_or(false);
28 30
        UNICODE.store(!plain && utf8, Ordering::Relaxed);
29 31
    });
…
10 unchanged lines
40 42
41 43
/// A glyph with an ASCII fallback for plain output and non-UTF-8 locales.
42 44
pub fn glyph<'a>(fancy: &'a str, ascii: &'a str) -> &'a str {
43 -
    if unicode() { fancy } else { ascii }
45 +
    if unicode() {
46 +
        fancy
47 +
    } else {
48 +
        ascii
49 +
    }
44 50
}
45 51
46 52
/// A finished action: a tick and the verb in green.
…
44 unchanged lines
91 97
/// Milliseconds since the epoch as a relative time ("3 minutes ago"), or the
92 98
/// date when older than a month.
93 99
pub fn ago(ms: u64) -> String {
94 -
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
100 +
    let now = std::time::SystemTime::now()
101 +
        .duration_since(std::time::UNIX_EPOCH)
102 +
        .map(|d| d.as_millis() as u64)
103 +
        .unwrap_or(0);
95 104
    let secs = now.saturating_sub(ms) / 1000;
96 105
    let unit = |n: u64, w: &str| format!("{n} {w}{} ago", if n == 1 { "" } else { "s" });
97 106
    match secs {
…
4 unchanged lines
102 111
        5_400..=79_199 => unit(secs.div_ceil(3_600), "hour"),
103 112
        79_200..=129_599 => "yesterday".into(),
104 113
        129_600..=2_591_999 => unit(secs.div_ceil(86_400), "day"),
105 -
        _ => super::format::when(ms).split(' ').next().unwrap_or("").to_string(),
114 +
        _ => super::format::when(ms)
115 +
            .split(' ')
116 +
            .next()
117 +
            .unwrap_or("")
118 +
            .to_string(),
106 119
    }
107 120
}
108 121
…
82 unchanged lines
191 204
        return Spinner { bar: None };
192 205
    }
193 206
    let bar = ProgressBar::new_spinner();
194 -
    bar.set_style(ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap().tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]));
207 +
    bar.set_style(
208 +
        ProgressStyle::with_template("{spinner:.cyan} {msg}")
209 +
            .unwrap()
210 +
            .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", "✔"]),
211 +
    );
195 212
    bar.set_message(msg.to_string());
196 213
    bar.enable_steady_tick(Duration::from_millis(80));
197 214
    Spinner { bar: Some(bar) }
…
21 unchanged lines
219 236
220 237
    #[test]
221 238
    fn relative_times() {
222 -
        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
239 +
        let now = std::time::SystemTime::now()
240 +
            .duration_since(std::time::UNIX_EPOCH)
241 +
            .unwrap()
242 +
            .as_millis() as u64;
223 243
        assert_eq!(ago(now), "just now");
224 244
        assert_eq!(ago(now - 60_000), "a minute ago");
225 245
        assert_eq!(ago(now - 5 * 60_000), "5 minutes ago");
…
7 unchanged lines
modified src/cli/workspace.rs +658 −109
1 1
//! Commands that act on the local repository and working copy.
2 2
3 -
use super::format::{author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line, signature_mark, subject_of, when};
3 +
use super::format::{
4 +
    author_name, deps_line, deps_of, plural, short_id, short_meta_id, signature_line,
5 +
    signature_mark, subject_of, when,
6 +
};
4 7
use super::json;
5 -
use oxy_core::repo::SignatureStatus;
6 8
use super::open;
7 9
use super::ui;
8 10
use anyhow::Result;
9 11
use oxy_core::objects::ChangeKind;
12 +
use oxy_core::repo::SignatureStatus;
10 13
use oxy_core::repo::{render_change, ChangeStatus, Repo};
11 14
use std::path::PathBuf;
12 15
13 16
pub fn init(path: Option<PathBuf>, bare: bool) -> Result<i32> {
14 17
    let path = path.unwrap_or(std::env::current_dir()?);
15 -
    let repo = if bare { Repo::init_bare(&path)? } else { Repo::init(&path)? };
16 -
    println!("{} {}oxy repository in {}", ui::done("Initialized"), if bare { "bare " } else { "" }, ui::path(repo.root().display()));
17 -
    println!("{}", ui::kv("bookmark", ui::bookmark(&repo.config.bookmark)));
18 -
    println!("{}", ui::kv("author", format!("{} <{}>", ui::author(&repo.config.author_name), repo.config.author_email)));
18 +
    let repo = if bare {
19 +
        Repo::init_bare(&path)?
20 +
    } else {
21 +
        Repo::init(&path)?
22 +
    };
23 +
    println!(
24 +
        "{} {}oxy repository in {}",
25 +
        ui::done("Initialized"),
26 +
        if bare { "bare " } else { "" },
27 +
        ui::path(repo.root().display())
28 +
    );
29 +
    println!(
30 +
        "{}",
31 +
        ui::kv("bookmark", ui::bookmark(&repo.config.bookmark))
32 +
    );
33 +
    println!(
34 +
        "{}",
35 +
        ui::kv(
36 +
            "author",
37 +
            format!(
38 +
                "{} <{}>",
39 +
                ui::author(&repo.config.author_name),
40 +
                repo.config.author_email
41 +
            )
42 +
        )
43 +
    );
19 44
    Ok(0)
20 45
}
21 46
…
17 unchanged lines
39 64
    sp.clear();
40 65
    if json {
41 66
        println!("{}", repo.status_json(&snap)?);
42 -
        return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 });
67 +
        return Ok(if snap.base_snapshot.is_conflicted() {
68 +
            2
69 +
        } else {
70 +
            0
71 +
        });
43 72
    }
44 73
    println!(
45 74
        "{} {}{}working copy {}{}{}",
…
5 unchanged lines
51 80
        ui::dim(plural(heads.len(), "head"))
52 81
    );
53 82
    if snap.statuses.is_empty() {
54 -
        println!("  {} {}", ui::ok(ui::glyph("✔", "*")), ui::dim("nothing to commit, working copy clean"));
83 +
        println!(
84 +
            "  {} {}",
85 +
            ui::ok(ui::glyph("✔", "*")),
86 +
            ui::dim("nothing to commit, working copy clean")
87 +
        );
55 88
    } else {
56 89
        println!();
57 90
        for s in &snap.statuses {
58 -
            println!("  {} {}", ui::status_mark(status_tag(s.status)), ui::path(&s.path));
91 +
            println!(
92 +
                "  {} {}",
93 +
                ui::status_mark(status_tag(s.status)),
94 +
                ui::path(&s.path)
95 +
            );
59 96
        }
60 97
    }
61 98
    if !snap.base_snapshot.is_conflicted() {
62 99
        return Ok(0);
63 100
    }
64 101
    println!();
65 -
    println!("{} {}", ui::section(ui::err("Conflicts")), ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit"));
102 +
    println!(
103 +
        "{} {}",
104 +
        ui::section(ui::err("Conflicts")),
105 +
        ui::dim("edit the marked files, or the .oxy-conflict-N candidates, then commit")
106 +
    );
66 107
    for c in &snap.base_snapshot.tree.conflicts {
67 108
        println!("  {} tree: {c:?}", ui::err(ui::glyph("✱", "!")));
68 109
    }
69 110
    for (p, f) in &snap.base_snapshot.files {
70 111
        if f.is_conflicted() {
71 -
            let state = match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
72 -
                Some(ChangeStatus::Resolved) => ui::ok(format!("{} resolved in working copy", ui::glyph("✔", "*"))),
73 -
                Some(ChangeStatus::ConflictEdited) => ui::warn(format!("{} markers remain", ui::glyph("‼", "!"))),
112 +
            let state = match snap
113 +
                .statuses
114 +
                .iter()
115 +
                .find(|s| s.path == *p)
116 +
                .map(|s| s.status)
117 +
            {
118 +
                Some(ChangeStatus::Resolved) => {
119 +
                    ui::ok(format!("{} resolved in working copy", ui::glyph("✔", "*")))
120 +
                }
121 +
                Some(ChangeStatus::ConflictEdited) => {
122 +
                    ui::warn(format!("{} markers remain", ui::glyph("‼", "!")))
123 +
                }
74 124
                _ => ui::err(format!("{} unresolved", ui::glyph("✱", "!"))),
75 125
            };
76 126
            println!("  {}  {state}", ui::path(p));
…
2 unchanged lines
79 129
    Ok(2)
80 130
}
81 131
82 -
pub fn commit(message: &str, depends_on: &[String], paths: &[String], interactive: bool, hunks: &[usize]) -> Result<i32> {
132 +
pub fn commit(
133 +
    message: &str,
134 +
    depends_on: &[String],
135 +
    paths: &[String],
136 +
    interactive: bool,
137 +
    hunks: &[usize],
138 +
) -> Result<i32> {
83 139
    let repo = open()?;
84 140
    let mut deps = Vec::new();
85 141
    for d in depends_on {
…
6 unchanged lines
92 148
        if hunks.is_empty() {
93 149
            anyhow::bail!("nothing to commit: the working copy matches its base");
94 150
        }
95 -
        let hunks: Vec<_> = hunks.into_iter().filter(|h| paths.is_empty() || paths.iter().any(|p| h.path == *p || h.path.starts_with(&format!("{}/", p.trim_end_matches('/'))))).collect();
151 +
        let hunks: Vec<_> = hunks
152 +
            .into_iter()
153 +
            .filter(|h| {
154 +
                paths.is_empty()
155 +
                    || paths.iter().any(|p| {
156 +
                        h.path == *p || h.path.starts_with(&format!("{}/", p.trim_end_matches('/')))
157 +
                    })
158 +
            })
159 +
            .collect();
96 160
        let mut chosen = Vec::new();
97 161
        let stdin = std::io::stdin();
98 162
        let mut take_rest = false;
…
2 unchanged lines
101 165
                chosen.push(h.index);
102 166
                continue;
103 167
            }
104 -
            println!("{} {} {}", ui::section(format!("hunk {} of {}", n + 1, hunks.len())), ui::path(&h.path), ui::dim(format!("(#{})", h.index)));
168 +
            println!(
169 +
                "{} {} {}",
170 +
                ui::section(format!("hunk {} of {}", n + 1, hunks.len())),
171 +
                ui::path(&h.path),
172 +
                ui::dim(format!("(#{})", h.index))
173 +
            );
105 174
            for line in h.text.lines() {
106 -
                let styled = if line.starts_with('+') { ui::ok(line) } else if line.starts_with('-') { ui::err(line) } else { ui::dim(line) };
175 +
                let styled = if line.starts_with('+') {
176 +
                    ui::ok(line)
177 +
                } else if line.starts_with('-') {
178 +
                    ui::err(line)
179 +
                } else {
180 +
                    ui::dim(line)
181 +
                };
107 182
                println!("  {styled}");
108 183
            }
109 184
            loop {
110 -
                print!("{} ", ui::warn("Commit this hunk? [y]es [n]o [a]ll remaining [q]uit:"));
185 +
                print!(
186 +
                    "{} ",
187 +
                    ui::warn("Commit this hunk? [y]es [n]o [a]ll remaining [q]uit:")
188 +
                );
111 189
                use std::io::Write as _;
112 190
                std::io::stdout().flush()?;
113 191
                let mut answer = String::new();
…
28 unchanged lines
142 220
    let sp = ui::spinner("Committing…");
143 221
    let h = repo.commit_selection(message, &deps, &selection)?;
144 222
    let c = repo.store().get_change(&h)?;
145 -
    sp.done(&format!("{} {} {}  {}", ui::done("Committed"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(message))));
223 +
    sp.done(&format!(
224 +
        "{} {} {}  {}",
225 +
        ui::done("Committed"),
226 +
        ui::id(short_id(&c)),
227 +
        ui::hash(format!("#{}", h.short(12))),
228 +
        ui::subject(subject_of(message))
229 +
    ));
146 230
    if !matches!(selection, oxy_core::repo::Selection::All) {
147 231
        let rest = repo.snapshot("oxy commit")?;
148 232
        if !rest.statuses.is_empty() {
149 -
            println!("  {} {}", ui::note(), ui::dim(format!("{} left uncommitted", plural(rest.statuses.len(), "path"))));
233 +
            println!(
234 +
                "  {} {}",
235 +
                ui::note(),
236 +
                ui::dim(format!(
237 +
                    "{} left uncommitted",
238 +
                    plural(rest.statuses.len(), "path")
239 +
                ))
240 +
            );
150 241
        }
151 242
    }
152 243
    Ok(0)
…
17 unchanged lines
170 261
        println!("{}", oxy_core::repo::json_array(items));
171 262
        return Ok(0);
172 263
    }
173 -
    let width = lines.iter().map(|l| l.author.chars().count()).max().unwrap_or(0).min(20);
264 +
    let width = lines
265 +
        .iter()
266 +
        .map(|l| l.author.chars().count())
267 +
        .max()
268 +
        .unwrap_or(0)
269 +
        .min(20);
174 270
    let digits = lines.len().to_string().len();
175 271
    for l in &lines {
176 272
        let author: String = l.author.chars().take(20).collect();
…
15 unchanged lines
192 288
    use oxy_core::repo::BisectStep::*;
193 289
    match step {
194 290
        NeedMarks { good, bad } => {
195 -
            println!("{} {} {}", ui::note(), ui::dim(format!("{good} good, {bad} bad;")), ui::dim(if *good == 0 { "mark a known-good change or bookmark: oxy bisect good CHANGE" } else { "mark a known-bad change or bookmark: oxy bisect bad CHANGE" }));
291 +
            println!(
292 +
                "{} {} {}",
293 +
                ui::note(),
294 +
                ui::dim(format!("{good} good, {bad} bad;")),
295 +
                ui::dim(if *good == 0 {
296 +
                    "mark a known-good change or bookmark: oxy bisect good CHANGE"
297 +
                } else {
298 +
                    "mark a known-bad change or bookmark: oxy bisect bad CHANGE"
299 +
                })
300 +
            );
196 301
            Ok(0)
197 302
        }
198 -
        Testing { heads, remaining, steps_left } => {
199 -
            println!("{} {} {}", ui::done("Testing"), ui::dim(format!("{} in the state under test;", plural(heads.len(), "head"))), ui::dim(format!("{remaining} candidates left, about {steps_left} more steps")));
200 -
            println!("  {} {}", ui::note(), ui::dim("run your test, then oxy bisect good | bad | skip"));
303 +
        Testing {
304 +
            heads,
305 +
            remaining,
306 +
            steps_left,
307 +
        } => {
308 +
            println!(
309 +
                "{} {} {}",
310 +
                ui::done("Testing"),
311 +
                ui::dim(format!(
312 +
                    "{} in the state under test;",
313 +
                    plural(heads.len(), "head")
314 +
                )),
315 +
                ui::dim(format!(
316 +
                    "{remaining} candidates left, about {steps_left} more steps"
317 +
                ))
318 +
            );
319 +
            println!(
320 +
                "  {} {}",
321 +
                ui::note(),
322 +
                ui::dim("run your test, then oxy bisect good | bad | skip")
323 +
            );
201 324
            Ok(0)
202 325
        }
203 326
        Found { culprit } => {
204 327
            let c = repo.store().get_change(culprit)?;
205 -
            println!("{} {} {}  {}", ui::done("Found"), ui::id(short_id(&c)), ui::hash(format!("#{}", culprit.short(12))), ui::subject(subject_of(&c.message)));
328 +
            println!(
329 +
                "{} {} {}  {}",
330 +
                ui::done("Found"),
331 +
                ui::id(short_id(&c)),
332 +
                ui::hash(format!("#{}", culprit.short(12))),
333 +
                ui::subject(subject_of(&c.message))
334 +
            );
206 335
            println!("  {} {}", ui::note(), ui::dim("the first change whose inclusion makes the state bad; oxy bisect reset to return"));
207 336
            Ok(0)
208 337
        }
209 338
        Inconclusive { candidates } => {
210 -
            println!("{} {}", ui::caution(), ui::warn(format!("every remaining candidate was skipped ({}); oxy bisect reset to return", plural(candidates.len(), "change"))));
339 +
            println!(
340 +
                "{} {}",
341 +
                ui::caution(),
342 +
                ui::warn(format!(
343 +
                    "every remaining candidate was skipped ({}); oxy bisect reset to return",
344 +
                    plural(candidates.len(), "change")
345 +
                ))
346 +
            );
211 347
            Ok(1)
212 348
        }
213 349
    }
…
2 unchanged lines
216 352
pub fn bisect_start() -> Result<i32> {
217 353
    let repo = open()?;
218 354
    repo.bisect_start()?;
219 -
    println!("{} bisect; {}", ui::done("Started"), ui::dim("now: oxy bisect good CHANGE and oxy bisect bad CHANGE"));
355 +
    println!(
356 +
        "{} bisect; {}",
357 +
        ui::done("Started"),
358 +
        ui::dim("now: oxy bisect good CHANGE and oxy bisect bad CHANGE")
359 +
    );
220 360
    Ok(0)
221 361
}
222 362
…
19 unchanged lines
242 382
        use oxy_core::repo::BisectStep::*;
243 383
        let v = match repo.bisect_status()? {
244 384
            None => "null".to_string(),
245 -
            Some(NeedMarks { good, bad }) => json::obj([("state", json::s("need-marks")), ("good", json::n(good)), ("bad", json::n(bad))]),
246 -
            Some(Testing { heads, remaining, steps_left }) => json::obj([("state", json::s("testing")), ("heads", json::hashes(&heads)), ("remaining", json::n(remaining)), ("steps_left", json::n(steps_left))]),
247 -
            Some(Found { culprit }) => json::obj([("state", json::s("found")), ("culprit", json::s(&culprit.to_hex()))]),
248 -
            Some(Inconclusive { candidates }) => json::obj([("state", json::s("inconclusive")), ("candidates", json::hashes(&candidates))]),
385 +
            Some(NeedMarks { good, bad }) => json::obj([
386 +
                ("state", json::s("need-marks")),
387 +
                ("good", json::n(good)),
388 +
                ("bad", json::n(bad)),
389 +
            ]),
390 +
            Some(Testing {
391 +
                heads,
392 +
                remaining,
393 +
                steps_left,
394 +
            }) => json::obj([
395 +
                ("state", json::s("testing")),
396 +
                ("heads", json::hashes(&heads)),
397 +
                ("remaining", json::n(remaining)),
398 +
                ("steps_left", json::n(steps_left)),
399 +
            ]),
400 +
            Some(Found { culprit }) => json::obj([
401 +
                ("state", json::s("found")),
402 +
                ("culprit", json::s(&culprit.to_hex())),
403 +
            ]),
404 +
            Some(Inconclusive { candidates }) => json::obj([
405 +
                ("state", json::s("inconclusive")),
406 +
                ("candidates", json::hashes(&candidates)),
407 +
            ]),
249 408
        };
250 409
        println!("{v}");
251 410
        return Ok(0);
…
20 unchanged lines
272 431
fn signature_json(repo: &Repo, h: &oxy_core::hash::ChangeHash) -> Result<String> {
273 432
    Ok(match repo.authenticate_change(h)? {
274 433
        SignatureStatus::Unsigned => json::obj([("status", json::s("unsigned"))]),
275 -
        SignatureStatus::Valid { key, name, email, sealed, revoked } => json::obj([
434 +
        SignatureStatus::Valid {
435 +
            key,
436 +
            name,
437 +
            email,
438 +
            sealed,
439 +
            revoked,
440 +
        } => json::obj([
276 441
            ("status", json::s(if sealed { "sealed" } else { "signed" })),
277 442
            ("key", json::s(&key.to_hex())),
278 443
            ("name", json::s(&name)),
279 444
            ("email", json::s(&email)),
280 445
            ("revoked", json::b(revoked)),
281 446
        ]),
282 -
        SignatureStatus::Invalid(why) => json::obj([("status", json::s("invalid")), ("reason", json::s(&why))]),
447 +
        SignatureStatus::Invalid(why) => {
448 +
            json::obj([("status", json::s("invalid")), ("reason", json::s(&why))])
449 +
        }
283 450
    })
284 451
}
285 452
…
10 unchanged lines
296 463
        ("deps", json::hashes(&c.deps)),
297 464
        ("head", json::b(e.is_head)),
298 465
        ("working_copy", json::b(e.is_working_copy)),
299 -
        ("signature", if e.is_working_copy { "null".into() } else { signature_json(repo, &e.hash)? }),
466 +
        (
467 +
            "signature",
468 +
            if e.is_working_copy {
469 +
                "null".into()
470 +
            } else {
471 +
                signature_json(repo, &e.hash)?
472 +
            },
473 +
        ),
300 474
    ]))
301 475
}
302 476
…
11 unchanged lines
314 488
        for e in entries.iter().take(shown) {
315 489
            items.push(log_entry_json(&repo, e)?);
316 490
        }
317 -
        println!("{}", json::obj([("bookmark", json::s(&repo.config.bookmark)), ("total", json::n(entries.len())), ("changes", json::arr(items))]));
491 +
        println!(
492 +
            "{}",
493 +
            json::obj([
494 +
                ("bookmark", json::s(&repo.config.bookmark)),
495 +
                ("total", json::n(entries.len())),
496 +
                ("changes", json::arr(items))
497 +
            ])
498 +
        );
318 499
        return Ok(0);
319 500
    }
320 501
    let total = entries.len();
…
1 unchanged lines
322 503
    let rail = ui::rail();
323 504
    for (i, e) in entries.iter().take(shown).enumerate() {
324 505
        let c = &e.change;
325 -
        let bullet = ui::log_bullet(e.is_working_copy, e.is_head, e.is_working_copy && conflicted);
506 +
        let bullet = ui::log_bullet(
507 +
            e.is_working_copy,
508 +
            e.is_head,
509 +
            e.is_working_copy && conflicted,
510 +
        );
326 511
        let mut labels = Vec::new();
327 512
        if e.is_working_copy {
328 513
            labels.push(ui::warn("working copy"));
…
1 unchanged lines
330 515
        if c.kind == ChangeKind::Resolution {
331 516
            labels.push(ui::warn("resolution"));
332 517
        }
333 -
        let labels = if labels.is_empty() { String::new() } else { format!(" {}{}{}", ui::dim("("), labels.join(ui::dim(", ").as_str()), ui::dim(")")) };
518 +
        let labels = if labels.is_empty() {
519 +
            String::new()
520 +
        } else {
521 +
            format!(
522 +
                " {}{}{}",
523 +
                ui::dim("("),
524 +
                labels.join(ui::dim(", ").as_str()),
525 +
                ui::dim(")")
526 +
            )
527 +
        };
334 528
        let subject = if e.is_working_copy && c.message.is_empty() {
335 -
            ui::dim(if snap.statuses.is_empty() { "(clean)" } else { "(uncommitted changes)" })
529 +
            ui::dim(if snap.statuses.is_empty() {
530 +
                "(clean)"
531 +
            } else {
532 +
                "(uncommitted changes)"
533 +
            })
336 534
        } else {
337 535
            ui::subject(subject_of(&c.message))
338 536
        };
…
6 unchanged lines
345 543
            ui::sep(),
346 544
            ui::dim(ui::ago(c.hlc.wall_ms)),
347 545
            ui::sep(),
348 -
            if e.is_working_copy { ui::dim("not yet committed") } else { signature_mark(&repo, &e.hash)? }
546 +
            if e.is_working_copy {
547 +
                ui::dim("not yet committed")
548 +
            } else {
549 +
                signature_mark(&repo, &e.hash)?
550 +
            }
349 551
        );
350 552
        println!("{rail} {meta}");
351 553
        if verbose {
352 554
            if !e.is_working_copy {
353 -
                println!("{rail} {}", ui::dim(format!("{} {}", when(c.hlc.wall_ms), signature_line(&repo, &e.hash)?)));
555 +
                println!(
556 +
                    "{rail} {}",
557 +
                    ui::dim(format!(
558 +
                        "{} {}",
559 +
                        when(c.hlc.wall_ms),
560 +
                        signature_line(&repo, &e.hash)?
561 +
                    ))
562 +
                );
354 563
            }
355 564
            if !c.deps.is_empty() {
356 -
                println!("{rail} {} {}", ui::dim(ui::glyph("↳", "->")), ui::dim(deps_of(&c.deps)));
565 +
                println!(
566 +
                    "{rail} {} {}",
567 +
                    ui::dim(ui::glyph("↳", "->")),
568 +
                    ui::dim(deps_of(&c.deps))
569 +
                );
357 570
            }
358 -
            let body: Vec<&str> = c.message.lines().skip(1).skip_while(|l| l.trim().is_empty()).collect();
571 +
            let body: Vec<&str> = c
572 +
                .message
573 +
                .lines()
574 +
                .skip(1)
575 +
                .skip_while(|l| l.trim().is_empty())
576 +
                .collect();
359 577
            if !body.is_empty() {
360 578
                println!("{rail}");
361 579
                for line in body {
…
6 unchanged lines
368 586
        }
369 587
    }
370 588
    if shown < total {
371 -
        println!("{}", ui::dim(format!("{} {} more; pass -n to see them", ui::glyph("…", "..."), total - shown)));
589 +
        println!(
590 +
            "{}",
591 +
            ui::dim(format!(
592 +
                "{} {} more; pass -n to see them",
593 +
                ui::glyph("…", "..."),
594 +
                total - shown
595 +
            ))
596 +
        );
372 597
    }
373 598
    Ok(0)
374 599
}
…
13 unchanged lines
388 613
                    json::arr(f.hunks.iter().map(|h| {
389 614
                        json::obj([
390 615
                            ("base_line", json::n(h.base_line)),
391 -
                            ("added", json::arr(h.added.iter().map(|l| json::s(&String::from_utf8_lossy(l))))),
392 -
                            ("removed", json::arr(h.removed.iter().map(|(n, l)| json::obj([("line", json::n(*n)), ("text", json::s(&String::from_utf8_lossy(l)))])))),
616 +
                            (
617 +
                                "added",
618 +
                                json::arr(
619 +
                                    h.added.iter().map(|l| json::s(&String::from_utf8_lossy(l))),
620 +
                                ),
621 +
                            ),
622 +
                            (
623 +
                                "removed",
624 +
                                json::arr(h.removed.iter().map(|(n, l)| {
625 +
                                    json::obj([
626 +
                                        ("line", json::n(*n)),
627 +
                                        ("text", json::s(&String::from_utf8_lossy(l))),
628 +
                                    ])
629 +
                                })),
630 +
                            ),
393 631
                        ])
394 632
                    })),
395 633
                ),
396 634
            ])
397 635
        });
398 -
        println!("{}", json::obj([("change", json::opt(hash, |h| json::s(&h.to_hex()))), ("files", json::arr(items))]));
636 +
        println!(
637 +
            "{}",
638 +
            json::obj([
639 +
                ("change", json::opt(hash, |h| json::s(&h.to_hex()))),
640 +
                ("files", json::arr(items))
641 +
            ])
642 +
        );
399 643
        return Ok(0);
400 644
    }
401 645
    match hash {
…
5 unchanged lines
407 651
        }
408 652
        None => {
409 653
            let snap = repo.snapshot("oxy diff")?;
410 -
            print!("{}", render_change(&snap.wc, &snap.base, &snap.base_snapshot));
654 +
            print!(
655 +
                "{}",
656 +
                render_change(&snap.wc, &snap.base, &snap.base_snapshot)
657 +
            );
411 658
        }
412 659
    }
413 660
    Ok(0)
…
16 unchanged lines
430 677
        return Ok(1);
431 678
    };
432 679
    if as_json {
433 -
        println!("{}", json::obj([("root", json::s(&repo.root().to_string_lossy())), ("workspace", json::s(&repo.workspace().to_hex())), ("bookmark", json::s(&repo.config.bookmark))]));
680 +
        println!(
681 +
            "{}",
682 +
            json::obj([
683 +
                ("root", json::s(&repo.root().to_string_lossy())),
684 +
                ("workspace", json::s(&repo.workspace().to_hex())),
685 +
                ("bookmark", json::s(&repo.config.bookmark))
686 +
            ])
687 +
        );
434 688
    } else {
435 689
        println!("{}", repo.root().display());
436 690
    }
…
2 unchanged lines
439 693
440 694
pub fn version(as_json: bool) -> Result<i32> {
441 695
    if as_json {
442 -
        println!("{}", json::obj([("version", json::s(env!("CARGO_PKG_VERSION"))), ("protocol", json::n(oxy_core::protocol::PROTOCOL_VERSION)), ("format", json::n(2))]));
696 +
        println!(
697 +
            "{}",
698 +
            json::obj([
699 +
                ("version", json::s(env!("CARGO_PKG_VERSION"))),
700 +
                ("protocol", json::n(oxy_core::protocol::PROTOCOL_VERSION)),
701 +
                ("format", json::n(2))
702 +
            ])
703 +
        );
443 704
    } else {
444 -
        println!("oxy {} (repository format 2, protocol {})", env!("CARGO_PKG_VERSION"), oxy_core::protocol::PROTOCOL_VERSION);
705 +
        println!(
706 +
            "oxy {} (repository format 2, protocol {})",
707 +
            env!("CARGO_PKG_VERSION"),
708 +
            oxy_core::protocol::PROTOCOL_VERSION
709 +
        );
445 710
    }
446 711
    Ok(0)
447 712
}
…
10 unchanged lines
458 723
    if !paths.is_empty() {
459 724
        n += repo.discard(paths)?;
460 725
    }
461 -
    println!("{} {} restored to the working copy's base", ui::done("Discarded"), plural(n, "path"));
726 +
    println!(
727 +
        "{} {} restored to the working copy's base",
728 +
        ui::done("Discarded"),
729 +
        plural(n, "path")
730 +
    );
462 731
    Ok(0)
463 732
}
464 733
…
1 unchanged lines
466 735
    let repo = open()?;
467 736
    let (_, hunks) = repo.hunks()?;
468 737
    if as_json {
469 -
        println!("{}", json::arr(hunks.iter().map(|h| json::obj([("index", json::n(h.index)), ("path", json::s(&h.path)), ("text", json::s(&h.text))]))));
738 +
        println!(
739 +
            "{}",
740 +
            json::arr(hunks.iter().map(|h| json::obj([
741 +
                ("index", json::n(h.index)),
742 +
                ("path", json::s(&h.path)),
743 +
                ("text", json::s(&h.text))
744 +
            ])))
745 +
        );
470 746
        return Ok(0);
471 747
    }
472 748
    if hunks.is_empty() {
473 -
        println!("{}", ui::dim("No hunks: the working copy matches its base."));
749 +
        println!(
750 +
            "{}",
751 +
            ui::dim("No hunks: the working copy matches its base.")
752 +
        );
474 753
    }
475 754
    for h in &hunks {
476 -
        println!("{} {}", ui::section(format!("hunk {}", h.index)), ui::path(&h.path));
755 +
        println!(
756 +
            "{} {}",
757 +
            ui::section(format!("hunk {}", h.index)),
758 +
            ui::path(&h.path)
759 +
        );
477 760
        for line in h.text.lines() {
478 -
            let styled = if line.starts_with('+') { ui::ok(line) } else if line.starts_with('-') { ui::err(line) } else { ui::dim(line) };
761 +
            let styled = if line.starts_with('+') {
762 +
                ui::ok(line)
763 +
            } else if line.starts_with('-') {
764 +
                ui::err(line)
765 +
            } else {
766 +
                ui::dim(line)
767 +
            };
479 768
            println!("  {styled}");
480 769
        }
481 770
    }
…
8 unchanged lines
490 779
    if as_json {
491 780
        let files = repo.diff_files(Some(&h))?;
492 781
        let heads = repo.bookmark_heads()?;
493 -
        let entry = oxy_core::repo::LogEntry { hash: h, change: oxy_core::state::ChangeMeta::of(&c), is_working_copy: c.kind == oxy_core::objects::ChangeKind::WorkingCopy, is_head: heads.contains(&h) };
782 +
        let entry = oxy_core::repo::LogEntry {
783 +
            hash: h,
784 +
            change: oxy_core::state::ChangeMeta::of(&c),
785 +
            is_working_copy: c.kind == oxy_core::objects::ChangeKind::WorkingCopy,
786 +
            is_head: heads.contains(&h),
787 +
        };
494 788
        let mut body = log_entry_json(&repo, &entry)?;
495 789
        body.pop();
496 -
        body.push_str(&format!(",{}:{}}}", json::s("files"), json::arr(files.iter().map(|f| json::obj([("path", json::s(&f.path)), ("kind", json::s(&f.kind)), ("from", json::opt(f.from.as_deref(), json::s))])))));
790 +
        body.push_str(&format!(
791 +
            ",{}:{}}}",
792 +
            json::s("files"),
793 +
            json::arr(files.iter().map(|f| json::obj([
794 +
                ("path", json::s(&f.path)),
795 +
                ("kind", json::s(&f.kind)),
796 +
                ("from", json::opt(f.from.as_deref(), json::s))
797 +
            ])))
798 +
        ));
497 799
        println!("{body}");
498 800
        return Ok(0);
499 801
    }
500 -
    println!("{} {} {}", ui::section("change"), ui::id(short_id(&c)), ui::hash(format!("#{h}")));
802 +
    println!(
803 +
        "{} {} {}",
804 +
        ui::section("change"),
805 +
        ui::id(short_id(&c)),
806 +
        ui::hash(format!("#{h}"))
807 +
    );
501 808
    println!("{}", ui::kv("kind", format!("{:?}", c.kind).to_lowercase()));
502 -
    println!("{}", ui::kv("author", ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))));
503 -
    println!("{}", ui::kv("date", format!("{} {}", when(c.hlc.wall_ms), ui::dim(format!("({})", ui::ago(c.hlc.wall_ms))))));
809 +
    println!(
810 +
        "{}",
811 +
        ui::kv(
812 +
            "author",
813 +
            ui::author(author_name(&repo, &oxy_core::state::ChangeMeta::of(&c)))
814 +
        )
815 +
    );
816 +
    println!(
817 +
        "{}",
818 +
        ui::kv(
819 +
            "date",
820 +
            format!(
821 +
                "{} {}",
822 +
                when(c.hlc.wall_ms),
823 +
                ui::dim(format!("({})", ui::ago(c.hlc.wall_ms)))
824 +
            )
825 +
        )
826 +
    );
504 827
    println!("{}", ui::kv("deps", ui::dim(deps_line(&c))));
505 828
    println!("{}", ui::kv("sig", signature_line(&repo, &h)?));
506 829
    println!();
507 -
    for line in (if c.message.is_empty() { "(no description)" } else { &c.message }).lines() {
830 +
    for line in (if c.message.is_empty() {
831 +
        "(no description)"
832 +
    } else {
833 +
        &c.message
834 +
    })
835 +
    .lines()
836 +
    {
508 837
        println!("    {line}");
509 838
    }
510 839
    println!();
…
12 unchanged lines
523 852
        }
524 853
        heads
525 854
    };
526 -
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
855 +
    let spec = if changes.is_empty() {
856 +
        repo.config.bookmark.clone()
857 +
    } else {
858 +
        changes.join(" ")
859 +
    };
527 860
    let sp = ui::spinner(&format!("Checking out {spec}…"));
528 861
    let r = repo.checkout(&heads, discard, &format!("oxy checkout {spec}"))?;
529 -
    sp.done(&format!("{} on {}: {} written, {} removed", ui::done("Working copy"), ui::bookmark(&spec), plural(r.written, "file"), r.removed));
862 +
    sp.done(&format!(
863 +
        "{} on {}: {} written, {} removed",
864 +
        ui::done("Working copy"),
865 +
        ui::bookmark(&spec),
866 +
        plural(r.written, "file"),
867 +
        r.removed
868 +
    ));
530 869
    for n in &r.notes {
531 870
        println!("  {} {n}", ui::note());
532 871
    }
533 872
    if r.conflicted {
534 -
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
873 +
        println!(
874 +
            "  {} {}",
875 +
            ui::caution(),
876 +
            ui::warn("this state has conflicts; see oxy status")
877 +
        );
535 878
    }
536 879
    Ok(0)
537 880
}
…
3 unchanged lines
541 884
    let snap = repo.snapshot("oxy resolve")?;
542 885
    if as_json {
543 886
        println!("{}", repo.status_json(&snap)?);
544 -
        return Ok(if snap.base_snapshot.is_conflicted() { 2 } else { 0 });
887 +
        return Ok(if snap.base_snapshot.is_conflicted() {
888 +
            2
889 +
        } else {
890 +
            0
891 +
        });
545 892
    }
546 893
    if !snap.base_snapshot.is_conflicted() {
547 894
        println!("The working copy's base has no conflicts.");
…
4 unchanged lines
552 899
        if !f.is_conflicted() {
553 900
            continue;
554 901
        }
555 -
        match snap.statuses.iter().find(|s| s.path == *p).map(|s| s.status) {
902 +
        match snap
903 +
            .statuses
904 +
            .iter()
905 +
            .find(|s| s.path == *p)
906 +
            .map(|s| s.status)
907 +
        {
556 908
            Some(ChangeStatus::Resolved) => println!("resolved   {p}"),
557 909
            Some(ChangeStatus::ConflictEdited) => {
558 910
                remaining += 1;
…
43 unchanged lines
602 954
        return Ok(0);
603 955
    }
604 956
    for (i, (r, op)) in repo.op_log()?.into_iter().enumerate() {
605 -
        let bullet = if i == 0 { ui::ok(ui::glyph("●", "@")) } else { ui::dim(ui::glyph("○", "o")) };
957 +
        let bullet = if i == 0 {
958 +
            ui::ok(ui::glyph("●", "@"))
959 +
        } else {
960 +
            ui::dim(ui::glyph("○", "o"))
961 +
        };
606 962
        let cmd = subject_of(&op.command).split("\\n").next().unwrap_or("");
607 -
        println!("{bullet} {}{}{}  {}", ui::hash(r.short(12)), ui::sep(), ui::dim(format!("{:<16}", ui::ago(op.hlc.wall_ms))), if i == 0 { ui::subject(cmd) } else { cmd.to_string() });
963 +
        println!(
964 +
            "{bullet} {}{}{}  {}",
965 +
            ui::hash(r.short(12)),
966 +
            ui::sep(),
967 +
            ui::dim(format!("{:<16}", ui::ago(op.hlc.wall_ms))),
968 +
            if i == 0 {
969 +
                ui::subject(cmd)
970 +
            } else {
971 +
                cmd.to_string()
972 +
            }
973 +
        );
608 974
    }
609 975
    Ok(0)
610 976
}
…
12 unchanged lines
623 989
            100.0 * r.packed.packed_bytes as f64 / r.packed.raw_bytes.max(1) as f64
624 990
        );
625 991
    }
626 -
    println!("{} reachable; pruned {} ({} bytes){}{}; {} present.",
992 +
    println!(
993 +
        "{} reachable; pruned {} ({} bytes){}{}; {} present.",
627 994
        plural(r.reachable, "object"),
628 995
        plural(r.pruned.objects, "unreachable object"),
629 996
        r.pruned.bytes,
630 -
        if r.pruned.deferred > 0 { format!("; {} deferred by the grace period (use --now)", plural(r.pruned.deferred, "object")) } else { String::new() },
631 -
        if r.merged_packs > 0 { format!("; merged {} into one", plural(r.merged_packs, "pack")) } else { String::new() },
997 +
        if r.pruned.deferred > 0 {
998 +
            format!(
999 +
                "; {} deferred by the grace period (use --now)",
1000 +
                plural(r.pruned.deferred, "object")
1001 +
            )
1002 +
        } else {
1003 +
            String::new()
1004 +
        },
1005 +
        if r.merged_packs > 0 {
1006 +
            format!("; merged {} into one", plural(r.merged_packs, "pack"))
1007 +
        } else {
1008 +
            String::new()
1009 +
        },
632 1010
        plural(repo.store().pack_count(), "pack")
633 1011
    );
634 1012
    Ok(0)
…
15 unchanged lines
650 1028
                .copied()
651 1029
                .unwrap();
652 1030
            let c = repo.store().get_change(&latest)?;
653 -
            println!("Defaulting to {} #{} (most recent of the {} heads under the working copy)", short_id(&c), latest.short(12), many.len());
1031 +
            println!(
1032 +
                "Defaulting to {} #{} (most recent of the {} heads under the working copy)",
1033 +
                short_id(&c),
1034 +
                latest.short(12),
1035 +
                many.len()
1036 +
            );
654 1037
            Ok(latest)
655 1038
        }
656 1039
    }
…
7 unchanged lines
664 1047
    };
665 1048
    let new = repo.describe(&h, message)?;
666 1049
    let c = repo.store().get_change(&new)?;
667 -
    println!("{} {} {}  {}", ui::done("Described"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(message)));
1050 +
    println!(
1051 +
        "{} {} {}  {}",
1052 +
        ui::done("Described"),
1053 +
        ui::id(short_id(&c)),
1054 +
        ui::hash(format!("#{}", new.short(12))),
1055 +
        ui::subject(subject_of(message))
1056 +
    );
668 1057
    Ok(0)
669 1058
}
670 1059
…
7 unchanged lines
678 1067
        None => repo.amend(message)?,
679 1068
    };
680 1069
    let c = repo.store().get_change(&new)?;
681 -
    println!("{} {} {}  {}", ui::done("Amended"), ui::id(short_id(&c)), ui::hash(format!("#{}", new.short(12))), ui::subject(subject_of(&c.message)));
1070 +
    println!(
1071 +
        "{} {} {}  {}",
1072 +
        ui::done("Amended"),
1073 +
        ui::id(short_id(&c)),
1074 +
        ui::hash(format!("#{}", new.short(12))),
1075 +
        ui::subject(subject_of(&c.message))
1076 +
    );
682 1077
    Ok(0)
683 1078
}
684 1079
…
2 unchanged lines
687 1082
    let h = repo.resolve_change(change)?;
688 1083
    let c = repo.store().get_change(&h)?;
689 1084
    let notes = repo.abandon(&h)?;
690 -
    println!("{} {} {}  {}", ui::done("Abandoned"), ui::id(short_id(&c)), ui::hash(format!("#{}", h.short(12))), ui::dim("its content remains on disk as uncommitted work if the working copy sat on it"));
1085 +
    println!(
1086 +
        "{} {} {}  {}",
1087 +
        ui::done("Abandoned"),
1088 +
        ui::id(short_id(&c)),
1089 +
        ui::hash(format!("#{}", h.short(12))),
1090 +
        ui::dim("its content remains on disk as uncommitted work if the working copy sat on it")
1091 +
    );
691 1092
    for n in notes {
692 1093
        println!("  {} {n}", ui::note());
693 1094
    }
…
4 unchanged lines
698 1099
    let repo = open()?;
699 1100
    let sp = ui::spinner("Writing checkpoint…");
700 1101
    let (r, changes, bytes) = repo.checkpoint()?;
701 -
    sp.done(&format!("{} {} of {} ({} bytes) for {}", ui::done("Checkpoint"), ui::hash(format!("#{}", r.short(12))), plural(changes, "change"), bytes, ui::id(&repo.config.bookmark)));
1102 +
    sp.done(&format!(
1103 +
        "{} {} of {} ({} bytes) for {}",
1104 +
        ui::done("Checkpoint"),
1105 +
        ui::hash(format!("#{}", r.short(12))),
1106 +
        plural(changes, "change"),
1107 +
        bytes,
1108 +
        ui::id(&repo.config.bookmark)
1109 +
    ));
702 1110
    Ok(0)
703 1111
}
704 1112
705 1113
/// For shell prompts: silent (exit 1) outside a repository, otherwise one line.
706 1114
pub fn prompt(format: &str, glyphs: bool, color: Option<&str>) -> Result<i32> {
707 -
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else { return Ok(1) };
1115 +
    let Ok(repo) = Repo::open(&std::env::current_dir()?) else {
1116 +
        return Ok(1);
1117 +
    };
708 1118
    let info = repo.quick_status()?;
709 -
    let fmt = if glyphs && format == oxy_core::repo::DEFAULT_PROMPT_FORMAT { oxy_core::repo::GLYPH_PROMPT_FORMAT } else { format };
1119 +
    let fmt = if glyphs && format == oxy_core::repo::DEFAULT_PROMPT_FORMAT {
1120 +
        oxy_core::repo::GLYPH_PROMPT_FORMAT
1121 +
    } else {
1122 +
        format
1123 +
    };
710 1124
    let line = match color {
711 1125
        Some("bash") => info.format_colored(fmt, ("\\[", "\\]")),
712 1126
        Some("zsh") => info.format_colored(fmt, ("%{", "%}")),
…
19 unchanged lines
732 1146
    }
733 1147
    let snap = repo.snapshot("oxy sparse")?;
734 1148
    if !snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty() {
735 -
        anyhow::bail!("the working copy has changes; commit them before changing the sparse filter");
1149 +
        anyhow::bail!(
1150 +
            "the working copy has changes; commit them before changing the sparse filter"
1151 +
        );
736 1152
    }
737 1153
    repo.config.set("filter.paths", &paths.join(","))?;
738 1154
    repo.save_config()?;
739 1155
    let heads = repo.bookmark_heads()?;
740 1156
    let sp = ui::spinner("Re-checking out with the new filter…");
741 1157
    let r = repo.checkout(&heads, true, "oxy sparse (checkout)")?;
742 -
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
1158 +
    sp.done(&format!(
1159 +
        "{} {}: {} written, {} removed",
1160 +
        ui::ok("Sparse filter set to"),
1161 +
        ui::path(paths.join(",")),
1162 +
        plural(r.written, "file"),
1163 +
        r.removed
1164 +
    ));
743 1165
    Ok(0)
744 1166
}
745 1167
…
17 unchanged lines
763 1185
        ui::dim(ui::glyph("→", "->")),
764 1186
        ui::hash(format!("#{}", t.state.short(12))),
765 1187
        plural(t.heads.len(), "head"),
766 -
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
1188 +
        t.signer
1189 +
            .map(|s| format!(", signed by {s}"))
1190 +
            .unwrap_or_else(|| ui::dim(", unsigned"))
767 1191
    );
768 1192
    Ok(0)
769 1193
}
…
10 unchanged lines
780 1204
                    ("state", json::s(&t.state.to_hex())),
781 1205
                    ("heads", json::hashes(&t.heads)),
782 1206
                    ("message", json::s(&t.message)),
783 -
                    ("signer", json::opt(t.signer.as_ref(), |s| json::s(&s.to_string()))),
1207 +
                    (
1208 +
                        "signer",
1209 +
                        json::opt(t.signer.as_ref(), |s| json::s(&s.to_string())),
1210 +
                    ),
784 1211
                ])
785 1212
            }))
786 1213
        );
…
9 unchanged lines
796 1223
            ui::bookmark(&t.name),
797 1224
            ui::hash(format!("#{}", t.state.short(12))),
798 1225
            ui::sep() + &ui::dim(plural(t.heads.len(), "head")),
799 -
            if t.message.is_empty() { String::new() } else { format!("  {}", ui::subject(subject_of(&t.message))) },
800 -
            t.signer.map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v")))).unwrap_or_default()
1226 +
            if t.message.is_empty() {
1227 +
                String::new()
1228 +
            } else {
1229 +
                format!("  {}", ui::subject(subject_of(&t.message)))
1230 +
            },
1231 +
            t.signer
1232 +
                .map(|s| ui::sep() + &ui::ok(format!("{} {s}", ui::glyph("✔", "v"))))
1233 +
                .unwrap_or_default()
801 1234
        );
802 1235
    }
803 1236
    Ok(0)
…
12 unchanged lines
816 1249
    };
817 1250
    let sp = ui::spinner(&format!("Creating workspace at {}…", path.display()));
818 1251
    let ws = repo.workspace_add(path, heads.as_deref())?;
819 -
    sp.done(&format!("{} {} (workspace {})", ui::done("Workspace created at"), ui::path(ws.root().display()), ui::id(ws.workspace().short(8))));
1252 +
    sp.done(&format!(
1253 +
        "{} {} (workspace {})",
1254 +
        ui::done("Workspace created at"),
1255 +
        ui::path(ws.root().display()),
1256 +
        ui::id(ws.workspace().short(8))
1257 +
    ));
820 1258
    Ok(0)
821 1259
}
822 1260
…
7 unchanged lines
830 1268
                    ("id", json::s(&w.id.to_hex())),
831 1269
                    ("path", json::s(&w.path.to_string_lossy())),
832 1270
                    ("current", json::b(w.current)),
833 -
                    ("working_copy", json::opt(w.working_copy, |h| json::s(&h.to_hex()))),
1271 +
                    (
1272 +
                        "working_copy",
1273 +
                        json::opt(w.working_copy, |h| json::s(&h.to_hex())),
1274 +
                    ),
834 1275
                ])
835 1276
            }))
836 1277
        );
…
2 unchanged lines
839 1280
    for w in repo.workspaces()? {
840 1281
        println!(
841 1282
            "{} {} {}{}",
842 -
            if w.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) },
1283 +
            if w.current {
1284 +
                ui::ok(ui::glyph("●", "*"))
1285 +
            } else {
1286 +
                ui::dim(ui::glyph("○", "o"))
1287 +
            },
843 1288
            ui::id(w.id.short(8)),
844 1289
            ui::path(w.path.display()),
845 -
            w.working_copy.map(|h| ui::sep() + &ui::dim(format!("working copy #{}", h.short(12)))).unwrap_or_else(|| ui::sep() + &ui::warn("not in view"))
1290 +
            w.working_copy
1291 +
                .map(|h| ui::sep() + &ui::dim(format!("working copy #{}", h.short(12))))
1292 +
                .unwrap_or_else(|| ui::sep() + &ui::warn("not in view"))
846 1293
        );
847 1294
    }
848 1295
    Ok(0)
…
3 unchanged lines
852 1299
    let repo = open()?;
853 1300
    let all = repo.workspaces()?;
854 1301
    let target = std::fs::canonicalize(spec).ok();
855 -
    let matches: Vec<_> = all.iter().filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path)).collect();
1302 +
    let matches: Vec<_> = all
1303 +
        .iter()
1304 +
        .filter(|w| w.id.starts_with_hex(spec) || target.as_ref().is_some_and(|t| *t == w.path))
1305 +
        .collect();
856 1306
    let w = match matches.as_slice() {
857 1307
        [w] => *w,
858 1308
        [] => anyhow::bail!("no workspace matches {spec:?}"),
859 1309
        _ => anyhow::bail!("{spec:?} is ambiguous"),
860 1310
    };
861 1311
    repo.workspace_forget(&w.id)?;
862 -
    println!("{} workspace {} at {}; its files were left in place.", ui::done("Forgot"), ui::id(w.id.short(8)), ui::path(w.path.display()));
1312 +
    println!(
1313 +
        "{} workspace {} at {}; its files were left in place.",
1314 +
        ui::done("Forgot"),
1315 +
        ui::id(w.id.short(8)),
1316 +
        ui::path(w.path.display())
1317 +
    );
863 1318
    Ok(0)
864 1319
}
865 1320
866 1321
pub fn lock(path: &str) -> Result<i32> {
867 1322
    let repo = open()?;
868 1323
    repo.lock_path(path)?;
869 -
    println!("{} {} {}", ui::done("Locked"), ui::path(path), ui::dim("(advisory, local)"));
1324 +
    println!(
1325 +
        "{} {} {}",
1326 +
        ui::done("Locked"),
1327 +
        ui::path(path),
1328 +
        ui::dim("(advisory, local)")
1329 +
    );
870 1330
    Ok(0)
871 1331
}
872 1332
…
10 unchanged lines
883 1343
    let (h1, h2) = repo.split(&h, paths)?;
884 1344
    let c1 = repo.store().get_change(&h1)?;
885 1345
    let c2 = repo.store().get_change(&h2)?;
886 -
    println!("{} into {} {} and {} {}", ui::done("Split"), ui::id(short_id(&c1)), ui::hash(format!("#{}", h1.short(12))), ui::id(short_id(&c2)), ui::hash(format!("#{}", h2.short(12))));
1346 +
    println!(
1347 +
        "{} into {} {} and {} {}",
1348 +
        ui::done("Split"),
1349 +
        ui::id(short_id(&c1)),
1350 +
        ui::hash(format!("#{}", h1.short(12))),
1351 +
        ui::id(short_id(&c2)),
1352 +
        ui::hash(format!("#{}", h2.short(12)))
1353 +
    );
887 1354
    Ok(0)
888 1355
}
889 1356
…
8 unchanged lines
898 1365
pub fn bookmark_create(name: &str, changes: &[String]) -> Result<i32> {
899 1366
    let repo = open()?;
900 1367
    repo.snapshot("oxy bookmark create")?;
901 -
    let heads = if changes.is_empty() { None } else { Some(heads_from(&repo, changes)?) };
1368 +
    let heads = if changes.is_empty() {
1369 +
        None
1370 +
    } else {
1371 +
        Some(heads_from(&repo, changes)?)
1372 +
    };
902 1373
    let heads = repo.bookmark_create(name, heads.as_deref())?;
903 -
    println!("{} bookmark {} {} {}", ui::done("Created"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
904 -
    println!("  {} {}", ui::note(), ui::dim(format!("switch to it with oxy bookmark switch {name}")));
1374 +
    println!(
1375 +
        "{} bookmark {} {} {}",
1376 +
        ui::done("Created"),
1377 +
        ui::bookmark(name),
1378 +
        ui::dim(ui::glyph("→", "->")),
1379 +
        ui::dim(plural(heads.len(), "head"))
1380 +
    );
1381 +
    println!(
1382 +
        "  {} {}",
1383 +
        ui::note(),
1384 +
        ui::dim(format!("switch to it with oxy bookmark switch {name}"))
1385 +
    );
905 1386
    Ok(0)
906 1387
}
907 1388
…
2 unchanged lines
910 1391
    repo.snapshot("oxy bookmark set")?;
911 1392
    let heads = heads_from(&repo, changes)?;
912 1393
    let heads = repo.bookmark_set(name, &heads)?;
913 -
    println!("{} bookmark {} {} {}", ui::done("Moved"), ui::bookmark(name), ui::dim(ui::glyph("→", "->")), ui::dim(plural(heads.len(), "head")));
1394 +
    println!(
1395 +
        "{} bookmark {} {} {}",
1396 +
        ui::done("Moved"),
1397 +
        ui::bookmark(name),
1398 +
        ui::dim(ui::glyph("→", "->")),
1399 +
        ui::dim(plural(heads.len(), "head"))
1400 +
    );
914 1401
    Ok(0)
915 1402
}
916 1403
917 1404
pub fn bookmark_delete(name: &str) -> Result<i32> {
918 1405
    let repo = open()?;
919 1406
    repo.bookmark_delete(name)?;
920 -
    println!("{} bookmark {} {}", ui::done("Deleted"), ui::bookmark(name), ui::dim("(its changes stay until unreachable)"));
1407 +
    println!(
1408 +
        "{} bookmark {} {}",
1409 +
        ui::done("Deleted"),
1410 +
        ui::bookmark(name),
1411 +
        ui::dim("(its changes stay until unreachable)")
1412 +
    );
921 1413
    Ok(0)
922 1414
}
923 1415
…
13 unchanged lines
937 1429
        return Ok(0);
938 1430
    }
939 1431
    for b in repo.bookmarks()? {
940 -
        let mark = if b.current { ui::ok(ui::glyph("●", "*")) } else { ui::dim(ui::glyph("○", "o")) };
1432 +
        let mark = if b.current {
1433 +
            ui::ok(ui::glyph("●", "*"))
1434 +
        } else {
1435 +
            ui::dim(ui::glyph("○", "o"))
1436 +
        };
941 1437
        let mut tail = String::new();
942 1438
        if let Some(n) = b.ahead.filter(|n| *n > 0) {
943 1439
            tail += &ui::ok(format!(" {}{n}", ui::glyph("↑", "+")));
…
1 unchanged lines
945 1441
        if let Some(n) = b.behind.filter(|n| *n > 0) {
946 1442
            tail += &ui::warn(format!(" {}{n}", ui::glyph("↓", "-")));
947 1443
        }
948 -
        let head = b.heads.first().map(|h| repo.store().get_change(h).map(|c| ui::subject(subject_of(&c.message))).unwrap_or_default()).unwrap_or_else(|| ui::dim("(empty)"));
949 -
        println!("{mark} {}{}{}{tail}  {head}", ui::bookmark(&b.name), ui::sep(), ui::dim(plural(b.heads.len(), "head")));
1444 +
        let head = b
1445 +
            .heads
1446 +
            .first()
1447 +
            .map(|h| {
1448 +
                repo.store()
1449 +
                    .get_change(h)
1450 +
                    .map(|c| ui::subject(subject_of(&c.message)))
1451 +
                    .unwrap_or_default()
1452 +
            })
1453 +
            .unwrap_or_else(|| ui::dim("(empty)"));
1454 +
        println!(
1455 +
            "{mark} {}{}{}{tail}  {head}",
1456 +
            ui::bookmark(&b.name),
1457 +
            ui::sep(),
1458 +
            ui::dim(plural(b.heads.len(), "head"))
1459 +
        );
950 1460
    }
951 1461
    Ok(0)
952 1462
}
…
2 unchanged lines
955 1465
    let mut repo = open()?;
956 1466
    let sp = ui::spinner(&format!("Switching to {name}…"));
957 1467
    let r = repo.bookmark_switch(name, discard)?;
958 -
    sp.done(&format!("{} to {}: {} written, {} removed", ui::done("Switched"), ui::bookmark(name), plural(r.written, "file"), r.removed));
1468 +
    sp.done(&format!(
1469 +
        "{} to {}: {} written, {} removed",
1470 +
        ui::done("Switched"),
1471 +
        ui::bookmark(name),
1472 +
        plural(r.written, "file"),
1473 +
        r.removed
1474 +
    ));
959 1475
    if r.conflicted {
960 -
        println!("  {} {}", ui::caution(), ui::warn("this state has conflicts; see oxy status"));
1476 +
        println!(
1477 +
            "  {} {}",
1478 +
            ui::caution(),
1479 +
            ui::warn("this state has conflicts; see oxy status")
1480 +
        );
961 1481
    }
962 1482
    Ok(0)
963 1483
}
…
3 unchanged lines
967 1487
    let sp = ui::spinner(&format!("Merging {bookmark}…"));
968 1488
    let r = repo.merge(bookmark)?;
969 1489
    if !r.changed {
970 -
        sp.done(&format!("{} {}", ui::ok(ui::glyph("✔", "*")), ui::dim(format!("{} already contains {bookmark}", repo.config.bookmark))));
1490 +
        sp.done(&format!(
1491 +
            "{} {}",
1492 +
            ui::ok(ui::glyph("✔", "*")),
1493 +
            ui::dim(format!(
1494 +
                "{} already contains {bookmark}",
1495 +
                repo.config.bookmark
1496 +
            ))
1497 +
        ));
971 1498
        return Ok(0);
972 1499
    }
973 1500
    sp.done(&format!(
…
2 unchanged lines
976 1503
        ui::bookmark(bookmark),
977 1504
        ui::bookmark(&repo.config.bookmark),
978 1505
        plural(r.heads.len(), "head"),
979 -
        ui::dim(if r.checked_out { "; working copy updated" } else { "; run oxy checkout to update the working copy" })
1506 +
        ui::dim(if r.checked_out {
1507 +
            "; working copy updated"
1508 +
        } else {
1509 +
            "; run oxy checkout to update the working copy"
1510 +
        })
980 1511
    ));
981 1512
    let snap = repo.snapshot("oxy merge")?;
982 1513
    if snap.base_snapshot.is_conflicted() {
983 -
        println!("  {} {}", ui::caution(), ui::warn("the result has conflicts; see oxy status"));
1514 +
        println!(
1515 +
            "  {} {}",
1516 +
            ui::caution(),
1517 +
            ui::warn("the result has conflicts; see oxy status")
1518 +
        );
984 1519
    }
985 1520
    Ok(0)
986 1521
}
…
5 unchanged lines
992 1527
    let sp = ui::spinner("Squashing…");
993 1528
    let h = repo.squash(&c, &d)?;
994 1529
    let new = repo.store().get_change(&h)?;
995 -
    sp.done(&format!("{} into {} {}  {}", ui::done("Squashed"), ui::id(short_id(&new)), ui::hash(format!("#{}", h.short(12))), ui::subject(subject_of(&new.message))));
1530 +
    sp.done(&format!(
1531 +
        "{} into {} {}  {}",
1532 +
        ui::done("Squashed"),
1533 +
        ui::id(short_id(&new)),
1534 +
        ui::hash(format!("#{}", h.short(12))),
1535 +
        ui::subject(subject_of(&new.message))
1536 +
    ));
996 1537
    Ok(0)
997 1538
}
998 1539
…
7 unchanged lines
1006 1547
    let sp = ui::spinner("Rebasing…");
1007 1548
    let h = repo.rebase(&c, &heads)?;
1008 1549
    let new = repo.store().get_change(&h)?;
1009 -
    sp.done(&format!("{} {} {}  {}", ui::done("Rebased"), ui::id(short_id(&new)), ui::hash(format!("#{}", h.short(12))), ui::dim(format!("now depends on {}", plural(new.deps.len(), "change")))));
1550 +
    sp.done(&format!(
1551 +
        "{} {} {}  {}",
1552 +
        ui::done("Rebased"),
1553 +
        ui::id(short_id(&new)),
1554 +
        ui::hash(format!("#{}", h.short(12))),
1555 +
        ui::dim(format!(
1556 +
            "now depends on {}",
1557 +
            plural(new.deps.len(), "change")
1558 +
        ))
1559 +
    ));
1010 1560
    Ok(0)
1011 1561
}
1012 -
modified tests/cli_json.rs +113 −19
…
5 unchanged lines
6 6
use std::process::Command;
7 7
8 8
fn oxy(dir: &Path, home: &Path, args: &[&str]) -> (i32, Vec<u8>, String) {
9 -
    let out = Command::new(env!("CARGO_BIN_EXE_oxy")).arg("--plain").args(args).current_dir(dir).env("OXY_HOME", home).env("NO_COLOR", "1").output().unwrap();
10 -
    (out.status.code().unwrap_or(-1), out.stdout, String::from_utf8_lossy(&out.stderr).to_string())
9 +
    let out = Command::new(env!("CARGO_BIN_EXE_oxy"))
10 +
        .arg("--plain")
11 +
        .args(args)
12 +
        .current_dir(dir)
13 +
        .env("OXY_HOME", home)
14 +
        .env("NO_COLOR", "1")
15 +
        .output()
16 +
        .unwrap();
17 +
    (
18 +
        out.status.code().unwrap_or(-1),
19 +
        out.stdout,
20 +
        String::from_utf8_lossy(&out.stderr).to_string(),
21 +
    )
11 22
}
12 23
13 24
fn json(dir: &Path, home: &Path, args: &[&str]) -> Value {
14 25
    let (code, out, err) = oxy(dir, home, args);
15 -
    assert!(code == 0 || code == 2 || code == 1, "oxy {args:?} exited {code}: {err}");
16 -
    serde_json::from_slice(&out).unwrap_or_else(|e| panic!("oxy {args:?} is not JSON: {e}\n{}", String::from_utf8_lossy(&out)))
26 +
    assert!(
27 +
        code == 0 || code == 2 || code == 1,
28 +
        "oxy {args:?} exited {code}: {err}"
29 +
    );
30 +
    serde_json::from_slice(&out).unwrap_or_else(|e| {
31 +
        panic!(
32 +
            "oxy {args:?} is not JSON: {e}\n{}",
33 +
            String::from_utf8_lossy(&out)
34 +
        )
35 +
    })
17 36
}
18 37
19 38
fn write(dir: &Path, rel: &str, content: &str) {
…
8 unchanged lines
28 47
    let home = tmp.path().join("home");
29 48
    std::fs::create_dir_all(&home).unwrap();
30 49
    let origin = tmp.path().join("origin");
31 -
    assert_eq!(oxy(tmp.path(), &home, &["init", origin.to_str().unwrap()]).0, 0);
50 +
    assert_eq!(
51 +
        oxy(tmp.path(), &home, &["init", origin.to_str().unwrap()]).0,
52 +
        0
53 +
    );
32 54
    let repo = tmp.path().join("repo");
33 -
    assert_eq!(oxy(tmp.path(), &home, &["clone", origin.to_str().unwrap(), repo.to_str().unwrap()]).0, 0);
55 +
    assert_eq!(
56 +
        oxy(
57 +
            tmp.path(),
58 +
            &home,
59 +
            &["clone", origin.to_str().unwrap(), repo.to_str().unwrap()]
60 +
        )
61 +
        .0,
62 +
        0
63 +
    );
34 64
    let r = repo.as_path();
35 -
    assert_eq!(oxy(r, &home, &["identity", "create", "--name", "Ada", "--email", "[email protected]"]).0, 0);
36 -
    assert_eq!(oxy(r, &home, &["config", "--user", "core.sign", "change"]).0, 0);
65 +
    assert_eq!(
66 +
        oxy(
67 +
            r,
68 +
            &home,
69 +
            &[
70 +
                "identity",
71 +
                "create",
72 +
                "--name",
73 +
                "Ada",
74 +
                "--email",
75 +
                "[email protected]"
76 +
            ]
77 +
        )
78 +
        .0,
79 +
        0
80 +
    );
81 +
    assert_eq!(
82 +
        oxy(r, &home, &["config", "--user", "core.sign", "change"]).0,
83 +
        0
84 +
    );
37 85
38 86
    let v = json(r, &home, &["version", "--json"]);
39 87
    assert_eq!(v["protocol"], 1);
…
23 unchanged lines
63 111
    let hunks = json(r, &home, &["hunks", "--json"]);
64 112
    let hunks = hunks.as_array().unwrap();
65 113
    assert!(hunks.len() >= 3, "{hunks:?}");
66 -
    let b_hunk = hunks.iter().find(|h| h["path"] == "b.txt").unwrap()["index"].as_u64().unwrap();
114 +
    let b_hunk = hunks.iter().find(|h| h["path"] == "b.txt").unwrap()["index"]
115 +
        .as_u64()
116 +
        .unwrap();
67 117
    // Commit only b's hunk; discard c by path; then discard a's remaining hunk.
68 -
    assert_eq!(oxy(r, &home, &["commit", "-m", "b only", "--hunks", &b_hunk.to_string()]).0, 0);
118 +
    assert_eq!(
119 +
        oxy(
120 +
            r,
121 +
            &home,
122 +
            &["commit", "-m", "b only", "--hunks", &b_hunk.to_string()]
123 +
        )
124 +
        .0,
125 +
        0
126 +
    );
69 127
    assert_eq!(oxy(r, &home, &["discard", "c.txt"]).0, 0);
70 128
    assert!(!r.join("c.txt").exists());
71 129
    let hunks = json(r, &home, &["hunks", "--json"]);
72 -
    let a_hunk = hunks.as_array().unwrap().iter().find(|h| h["path"] == "a.txt").unwrap()["index"].as_u64().unwrap();
73 -
    assert_eq!(oxy(r, &home, &["discard", "--hunks", &a_hunk.to_string()]).0, 0);
74 -
    assert_eq!(std::fs::read_to_string(r.join("a.txt")).unwrap(), "1\n2\n3\n");
130 +
    let a_hunk = hunks
131 +
        .as_array()
132 +
        .unwrap()
133 +
        .iter()
134 +
        .find(|h| h["path"] == "a.txt")
135 +
        .unwrap()["index"]
136 +
        .as_u64()
137 +
        .unwrap();
138 +
    assert_eq!(
139 +
        oxy(r, &home, &["discard", "--hunks", &a_hunk.to_string()]).0,
140 +
        0
141 +
    );
142 +
    assert_eq!(
143 +
        std::fs::read_to_string(r.join("a.txt")).unwrap(),
144 +
        "1\n2\n3\n"
145 +
    );
75 146
    let st = json(r, &home, &["status", "--json"]);
76 147
    assert_eq!(st["clean"], true, "{st}");
77 148
…
16 unchanged lines
94 165
    let show = json(r, &home, &["show", "--json", &format!("#{}", &hash[..12])]);
95 166
    assert_eq!(show["files"][0]["path"], "b.txt");
96 167
    let verify = json(r, &home, &["verify", "--json"]);
97 -
    assert!(verify.as_array().unwrap().iter().all(|v| v["status"] == "signed"));
168 +
    assert!(verify
169 +
        .as_array()
170 +
        .unwrap()
171 +
        .iter()
172 +
        .all(|v| v["status"] == "signed"));
98 173
    let ops = json(r, &home, &["op", "log", "--json"]);
99 174
    assert!(ops[0]["current"] == true && ops.as_array().unwrap().len() > 3);
100 175
    assert_eq!(oxy(r, &home, &["bookmark", "create", "feature"]).0, 0);
101 176
    let bms = json(r, &home, &["bookmark", "list", "--json"]);
102 -
    assert_eq!(bms.as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::<Vec<_>>(), vec!["feature", "main"]);
177 +
    assert_eq!(
178 +
        bms.as_array()
179 +
            .unwrap()
180 +
            .iter()
181 +
            .map(|b| b["name"].as_str().unwrap())
182 +
            .collect::<Vec<_>>(),
183 +
        vec!["feature", "main"]
184 +
    );
103 185
    let remotes = json(r, &home, &["remote", "list", "--json"]);
104 186
    assert_eq!(remotes[0]["name"], "origin");
105 187
    let push = json(r, &home, &["push", "--json"]);
…
3 unchanged lines
109 191
    let pull = json(r, &home, &["pull", "--json"]);
110 192
    assert_eq!(pull["changed"], false);
111 193
    assert_eq!(json(r, &home, &["resolve", "--json"])["conflicted"], false);
112 -
    assert!(json(r, &home, &["submodule", "status", "--json"]).as_array().unwrap().is_empty());
194 +
    assert!(json(r, &home, &["submodule", "status", "--json"])
195 +
        .as_array()
196 +
        .unwrap()
197 +
        .is_empty());
113 198
    assert!(json(r, &home, &["bisect", "status", "--json"]).is_null());
114 199
    assert!(json(r, &home, &["policy", "show", "--json"]).is_null());
115 200
    assert_eq!(oxy(r, &home, &["policy", "init"]).0, 0);
116 201
    let policy = json(r, &home, &["policy", "show", "--json"]);
117 202
    assert_eq!(policy["roots"][0]["name"], "Ada");
118 -
    assert_eq!(oxy(r, &home, &["tag", "create", "v1", "-m", "first tag"]).0, 0);
203 +
    assert_eq!(
204 +
        oxy(r, &home, &["tag", "create", "v1", "-m", "first tag"]).0,
205 +
        0
206 +
    );
119 207
    let tags = json(r, &home, &["tag", "list", "--json"]);
120 -
    assert_eq!((tags[0]["name"].as_str(), tags[0]["message"].as_str()), (Some("v1"), Some("first tag")));
208 +
    assert_eq!(
209 +
        (tags[0]["name"].as_str(), tags[0]["message"].as_str()),
210 +
        (Some("v1"), Some("first tag"))
211 +
    );
121 212
    let ws = json(r, &home, &["workspace", "list", "--json"]);
122 -
    assert_eq!((ws.as_array().unwrap().len(), ws[0]["current"].as_bool()), (1, Some(true)));
213 +
    assert_eq!(
214 +
        (ws.as_array().unwrap().len(), ws[0]["current"].as_bool()),
215 +
        (1, Some(true))
216 +
    );
123 217
}