OXYGENHUB
# 02f1b14af1e2 oxygen / crates / oxy-core / src / repo.rs
110.1 KiB · Rust · last changed Import the Oxygen specification and the first Rust implementation (root change) 1 month ago source blame history raw
1
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2
//! log, and the working-copy change (spec §12, §13.1).
3
//!
4
//! Not yet implemented from the spec: trust policy (every remote is
5
//! "unverified", so unsigned pushes are accepted), packs, checkpoints,
6
//! multiple workspaces, and network transports (only local paths). Changes are signed individually when `core.sign` is
7
//! `change`; the default `seal` leaves them unsigned until push exists.
8
9
use crate::attest::{Attest, AttestBody};
10
use crate::diff::{ranges, split_lines, text_edits};
11
use crate::protocol::{open_remote, Bundle, RefResult, RefUpdate, RemoteRef, SignedAttest};
12
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
13
use crate::identity::{self, Domain, Identity, SecretKey, Signature};
14
use crate::ids::{ChangeId, NodeId, WorkspaceId};
15
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp};
16
use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX};
17
use crate::state::{FileContent, Snapshot, State};
18
use crate::store::{Store, StoreError};
19
use crate::tree::OpKey;
20
use rand::RngCore;
21
use std::collections::{BTreeMap, BTreeSet, HashMap};
22
use std::fs;
23
use std::io::Write;
24
use std::path::{Path, PathBuf};
25
26
pub const OXY_DIR: &str = ".oxy";
27
pub const FORMAT_LINE: &str = "oxy repository format 2\n";
28
const FORMAT1_LINE: &str = "oxy repository format 1\n";
29
const BINARY_SNIFF: usize = 8 * 1024;
30
31
#[derive(Debug, thiserror::Error)]
32
pub enum RepoError {
33
    #[error("io: {0}")]
34
    Io(#[from] std::io::Error),
35
    #[error(transparent)]
36
    Store(#[from] StoreError),
37
    #[error(transparent)]
38
    Object(#[from] crate::objects::ObjectError),
39
    #[error(transparent)]
40
    State(#[from] crate::state::StateError),
41
    #[error("{0}")]
42
    Msg(String),
43
}
44
45
pub type Result<T> = std::result::Result<T, RepoError>;
46
47
fn fail<T>(s: impl Into<String>) -> Result<T> {
48
    Err(RepoError::Msg(s.into()))
49
}
50
51
/// Repository-local settings (`.oxy/config`, `key = value` lines).
52
#[derive(Clone, Debug, PartialEq, Eq)]
53
pub struct Config {
54
    pub author_name: String,
55
    pub author_email: String,
56
    /// The KeyId of the author's identity, once `create_identity` has run.
57
    pub author_key: Option<KeyId>,
58
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
59
    /// push-time seals (`false`, the spec default).
60
    pub sign_changes: bool,
61
    pub bookmark: String,
62
    /// `remote.<name>.url`.
63
    pub remotes: BTreeMap<String, String>,
64
    /// `remote.<name>.token`: bearer token for HTTP pushes.
65
    pub remote_tokens: BTreeMap<String, String>,
66
}
67
68
impl Config {
69
    fn default_for_user() -> Config {
70
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
71
        Config {
72
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
73
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
74
            author_key: None,
75
            sign_changes: false,
76
            bookmark: "main".into(),
77
            remotes: BTreeMap::new(),
78
            remote_tokens: BTreeMap::new(),
79
        }
80
    }
81
82
    /// Set a key as `oxy config` does; returns false for unknown keys.
83
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
84
        match key {
85
            "author.name" => self.author_name = value.into(),
86
            "author.email" => self.author_email = value.into(),
87
            "author.key" => {
88
                self.author_key = if value.is_empty() {
89
                    None
90
                } else {
91
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
92
                }
93
            }
94
            "core.sign" => {
95
                self.sign_changes = match value {
96
                    "change" => true,
97
                    "seal" => false,
98
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
99
                }
100
            }
101
            "workspace.bookmark" => self.bookmark = value.into(),
102
            k if k.starts_with("remote.") && (k.ends_with(".url") || k.ends_with(".token")) => {
103
                let (name, is_url) = match k.strip_suffix(".url") {
104
                    Some(n) => (&n["remote.".len()..], true),
105
                    None => (&k["remote.".len()..k.len() - ".token".len()], false),
106
                };
107
                if name.is_empty() || name.contains('/') {
108
                    return fail(format!("invalid remote name in {key:?}"));
109
                }
110
                let map = if is_url { &mut self.remotes } else { &mut self.remote_tokens };
111
                if value.is_empty() {
112
                    map.remove(name);
113
                    if is_url {
114
                        self.remote_tokens.remove(name);
115
                    }
116
                } else {
117
                    map.insert(name.to_string(), value.into());
118
                }
119
            }
120
            _ => return fail(format!("unknown configuration key {key:?}")),
121
        }
122
        Ok(())
123
    }
124
125
    fn load(path: &Path) -> Result<Config> {
126
        let mut c = Config::default_for_user();
127
        for line in fs::read_to_string(path)?.lines() {
128
            let line = line.trim();
129
            if line.is_empty() || line.starts_with('#') {
130
                continue;
131
            }
132
            let Some((k, v)) = line.split_once('=') else { continue };
133
            let v = v.trim().trim_matches('"').to_string();
134
            let _ = c.set(k.trim(), &v);
135
        }
136
        Ok(c)
137
    }
138
139
    fn save(&self, path: &Path) -> Result<()> {
140
        let mut text = format!(
141
            "author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n",
142
            self.author_name,
143
            self.author_email,
144
            self.author_key.map(|k| k.to_hex()).unwrap_or_default(),
145
            if self.sign_changes { "change" } else { "seal" },
146
            self.bookmark
147
        );
148
        for (name, url) in &self.remotes {
149
            text.push_str(&format!("remote.{name}.url = \"{url}\"\n"));
150
            if let Some(t) = self.remote_tokens.get(name) {
151
                text.push_str(&format!("remote.{name}.token = \"{t}\"\n"));
152
            }
153
        }
154
        write_atomic(path, text.as_bytes())
155
    }
156
}
157
158
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
159
    let tmp = path.with_extension("tmp");
160
    {
161
        let mut f = fs::File::create(&tmp)?;
162
        f.write_all(data)?;
163
        f.sync_all()?;
164
    }
165
    fs::rename(&tmp, path)?;
166
    Ok(())
167
}
168
169
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
170
pub enum ChangeStatus {
171
    Added,
172
    Modified,
173
    Removed,
174
    ModeChanged,
175
    /// The file is conflicted in the base state and still contains conflict markers.
176
    ConflictEdited,
177
    /// The file was conflicted in the base state and the working copy resolves it.
178
    Resolved,
179
}
180
181
/// Lines that mark a conflict region in a materialized file (spec §9.5).
182
pub fn has_conflict_markers(content: &[u8]) -> bool {
183
    split_lines(content).iter().any(|l| {
184
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
185
    })
186
}
187
188
#[derive(Clone, Debug, PartialEq, Eq)]
189
pub struct PathStatus {
190
    pub path: String,
191
    pub status: ChangeStatus,
192
}
193
194
/// Result of snapshotting the working copy.
195
pub struct SnapshotResult {
196
    pub wc_hash: ChangeHash,
197
    pub wc: Change,
198
    /// The base state (closure of the working-copy change's dependencies).
199
    pub base: State,
200
    pub base_snapshot: Snapshot,
201
    pub statuses: Vec<PathStatus>,
202
    /// Whether the snapshot rewrote the working-copy change.
203
    pub changed: bool,
204
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
205
    pub resolves: bool,
206
}
207
208
pub struct LogEntry {
209
    pub hash: ChangeHash,
210
    pub change: Change,
211
    pub is_working_copy: bool,
212
    pub is_head: bool,
213
}
214
215
struct FsEntry {
216
    kind: NodeKind,
217
    exec: bool,
218
    content: Vec<u8>,
219
}
220
221
pub struct Repo {
222
    root: PathBuf,
223
    oxy: PathBuf,
224
    store: Store,
225
    workspace: WorkspaceId,
226
    keys_dir: PathBuf,
227
    pub config: Config,
228
}
229
230
/// Where secret keys live: `$OXY_HOME/keys`, else `$XDG_CONFIG_HOME/oxy/keys`,
231
/// else `~/.config/oxy/keys`.
232
pub fn default_keys_dir() -> PathBuf {
233
    if let Ok(h) = std::env::var("OXY_HOME") {
234
        return PathBuf::from(h).join("keys");
235
    }
236
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
237
        return PathBuf::from(x).join("oxy").join("keys");
238
    }
239
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
240
    PathBuf::from(home).join(".config").join("oxy").join("keys")
241
}
242
243
/// Outcome of verifying the signatures on a change (spec §14.3, §14.4).
244
#[derive(Clone, Debug, PartialEq, Eq)]
245
pub enum SignatureStatus {
246
    Unsigned,
247
    Valid { key: KeyId, name: String, email: String, sealed: bool },
248
    Invalid(String),
249
}
250
251
pub fn is_binary(content: &[u8]) -> bool {
252
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
253
}
254
255
impl Repo {
256
    // ------------------------------------------------------------ lifecycle
257
258
    pub fn init(root: &Path) -> Result<Repo> {
259
        let mut id = [0u8; 16];
260
        rand::rngs::OsRng.fill_bytes(&mut id);
261
        Self::init_with_id(root, id)
262
    }
263
264
    /// Initialize a repository with a given repository ID (clones share the
265
    /// ID of their origin, spec §14.4).
266
    pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
267
        fs::create_dir_all(root)?;
268
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
269
        let oxy = root.join(OXY_DIR);
270
        if oxy.exists() {
271
            return fail(format!("{} is already an oxy repository", root.display()));
272
        }
273
        fs::create_dir_all(oxy.join("ops"))?;
274
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
275
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
276
        let config = Config::default_for_user();
277
        config.save(&oxy.join("config"))?;
278
        let workspace = WorkspaceId::random();
279
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
280
        let store = Store::open(oxy.join("objects"))?;
281
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), config };
282
283
        let wc = repo.new_working_copy(vec![], "");
284
        let wc_hash = repo.store.put_change(&wc)?;
285
        let mut view = View::default();
286
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
287
        view.workspaces.insert(workspace, wc_hash);
288
        repo.record_op(&[], "oxy init", view)?;
289
        Ok(repo)
290
    }
291
292
    /// Open the repository containing `start` (searching upwards).
293
    pub fn open(start: &Path) -> Result<Repo> {
294
        let start = start.canonicalize()?;
295
        let mut dir: Option<&Path> = Some(&start);
296
        while let Some(d) = dir {
297
            let oxy = d.join(OXY_DIR);
298
            if oxy.is_dir() {
299
                let format = fs::read_to_string(oxy.join("format"))?;
300
                let config = Config::load(&oxy.join("config"))?;
301
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
302
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
303
                let store = Store::open(oxy.join("objects"))?;
304
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config };
305
                if format == FORMAT1_LINE {
306
                    repo.migrate_format1()?;
307
                } else if format != FORMAT_LINE {
308
                    return fail(format!("unsupported repository format: {}", format.trim()));
309
                }
310
                return Ok(repo);
311
            }
312
            dir = d.parent();
313
        }
314
        fail(format!("no oxy repository found at or above {}", start.display()))
315
    }
316
317
    /// Rewrite a format-1 operation log, whose bookmarks were head lists,
318
    /// into format 2, where each bookmark names a `state` object.
319
    fn migrate_format1(&self) -> Result<()> {
320
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
321
        let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
322
        // Collect the chain root-first.
323
        let mut chain: Vec<Format1Op> = Vec::new();
324
        let mut cur = Some(head);
325
        while let Some(r) = cur {
326
            let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?;
327
            cur = op.parents.first().copied();
328
            chain.push(op);
329
        }
330
        chain.reverse();
331
        let mut new_parent: Option<ObjectRef> = None;
332
        for old in chain {
333
            let mut view = View::default();
334
            for (name, heads) in &old.view.bookmarks {
335
                view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
336
            }
337
            view.workspaces = old.view.workspaces.clone();
338
            let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view };
339
            new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?);
340
        }
341
        write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?;
342
        write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?;
343
        Ok(())
344
    }
345
346
    /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use.
347
    pub fn id(&self) -> Result<[u8; 16]> {
348
        let path = self.oxy.join("id");
349
        match fs::read_to_string(&path) {
350
            Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())),
351
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
352
                let mut id = [0u8; 16];
353
                rand::rngs::OsRng.fill_bytes(&mut id);
354
                write_atomic(&path, hex::encode(id).as_bytes())?;
355
                Ok(id)
356
            }
357
            Err(e) => Err(e.into()),
358
        }
359
    }
360
361
    pub fn root(&self) -> &Path {
362
        &self.root
363
    }
364
    pub fn store(&self) -> &Store {
365
        &self.store
366
    }
367
    pub fn workspace(&self) -> WorkspaceId {
368
        self.workspace
369
    }
370
371
    /// Use a different secret key directory (tests, or `--keys-dir`).
372
    pub fn with_keys_dir(mut self, dir: impl Into<PathBuf>) -> Repo {
373
        self.keys_dir = dir.into();
374
        self
375
    }
376
377
    pub fn save_config(&self) -> Result<()> {
378
        self.config.save(&self.oxy.join("config"))
379
    }
380
381
    fn author(&self) -> KeyId {
382
        self.config.author_key.unwrap_or(ObjectRef::ZERO)
383
    }
384
385
    // ------------------------------------------------------------ identity
386
387
    /// Generate a composite key pair, store its identity object, keep the
388
    /// secret in the key directory, and make it this repository's author.
389
    pub fn create_identity(&mut self, name: &str, email: &str) -> Result<(KeyId, Identity)> {
390
        let sk = SecretKey::generate();
391
        let id = sk.identity(name, email, Hlc::now().wall_ms);
392
        let key = self.store.put_identity(&id)?;
393
        fs::create_dir_all(&self.keys_dir)?;
394
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
395
        write_atomic(&path, &sk.encode(&key))?;
396
        #[cfg(unix)]
397
        {
398
            use std::os::unix::fs::PermissionsExt;
399
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
400
        }
401
        self.config.author_name = name.into();
402
        self.config.author_email = email.into();
403
        self.config.author_key = Some(key);
404
        self.save_config()?;
405
        Ok((key, id))
406
    }
407
408
    /// The secret key for `author.key`, if configured and present on disk.
409
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
410
        let Some(key) = self.config.author_key else { return Ok(None) };
411
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
412
        match fs::read(&path) {
413
            Ok(b) => {
414
                let (k, sk) = SecretKey::decode(&b)?;
415
                if k != key {
416
                    return fail(format!("{} holds a different key than its name", path.display()));
417
                }
418
                Ok(Some((k, sk)))
419
            }
420
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
421
            Err(e) => Err(e.into()),
422
        }
423
    }
424
425
    pub fn identity(&self, key: &KeyId) -> Result<Identity> {
426
        Ok(self.store.get_identity(key)?)
427
    }
428
429
    /// Sign a stored change with the configured secret key.
430
    pub fn sign_change(&self, hash: &ChangeHash) -> Result<Signature> {
431
        let Some((key, sk)) = self.secret_key()? else {
432
            return fail("no secret key: run `oxy identity create` first");
433
        };
434
        let sig = sk.sign(Domain::Change, &key, hash);
435
        self.store.put_signature(hash, &sig)?;
436
        Ok(sig)
437
    }
438
439
    /// Check the signatures on a change against the author-or-delegate rule.
440
    pub fn verify_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
441
        let change = self.store.get_change(hash)?;
442
        let sigs = self.store.signatures(hash)?;
443
        if sigs.is_empty() {
444
            return Ok(SignatureStatus::Unsigned);
445
        }
446
        let mut last_err = String::new();
447
        for sig in &sigs {
448
            match self.check_signer(&change, sig, Domain::Change, hash) {
449
                Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false }),
450
                Err(e) => last_err = e,
451
            }
452
        }
453
        Ok(SignatureStatus::Invalid(last_err))
454
    }
455
456
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
457
    fn check_signer(&self, change: &Change, sig: &Signature, domain: Domain, target: &ObjectRef) -> std::result::Result<Identity, String> {
458
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
459
        let authorised =
460
            sig.key == change.author || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
461
        if !authorised {
462
            return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12)));
463
        }
464
        identity::verify(&id, sig, domain, target).map_err(|e| e.to_string())?;
465
        Ok(id)
466
    }
467
468
    /// Like `verify_change`, but a change with no valid individual signature
469
    /// is also authenticated by a valid seal covering it (spec §14.4).
470
    pub fn authenticate_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
471
        let direct = self.verify_change(hash)?;
472
        if matches!(direct, SignatureStatus::Valid { .. }) {
473
            return Ok(direct);
474
        }
475
        let change = self.store.get_change(hash)?;
476
        let mut last_err = match &direct {
477
            SignatureStatus::Invalid(e) => e.clone(),
478
            _ => String::new(),
479
        };
480
        for seal_ref in self.store.seals_for(hash)? {
481
            let attest = self.store.get_attest(&seal_ref)?;
482
            let AttestBody::Seal { hashes } = &attest.body else { continue };
483
            if !hashes.contains(hash) {
484
                continue;
485
            }
486
            for sig in self.store.signatures(&seal_ref)? {
487
                match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) {
488
                    Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true }),
489
                    Err(e) => last_err = e,
490
                }
491
            }
492
        }
493
        if last_err.is_empty() { Ok(SignatureStatus::Unsigned) } else { Ok(SignatureStatus::Invalid(last_err)) }
494
    }
495
496
    /// Store and sign an attestation with the configured key.
497
    fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> {
498
        let Some((key, sk)) = self.secret_key()? else {
499
            return fail("no secret key: run `oxy identity create` first");
500
        };
501
        let r = self.store.put_attest(attest)?;
502
        let sig = sk.sign(Domain::Attest, &key, &r);
503
        self.store.put_signature(&r, &sig)?;
504
        if let AttestBody::Seal { hashes } = &attest.body {
505
            for h in hashes {
506
                self.store.index_seal(h, &r)?;
507
            }
508
        }
509
        Ok(SignedAttest { attest: attest.clone(), sigs: vec![sig] })
510
    }
511
512
    // ------------------------------------------------------------ serving (spec §16)
513
514
    fn ref_attest_path(&self, bookmark: &str) -> PathBuf {
515
        self.oxy.join("attest").join(bookmark)
516
    }
517
518
    /// The latest reference attestation stored for a bookmark.
519
    pub fn latest_ref_attest(&self, bookmark: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
520
        match fs::read_to_string(self.ref_attest_path(bookmark)) {
521
            Ok(h) => {
522
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?;
523
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
524
            }
525
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
526
            Err(e) => Err(e.into()),
527
        }
528
    }
529
530
    fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
531
        fs::create_dir_all(self.oxy.join("attest"))?;
532
        write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes())
533
    }
534
535
    pub fn remote_refs(&self) -> Result<Vec<RemoteRef>> {
536
        let view = self.view()?;
537
        let mut out = Vec::new();
538
        for (name, state) in &view.bookmarks {
539
            out.push(RemoteRef {
540
                name: name.clone(),
541
                state: *state,
542
                heads: self.store.get_state(state)?.heads,
543
                attestation: self.latest_ref_attest(name)?.map(|(_, a)| a),
544
            });
545
        }
546
        Ok(out)
547
    }
548
549
    /// Every change reachable from `heads` that is in the store.
550
    fn closure_in_store(&self, heads: &[ChangeHash]) -> Result<BTreeSet<ChangeHash>> {
551
        let mut seen = BTreeSet::new();
552
        let mut stack: Vec<ChangeHash> = heads.to_vec();
553
        while let Some(h) = stack.pop() {
554
            if seen.contains(&h) || !self.store.has(&h) {
555
                continue;
556
            }
557
            seen.insert(h);
558
            stack.extend(self.store.get_change(&h)?.deps);
559
        }
560
        Ok(seen)
561
    }
562
563
    /// `closure(want) \ closure(have)`, dependencies first (spec §16.2).
564
    pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
565
        let have = self.closure_in_store(have)?;
566
        let mut out = Vec::new();
567
        let mut done: BTreeSet<ChangeHash> = have.clone();
568
        // Iterative post-order DFS.
569
        let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect();
570
        while let Some((h, expanded)) = stack.pop() {
571
            if done.contains(&h) {
572
                continue;
573
            }
574
            if !self.store.has(&h) {
575
                return fail(format!("change {} is not in this repository", h.short(12)));
576
            }
577
            if expanded {
578
                done.insert(h);
579
                out.push(h);
580
                continue;
581
            }
582
            stack.push((h, true));
583
            for d in self.store.get_change(&h)?.deps {
584
                if !done.contains(&d) {
585
                    stack.push((d, false));
586
                }
587
            }
588
        }
589
        Ok(out)
590
    }
591
592
    /// Bundle changes (in the given dependency order) with their signatures,
593
    /// seals, identities, blobs, chunks.
594
    pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> {
595
        let mut bundle = Bundle::default();
596
        let mut objects: BTreeSet<ObjectRef> = BTreeSet::new();
597
        let mut seals: BTreeSet<ObjectRef> = BTreeSet::new();
598
        let add_object = |r: &ObjectRef, objects: &mut BTreeSet<ObjectRef>| {
599
            if !r.is_zero() && self.store.has(r) {
600
                objects.insert(*r);
601
            }
602
        };
603
        for h in changes {
604
            let c = self.store.get_change(h)?;
605
            let sigs = self.store.signatures(h)?;
606
            add_object(&c.author, &mut objects);
607
            for s in &sigs {
608
                add_object(&s.key, &mut objects);
609
            }
610
            for e in &c.edits {
611
                if let Edit::Replace { old, new, .. } = e {
612
                    for b in [old, new].into_iter().flatten() {
613
                        if self.store.has(b) {
614
                            objects.insert(*b);
615
                            if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) {
616
                                objects.extend(blob.chunks.iter().copied());
617
                            }
618
                        }
619
                    }
620
                }
621
            }
622
            for s in self.store.seals_for(h)? {
623
                seals.insert(s);
624
            }
625
            bundle.changes.push((c, sigs));
626
        }
627
        for r in seals {
628
            let attest = self.store.get_attest(&r)?;
629
            let sigs = self.store.signatures(&r)?;
630
            for s in &sigs {
631
                add_object(&s.key, &mut objects);
632
            }
633
            bundle.attests.push(SignedAttest { attest, sigs });
634
        }
635
        for r in objects {
636
            let (ty, body) = self.store.get(&r)?;
637
            bundle.objects.push((ty, body));
638
        }
639
        Ok(bundle)
640
    }
641
642
    /// Store a bundle's contents. With `strict`, every change must
643
    /// authenticate (signature or seal by its author or a delegate).
644
    pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> {
645
        for (ty, body) in &bundle.objects {
646
            match ty {
647
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => {
648
                    self.store.put(*ty, body)?;
649
                }
650
                other => return fail(format!("bundle may not carry {} objects", other.name())),
651
            }
652
        }
653
        for sa in &bundle.attests {
654
            let r = self.store.put_attest(&sa.attest)?;
655
            for sig in &sa.sigs {
656
                self.store.put_signature(&r, sig)?;
657
            }
658
            if let AttestBody::Seal { hashes } = &sa.attest.body {
659
                for h in hashes {
660
                    self.store.index_seal(h, &r)?;
661
                }
662
            }
663
        }
664
        let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect();
665
        let mut imported = Vec::new();
666
        for (c, sigs) in &bundle.changes {
667
            let h = c.hash();
668
            for d in &c.deps {
669
                if !self.store.has(d) && !incoming.contains(d) {
670
                    return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12)));
671
                }
672
            }
673
            self.store.put_change(c)?;
674
            for sig in sigs {
675
                self.store.put_signature(&h, sig)?;
676
            }
677
            imported.push(h);
678
        }
679
        if strict {
680
            for h in &imported {
681
                if !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
682
                    return fail(format!("change {} is not authenticated by its author", h.short(12)));
683
                }
684
            }
685
        }
686
        Ok(imported)
687
    }
688
689
    /// Verify a reference attestation against an update it claims to describe.
690
    fn check_ref_attest(&self, sa: &SignedAttest, name: &str, old_state: &ObjectRef, new_state: &ObjectRef, last_serial: Option<u64>) -> std::result::Result<u64, String> {
691
        let AttestBody::Ref { name: n, old_state: o, new_state: nw, serial } = &sa.attest.body else {
692
            return Err("attestation is not a ref attestation".into());
693
        };
694
        if n != name || o != old_state || nw != new_state {
695
            return Err("attestation does not describe this update".into());
696
        }
697
        if sa.attest.repo != self.id().map_err(|e| e.to_string())? {
698
            return Err("attestation is for a different repository".into());
699
        }
700
        if let Some(last) = last_serial {
701
            if *serial <= last {
702
                return Err(format!("attestation serial {serial} is not greater than the last seen {last}"));
703
            }
704
        }
705
        let r = sa.attest.hash();
706
        let sig = sa.sigs.first().ok_or("attestation carries no signature")?;
707
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
708
        identity::verify(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
709
        Ok(*serial)
710
    }
711
712
    /// Accept a push (spec §16.3). Without a trust policy every repository
713
    /// is unverified, so unsigned changes and unattested updates are accepted;
714
    /// attestations that are present must still be valid.
715
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
716
        self.import_bundle(&bundle, false)?;
717
        let (op_ref, op) = self.head_op()?;
718
        let mut view = op.view.clone();
719
        let mut results = Vec::new();
720
        let mut names = Vec::new();
721
        for u in updates {
722
            let outcome = self.apply_update(&mut view, &u);
723
            let (ok, reason) = match outcome {
724
                Ok(()) => (true, String::new()),
725
                Err(e) => (false, e),
726
            };
727
            if ok {
728
                names.push(u.name.clone());
729
            }
730
            results.push(RefResult { name: u.name, ok, reason });
731
        }
732
        if !names.is_empty() {
733
            self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?;
734
        }
735
        Ok(results)
736
    }
737
738
    fn apply_update(&self, view: &mut View, u: &RefUpdate) -> std::result::Result<(), String> {
739
        let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?;
740
        let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty);
741
        if current != u.old_state {
742
            return Err("stale: the remote bookmark has moved; pull first".into());
743
        }
744
        let new_state = self.store.get_state(&u.new_state).map_err(|_| "new state object is missing".to_string())?;
745
        for h in &new_state.heads {
746
            if !self.store.has(h) {
747
                return Err(format!("head {} is missing", h.short(12)));
748
            }
749
        }
750
        if !u.force {
751
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
752
            let closure = self.closure_in_store(&new_state.heads).map_err(|e| e.to_string())?;
753
            if let Some(h) = old_heads.iter().find(|h| !closure.contains(h)) {
754
                return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12)));
755
            }
756
        }
757
        if let Some(sa) = &u.attestation {
758
            let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body {
759
                AttestBody::Ref { serial, .. } => Some(serial),
760
                _ => None,
761
            });
762
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last)?;
763
            let r = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
764
            for sig in &sa.sigs {
765
                self.store.put_signature(&r, sig).map_err(|e| e.to_string())?;
766
            }
767
            self.set_ref_attest(&u.name, &r).map_err(|e| e.to_string())?;
768
        }
769
        view.bookmarks.insert(u.name.clone(), u.new_state);
770
        Ok(())
771
    }
772
773
    // ------------------------------------------------------------ client side
774
775
    fn remote_url(&self, name: &str) -> Result<String> {
776
        self.config.remotes.get(name).cloned().ok_or_else(|| RepoError::Msg(format!("no remote named {name:?}; add one with `oxy remote add`")))
777
    }
778
779
    /// Token for a remote: `remote.<name>.token`, else `$OXY_TOKEN`.
780
    fn remote_token(&self, name: &str) -> Option<String> {
781
        self.config.remote_tokens.get(name).cloned().or_else(|| std::env::var("OXY_TOKEN").ok())
782
    }
783
784
    fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> {
785
        open_remote(&self.remote_url(name)?, self.remote_token(name))
786
    }
787
788
    fn serial_path(&self, remote: &str, bookmark: &str) -> PathBuf {
789
        self.oxy.join("remotes").join(remote).join(format!("{bookmark}.serial"))
790
    }
791
792
    fn last_seen_serial(&self, remote: &str, bookmark: &str) -> Option<u64> {
793
        fs::read_to_string(self.serial_path(remote, bookmark)).ok().and_then(|s| s.trim().parse().ok())
794
    }
795
796
    fn record_serial(&self, remote: &str, bookmark: &str, serial: u64) -> Result<()> {
797
        let p = self.serial_path(remote, bookmark);
798
        fs::create_dir_all(p.parent().unwrap())?;
799
        write_atomic(&p, serial.to_string().as_bytes())
800
    }
801
802
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
803
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
804
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
805
        for st in view.bookmarks.values().chain(view.remotes.values().flat_map(|b| b.values())) {
806
            if let Ok(s) = self.store.get_state(st) {
807
                have.extend(s.heads);
808
            }
809
        }
810
        have.extend(view.workspaces.values().copied());
811
        Ok(have.into_iter().collect())
812
    }
813
814
    /// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2).
815
    pub fn fetch(&self, remote_name: &str) -> Result<Vec<(String, Vec<ChangeHash>, usize)>> {
816
        let remote = self.open_named_remote(remote_name)?;
817
        if remote.repo_id()? != self.id()? {
818
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
819
        }
820
        let (op_ref, op) = self.head_op()?;
821
        let mut view = op.view.clone();
822
        let have = self.known_heads(&view)?;
823
        let mut report = Vec::new();
824
        for r in remote.refs()? {
825
            let needed = remote.negotiate(&have, &r.heads)?;
826
            let bundle = remote.fetch(&needed)?;
827
            let imported = self.import_bundle(&bundle, false)?;
828
            for h in &r.heads {
829
                if !self.store.has(h) {
830
                    return fail(format!("remote did not send head {}", h.short(12)));
831
                }
832
            }
833
            let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
834
            if state != r.state {
835
                return fail(format!("remote state {} does not match its heads", r.state.short(12)));
836
            }
837
            if let Some(sa) = &r.attestation {
838
                let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else {
839
                    return fail("remote sent a non-ref attestation for a bookmark");
840
                };
841
                let (old_state, new_state) = (*old_state, *new_state);
842
                let serial = self
843
                    .check_ref_attest(sa, &r.name, &old_state, &new_state, self.last_seen_serial(remote_name, &r.name))
844
                    .map_err(|e| RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name)))?;
845
                if new_state != r.state {
846
                    return fail(format!("attestation for {}/{} does not cover its current state", remote_name, r.name));
847
                }
848
                let ar = self.store.put_attest(&sa.attest)?;
849
                for sig in &sa.sigs {
850
                    self.store.put_signature(&ar, sig)?;
851
                }
852
                self.record_serial(remote_name, &r.name, serial)?;
853
            }
854
            view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
855
            report.push((r.name, r.heads, imported.len()));
856
        }
857
        if view != op.view {
858
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
859
        }
860
        Ok(report)
861
    }
862
863
    /// Fetch, then take the union of the local bookmark's state with the
864
    /// remote's: no merge change, conflicts become reified (spec §9.4).
865
    pub fn pull(&self, remote_name: &str) -> Result<PullReport> {
866
        self.fetch(remote_name)?;
867
        let (op_ref, op) = self.head_op()?;
868
        let bookmark = self.config.bookmark.clone();
869
        let local = self.heads_of(&op.view, &bookmark)?;
870
        let Some(remote_state) = op.view.remotes.get(remote_name).and_then(|b| b.get(&bookmark)).copied() else {
871
            return Ok(PullReport { heads: local, changed: false, checked_out: false });
872
        };
873
        let remote_heads = self.store.get_state(&remote_state)?.heads;
874
        let mut all = local.clone();
875
        all.extend(remote_heads);
876
        let st = self.load_state(&all)?;
877
        let mut heads = st.maximal(&all);
878
        heads.sort();
879
        let mut sorted_local = local.clone();
880
        sorted_local.sort();
881
        if heads == sorted_local {
882
            return Ok(PullReport { heads, changed: false, checked_out: false });
883
        }
884
        let mut view = op.view.clone();
885
        view.bookmarks.insert(bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
886
        self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?;
887
        // Follow along if the working copy was clean and on the old heads.
888
        let snap = self.snapshot("oxy pull")?;
889
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
890
        let mut base = snap.base.maximal(&snap.wc.deps);
891
        base.sort();
892
        let mut checked_out = false;
893
        if clean && base == sorted_local {
894
            self.checkout(&heads, false, "oxy pull (checkout)")?;
895
            checked_out = true;
896
        }
897
        Ok(PullReport { heads, changed: true, checked_out })
898
    }
899
900
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
901
    /// changes and attesting the reference when a key is available.
902
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
903
        let remote = self.open_named_remote(remote_name)?;
904
        if remote.repo_id()? != self.id()? {
905
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
906
        }
907
        let (op_ref, op) = self.head_op()?;
908
        let bookmark = self.config.bookmark.clone();
909
        let local_heads = self.heads_of(&op.view, &bookmark)?;
910
        let refs = remote.refs()?;
911
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
912
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
913
            Some(r) => (
914
                r.state,
915
                r.heads.clone(),
916
                r.attestation.as_ref().and_then(|a| match a.attest.body {
917
                    AttestBody::Ref { serial, .. } => Some(serial),
918
                    _ => None,
919
                }),
920
            ),
921
            None => (empty, vec![], None),
922
        };
923
        let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?;
924
        if new_state == remote_state {
925
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
926
        }
927
        for h in &remote_heads {
928
            if !self.store.has(h) {
929
                return fail(format!("the remote has change {} which you do not; pull first", h.short(12)));
930
            }
931
        }
932
        if !force {
933
            let closure = self.closure_in_store(&local_heads)?;
934
            if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) {
935
                return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12)));
936
            }
937
        }
938
        let to_send = self.negotiate(&remote_heads, &local_heads)?;
939
        let mut bundle = self.bundle_for(&to_send)?;
940
        let key = self.secret_key()?.map(|(k, _)| k);
941
        if let Some(key) = key {
942
            // Seal this author's changes that carry no valid authenticator.
943
            let mut unsigned = Vec::new();
944
            for h in &to_send {
945
                let c = self.store.get_change(h)?;
946
                if c.author == key && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
947
                    unsigned.push(*h);
948
                }
949
            }
950
            for chunk in unsigned.chunks(crate::attest::MAX_SEAL_HASHES) {
951
                let sa = self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?;
952
                bundle.attests.push(sa);
953
            }
954
            bundle = self.bundle_for(&to_send)?;
955
        }
956
        bundle.objects.push((ObjectType::State, StateObject::new(local_heads.clone()).encode()));
957
        let attestation = match key {
958
            Some(_) => Some(self.sign_attest(&Attest {
959
                repo: self.id()?,
960
                hlc: Hlc::now(),
961
                body: AttestBody::Ref { name: bookmark.clone(), old_state: remote_state, new_state, serial: last_serial.unwrap_or(0) + 1 },
962
            })?),
963
            None => None,
964
        };
965
        let serial = attestation.as_ref().and_then(|a| match a.attest.body {
966
            AttestBody::Ref { serial, .. } => Some(serial),
967
            _ => None,
968
        });
969
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }])?;
970
        if results.iter().any(|r| r.ok) {
971
            let mut view = op.view.clone();
972
            view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state);
973
            self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?;
974
            if let Some(s) = serial {
975
                self.record_serial(remote_name, &bookmark, s)?;
976
            }
977
        }
978
        Ok(results)
979
    }
980
981
    /// Clone a remote into `dest`: same repository ID, `origin` remote,
982
    /// bookmark set to the remote's, working copy checked out.
983
    pub fn clone(url: &str, dest: &Path) -> Result<Repo> {
984
        let remote = open_remote(url, std::env::var("OXY_TOKEN").ok())?;
985
        let id = remote.repo_id()?;
986
        let mut repo = Repo::init_with_id(dest, id)?;
987
        repo.config.remotes.insert("origin".into(), url.to_string());
988
        repo.save_config()?;
989
        repo.fetch("origin")?;
990
        let (op_ref, op) = repo.head_op()?;
991
        let bookmark = repo.config.bookmark.clone();
992
        if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() {
993
            let heads = repo.store.get_state(&state)?.heads;
994
            let mut view = op.view.clone();
995
            view.bookmarks.insert(bookmark, state);
996
            repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?;
997
            repo.checkout(&heads, false, "oxy clone (checkout)")?;
998
        }
999
        Ok(repo)
1000
    }
1001
1002
1003
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
1004
        let mut c = Change {
1005
            change_id: ChangeId::random(),
1006
            deps,
1007
            author: self.author(),
1008
            hlc: Hlc::now(),
1009
            message: message.into(),
1010
            tree_ops: vec![],
1011
            edits: vec![],
1012
            kind: ChangeKind::WorkingCopy,
1013
        };
1014
        c.normalize();
1015
        c
1016
    }
1017
1018
    // ------------------------------------------------------------ operation log
1019
1020
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
1021
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
1022
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
1023
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
1024
        Ok((r, op))
1025
    }
1026
1027
    pub fn view(&self) -> Result<View> {
1028
        Ok(self.head_op()?.1.view)
1029
    }
1030
1031
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
1032
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
1033
        let r = self.store.put(ObjectType::Op, &op.encode())?;
1034
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
1035
        Ok(r)
1036
    }
1037
1038
    pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> {
1039
        let mut out = Vec::new();
1040
        let (mut r, mut op) = self.head_op()?;
1041
        loop {
1042
            let next = op.parents.first().copied();
1043
            out.push((r, op));
1044
            match next {
1045
                Some(p) => {
1046
                    r = p;
1047
                    op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
1048
                }
1049
                None => break,
1050
            }
1051
        }
1052
        Ok(out)
1053
    }
1054
1055
    /// Undo the last user command (spec §12.1). Operations that only recorded
1056
    /// a working-copy snapshot are skipped, since the files are still on disk
1057
    /// and the next snapshot recreates them.
1058
    pub fn undo(&self) -> Result<Op> {
1059
        let (_, mut op) = self.head_op()?;
1060
        while op.is_snapshot() {
1061
            let Some(parent) = op.parents.first() else { break };
1062
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
1063
        }
1064
        let Some(parent) = op.parents.first() else {
1065
            return fail("nothing to undo");
1066
        };
1067
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
1068
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
1069
        Ok(parent_op)
1070
    }
1071
1072
    /// The last operation that was not a snapshot.
1073
    pub fn last_command(&self) -> Result<Op> {
1074
        let (_, mut op) = self.head_op()?;
1075
        while op.is_snapshot() {
1076
            let Some(parent) = op.parents.first() else { break };
1077
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
1078
        }
1079
        Ok(op)
1080
    }
1081
1082
    // ------------------------------------------------------------ states
1083
1084
    /// Load the dependency closure of `heads` from the store.
1085
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
1086
        let mut state = State::new();
1087
        let mut stack: Vec<ChangeHash> = heads.to_vec();
1088
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
1089
        while let Some(h) = stack.pop() {
1090
            if !seen.insert(h) {
1091
                continue;
1092
            }
1093
            let c = self.store.get_change(&h)?;
1094
            stack.extend(c.deps.iter().copied());
1095
            state.add_unchecked(c);
1096
        }
1097
        state.validate()?;
1098
        Ok(state)
1099
    }
1100
1101
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
1102
        let view = self.view()?;
1103
        let h = *view
1104
            .workspaces
1105
            .get(&self.workspace)
1106
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
1107
        Ok((h, self.store.get_change(&h)?))
1108
    }
1109
1110
    /// Heads of a bookmark's state (spec §10.1).
1111
    pub fn heads_of(&self, view: &View, bookmark: &str) -> Result<Vec<ChangeHash>> {
1112
        match view.bookmarks.get(bookmark) {
1113
            Some(st) => Ok(self.store.get_state(st)?.heads),
1114
            None => Ok(vec![]),
1115
        }
1116
    }
1117
1118
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
1119
        self.heads_of(&self.view()?, &self.config.bookmark)
1120
    }
1121
1122
    // ------------------------------------------------------------ blobs
1123
1124
    fn blob_for(content: &[u8]) -> Blob {
1125
        Blob {
1126
            length: content.len() as u64,
1127
            chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(),
1128
        }
1129
    }
1130
1131
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
1132
        Self::blob_for(content).hash()
1133
    }
1134
1135
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
1136
        for c in crate::chunk::chunks(content) {
1137
            self.store.put_chunk(c)?;
1138
        }
1139
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
1140
    }
1141
1142
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
1143
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
1144
        let mut out = Vec::with_capacity(blob.length as usize);
1145
        for c in &blob.chunks {
1146
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
1147
        }
1148
        Ok(out)
1149
    }
1150
1151
    // ------------------------------------------------------------ working copy
1152
1153
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
1154
        let mut wb = ignore::WalkBuilder::new(&self.root);
1155
        wb.hidden(false)
1156
            .git_ignore(false)
1157
            .git_global(false)
1158
            .git_exclude(false)
1159
            .require_git(false)
1160
            .follow_links(false)
1161
            .add_custom_ignore_filename(".oxyignore");
1162
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
1163
        let mut out = BTreeMap::new();
1164
        for entry in wb.build() {
1165
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
1166
            let path = entry.path();
1167
            if path == self.root {
1168
                continue;
1169
            }
1170
            let rel = path.strip_prefix(&self.root).unwrap();
1171
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
1172
            let Some(ft) = entry.file_type() else { continue };
1173
            if ft.is_dir() {
1174
                continue;
1175
            }
1176
            if ft.is_symlink() {
1177
                let target = fs::read_link(path)?;
1178
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
1179
            } else if ft.is_file() {
1180
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
1181
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
1182
            }
1183
        }
1184
        Ok(out)
1185
    }
1186
1187
    /// Snapshot the working copy into the working-copy change (spec §12.3).
1188
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
1189
        let (op_ref, op) = self.head_op()?;
1190
        let (old_hash, old_wc) = self.working_copy()?;
1191
        let base = self.load_state(&old_wc.deps)?;
1192
        let base_snapshot = base.materialize();
1193
        let fs_entries = self.walk_working_copy()?;
1194
1195
        // Reuse the NodeIds the previous working-copy version assigned to new
1196
        // paths, so an unchanged working copy hashes identically (spec §12.3).
1197
        let reuse = added_node_paths(&old_wc, &base_snapshot);
1198
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
1199
        for (path, fs_entry) in &fs_entries {
1200
            match base_snapshot.files.get(path) {
1201
                None => b.add_path(self, path, fs_entry)?,
1202
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
1203
            }
1204
        }
1205
        for (path, expected) in &base_snapshot.files {
1206
            if fs_entries.contains_key(path) {
1207
                continue;
1208
            }
1209
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
1210
            if !still_needed {
1211
                b.remove_path(path, expected);
1212
            }
1213
        }
1214
1215
        let mut wc = Change {
1216
            change_id: old_wc.change_id,
1217
            deps: vec![],
1218
            author: self.author(),
1219
            hlc: old_wc.hlc,
1220
            message: old_wc.message.clone(),
1221
            tree_ops: b.tree_ops,
1222
            edits: b.edits,
1223
            kind: ChangeKind::WorkingCopy,
1224
        };
1225
        let base_heads = base.maximal(&old_wc.deps);
1226
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
1227
        deps.extend(referenced_changes(&wc, &base));
1228
        deps.extend(b.contributors.iter().copied());
1229
        wc.deps = deps.into_iter().collect();
1230
        wc.normalize();
1231
        let resolves = b.resolves;
1232
1233
        let mut statuses = b.statuses;
1234
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
1235
        // Empty snapshot: hash the candidate before storing; only record an op on change.
1236
        let new_hash = wc.hash();
1237
        let changed = new_hash != old_hash;
1238
        if changed {
1239
            self.store.put_change(&wc)?;
1240
            let mut view = op.view.clone();
1241
            view.workspaces.insert(self.workspace, new_hash);
1242
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
1243
        }
1244
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves })
1245
    }
1246
1247
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
1248
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
1249
        let snap = self.snapshot("oxy commit")?;
1250
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
1251
            return fail("nothing to commit: the working copy matches its base");
1252
        }
1253
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
1254
            return fail(format!("{} still contains conflict markers", s.path));
1255
        }
1256
        if message.trim().is_empty() {
1257
            return fail("a commit message is required");
1258
        }
1259
        let (op_ref, op) = self.head_op()?;
1260
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
1261
        if snap.resolves {
1262
            // A resolution depends on every change that contributed to the
1263
            // conflict (spec §8.1); those are already among the working copy's
1264
            // dependencies, so keep every one that is not merely a base head.
1265
            let heads: BTreeSet<ChangeHash> = snap.base.maximal(&snap.wc.deps).into_iter().collect();
1266
            deps.extend(snap.wc.deps.iter().filter(|d| !heads.contains(d) || touches_conflict(&snap)).copied());
1267
        }
1268
        for d in extra_deps {
1269
            if !snap.base.contains(d) {
1270
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
1271
            }
1272
            deps.insert(*d);
1273
        }
1274
        if self.config.sign_changes && self.secret_key()?.is_none() {
1275
            return fail("core.sign is \"change\" but no secret key is available for author.key");
1276
        }
1277
        let mut change = Change {
1278
            change_id: snap.wc.change_id,
1279
            deps: deps.into_iter().collect(),
1280
            author: self.author(),
1281
            hlc: Hlc::now(),
1282
            message: message.trim_end().to_string(),
1283
            tree_ops: snap.wc.tree_ops.clone(),
1284
            edits: snap.wc.edits.clone(),
1285
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
1286
        };
1287
        change.normalize();
1288
        let hash = self.store.put_change(&change)?;
1289
        if self.config.sign_changes {
1290
            self.sign_change(&hash)?;
1291
        }
1292
1293
        // New bookmark heads: maximal elements of old heads plus the new change.
1294
        // The working copy continues on its own base plus the new change, which
1295
        // is exactly what the files on disk contain; the bookmark may hold
1296
        // other heads (and conflicts) the working copy has not checked out.
1297
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
1298
        let old_base = snap.base.maximal(&snap.wc.deps);
1299
        let mut all: Vec<ChangeHash> = old_heads.clone();
1300
        all.extend(old_base.iter().copied());
1301
        all.push(hash);
1302
        let mut st = self.load_state(&all)?;
1303
        st.add(change)?;
1304
        let mut with_new = old_heads.clone();
1305
        with_new.push(hash);
1306
        let new_heads = st.maximal(&with_new);
1307
        let mut wc_base = old_base.clone();
1308
        wc_base.push(hash);
1309
        let wc_base = st.maximal(&wc_base);
1310
1311
        let wc = self.new_working_copy(wc_base, "");
1312
        let wc_hash = self.store.put_change(&wc)?;
1313
        let mut view = op.view.clone();
1314
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
1315
        view.workspaces.insert(self.workspace, wc_hash);
1316
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
1317
        Ok(hash)
1318
    }
1319
1320
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
1321
    /// the working directory is rewritten to that state and a fresh
1322
    /// working-copy change is started on it. The bookmark does not move.
1323
    pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> {
1324
        let snap = self.snapshot(command)?;
1325
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
1326
            return fail("the working copy has changes; commit them first or pass --discard");
1327
        }
1328
        let target_state = self.load_state(heads)?;
1329
        let heads = target_state.maximal(heads);
1330
        let target = target_state.materialize();
1331
        let current = self.walk_working_copy()?;
1332
        let mut report = CheckoutReport::default();
1333
1334
        // Remove tracked paths that the target does not have.
1335
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
1336
        for path in current.keys() {
1337
            if !target.files.contains_key(path) {
1338
                let full = self.safe_path(path)?;
1339
                fs::remove_file(&full)?;
1340
                report.removed += 1;
1341
                if let Some(parent) = full.parent() {
1342
                    dirs_to_prune.insert(parent.to_path_buf());
1343
                }
1344
            }
1345
        }
1346
        // Write target files that differ.
1347
        for (path, f) in &target.files {
1348
            match &f.content {
1349
                FileContent::Dir => continue,
1350
                FileContent::Text { bytes, .. } => {
1351
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec);
1352
                    if !same {
1353
                        self.write_file(path, bytes, f.mode.exec)?;
1354
                        report.written += 1;
1355
                    }
1356
                }
1357
                FileContent::Opaque { blobs } => {
1358
                    let Some(blob) = blobs.first() else { continue };
1359
                    let bytes = self.read_blob(blob)?;
1360
                    if f.kind == NodeKind::Symlink {
1361
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
1362
                        if !same {
1363
                            self.write_symlink(path, &bytes)?;
1364
                            report.written += 1;
1365
                        }
1366
                    } else {
1367
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec);
1368
                        if !same {
1369
                            self.write_file(path, &bytes, f.mode.exec)?;
1370
                            report.written += 1;
1371
                        }
1372
                    }
1373
                    if blobs.len() > 1 {
1374
                        for (i, b) in blobs.iter().enumerate().skip(1) {
1375
                            self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?;
1376
                        }
1377
                    }
1378
                }
1379
            }
1380
        }
1381
        // Prune directories left empty, deepest first.
1382
        for d in dirs_to_prune.iter().rev() {
1383
            let mut d = d.clone();
1384
            while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) {
1385
                fs::remove_dir(&d)?;
1386
                let Some(p) = d.parent() else { break };
1387
                d = p.to_path_buf();
1388
            }
1389
        }
1390
1391
        let (op_ref, op) = self.head_op()?;
1392
        let wc = self.new_working_copy(heads, "");
1393
        let wc_hash = self.store.put_change(&wc)?;
1394
        let mut view = op.view.clone();
1395
        view.workspaces.insert(self.workspace, wc_hash);
1396
        self.record_op(&[op_ref], command, view)?;
1397
        report.conflicted = target.is_conflicted();
1398
        Ok(report)
1399
    }
1400
1401
    /// The absolute path for a repository path, refusing anything that would
1402
    /// escape the workspace or write through a symlink (spec §7.7).
1403
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
1404
        let mut full = self.root.clone();
1405
        let comps: Vec<&str> = rel.split('/').collect();
1406
        for (i, comp) in comps.iter().enumerate() {
1407
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
1408
                return fail(format!("refusing unsafe path {rel:?}"));
1409
            }
1410
            if i == 0 && *comp == OXY_DIR {
1411
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
1412
            }
1413
            if i + 1 < comps.len() {
1414
                full.push(comp);
1415
                if let Ok(m) = fs::symlink_metadata(&full) {
1416
                    if m.file_type().is_symlink() {
1417
                        return fail(format!("refusing to write through symlink {}", full.display()));
1418
                    }
1419
                }
1420
            } else {
1421
                full.push(comp);
1422
            }
1423
        }
1424
        Ok(full)
1425
    }
1426
1427
    fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
1428
        let full = self.safe_path(rel)?;
1429
        if let Some(parent) = full.parent() {
1430
            fs::create_dir_all(parent)?;
1431
        }
1432
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
1433
            remove_any(&full)?;
1434
        }
1435
        fs::write(&full, bytes)?;
1436
        set_executable(&full, exec)?;
1437
        Ok(())
1438
    }
1439
1440
    fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
1441
        let full = self.safe_path(rel)?;
1442
        if let Some(parent) = full.parent() {
1443
            fs::create_dir_all(parent)?;
1444
        }
1445
        if fs::symlink_metadata(&full).is_ok() {
1446
            remove_any(&full)?;
1447
        }
1448
        #[cfg(unix)]
1449
        {
1450
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
1451
            Ok(())
1452
        }
1453
        #[cfg(not(unix))]
1454
        {
1455
            // Spec §7.7: without symlink support, write the target as a plain file.
1456
            fs::write(&full, target)?;
1457
            Ok(())
1458
        }
1459
    }
1460
1461
    /// Changes reachable from the bookmark and the working copy, newest first.
1462
    pub fn log(&self) -> Result<Vec<LogEntry>> {
1463
        let (wc_hash, _) = self.working_copy()?;
1464
        let mut heads = self.bookmark_heads()?;
1465
        heads.push(wc_hash);
1466
        let state = self.load_state(&heads)?;
1467
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
1468
        let mut entries: Vec<LogEntry> = state
1469
            .changes()
1470
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
1471
            .collect();
1472
        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)));
1473
        Ok(entries)
1474
    }
1475
1476
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
1477
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
1478
        if spec == "@" {
1479
            return Ok(self.working_copy()?.0);
1480
        }
1481
        if spec.is_empty() || spec == "#" {
1482
            return fail("empty change specifier");
1483
        }
1484
        let entries = self.log()?;
1485
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
1486
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
1487
        } else {
1488
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
1489
        };
1490
        match matches.len() {
1491
            1 => Ok(matches[0].hash),
1492
            0 => fail(format!("no change matches {spec:?}")),
1493
            _ => {
1494
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
1495
                if ids.len() == 1 {
1496
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
1497
                } else {
1498
                    fail(format!("{spec:?} is ambiguous"))
1499
                }
1500
            }
1501
        }
1502
    }
1503
1504
    /// Human-readable description of what a change does, with line content.
1505
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
1506
        let change = self.store.get_change(hash)?;
1507
        let base = self.load_state(&change.deps)?;
1508
        let base_snapshot = base.materialize();
1509
        Ok(render_change(&change, &base, &base_snapshot))
1510
    }
1511
}
1512
1513
#[derive(Debug, Clone, PartialEq, Eq)]
1514
pub struct PullReport {
1515
    pub heads: Vec<ChangeHash>,
1516
    pub changed: bool,
1517
    pub checked_out: bool,
1518
}
1519
1520
#[derive(Debug, Default, Clone, PartialEq, Eq)]
1521
pub struct CheckoutReport {
1522
    pub written: usize,
1523
    pub removed: usize,
1524
    pub conflicted: bool,
1525
}
1526
1527
fn remove_any(path: &Path) -> std::io::Result<()> {
1528
    let m = fs::symlink_metadata(path)?;
1529
    if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
1530
}
1531
1532
#[cfg(unix)]
1533
fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> {
1534
    use std::os::unix::fs::PermissionsExt;
1535
    let mut perms = fs::metadata(path)?.permissions();
1536
    let mode = perms.mode();
1537
    let want = if exec { mode | 0o111 } else { mode & !0o111 };
1538
    if want != mode {
1539
        perms.set_mode(want);
1540
        fs::set_permissions(path, perms)?;
1541
    }
1542
    Ok(())
1543
}
1544
#[cfg(not(unix))]
1545
fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> {
1546
    Ok(())
1547
}
1548
1549
#[cfg(unix)]
1550
fn is_executable(m: &fs::Metadata) -> bool {
1551
    use std::os::unix::fs::PermissionsExt;
1552
    m.permissions().mode() & 0o111 != 0
1553
}
1554
#[cfg(not(unix))]
1555
fn is_executable(_m: &fs::Metadata) -> bool {
1556
    false
1557
}
1558
1559
fn op_ref(k: OpKey) -> OpRef {
1560
    OpRef { change: k.0, index: k.1 }
1561
}
1562
1563
fn split_path(path: &str) -> (&str, &str) {
1564
    match path.rfind('/') {
1565
        Some(i) => (&path[..i], &path[i + 1..]),
1566
        None => ("", path),
1567
    }
1568
}
1569
1570
/// Builds the tree ops and edits of a working-copy change against a base.
1571
struct ChangeBuilder<'a> {
1572
    base: &'a State,
1573
    base_snapshot: &'a Snapshot,
1574
    tree_ops: Vec<TreeOp>,
1575
    edits: Vec<Edit>,
1576
    statuses: Vec<PathStatus>,
1577
    dir_nodes: HashMap<String, NodeId>,
1578
    reuse: HashMap<String, (NodeId, NodeKind)>,
1579
    /// Changes that contributed to conflicts this working copy resolves.
1580
    contributors: BTreeSet<ChangeHash>,
1581
    resolves: bool,
1582
}
1583
1584
impl<'a> ChangeBuilder<'a> {
1585
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
1586
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
1587
        dir_nodes.insert(String::new(), NodeId::ROOT);
1588
        for (p, f) in &base_snapshot.files {
1589
            if f.kind == NodeKind::Dir {
1590
                dir_nodes.insert(p.clone(), f.node);
1591
            }
1592
        }
1593
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
1594
    }
1595
1596
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
1597
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
1598
    }
1599
1600
    fn ensure_dir(&mut self, path: &str) -> NodeId {
1601
        if let Some(n) = self.dir_nodes.get(path) {
1602
            return *n;
1603
        }
1604
        let mut parent = NodeId::ROOT;
1605
        let mut prefix = String::new();
1606
        for comp in path.split('/') {
1607
            if !prefix.is_empty() {
1608
                prefix.push('/');
1609
            }
1610
            prefix.push_str(comp);
1611
            parent = match self.dir_nodes.get(&prefix) {
1612
                Some(n) => *n,
1613
                None => {
1614
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
1615
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
1616
                    self.dir_nodes.insert(prefix.clone(), node);
1617
                    node
1618
                }
1619
            };
1620
        }
1621
        parent
1622
    }
1623
1624
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
1625
        let (dir, name) = split_path(path);
1626
        let parent = self.ensure_dir(dir);
1627
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
1628
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
1629
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
1630
        if opaque {
1631
            let blob = repo.store_blob(&e.content)?;
1632
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1633
        } else if !e.content.is_empty() {
1634
            self.edits.push(Edit::Insert {
1635
                node,
1636
                after: crate::objects::AtomRef::Start,
1637
                before: crate::objects::AtomRef::End,
1638
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
1639
            });
1640
        }
1641
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
1642
        Ok(())
1643
    }
1644
1645
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
1646
        let entry = &self.base_snapshot.tree.entries[path];
1647
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
1648
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
1649
            self.resolves = true;
1650
        }
1651
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
1652
    }
1653
1654
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
1655
        if expected.kind != e.kind {
1656
            self.remove_path(path, expected);
1657
            self.statuses.pop();
1658
            self.add_path(repo, path, e)?;
1659
            self.statuses.pop();
1660
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
1661
            return Ok(());
1662
        }
1663
        let entry = &self.base_snapshot.tree.entries[path];
1664
        let node = expected.node;
1665
        let mode_atom = entry.mode_atom.map(op_ref);
1666
        let mut status: Option<ChangeStatus> = None;
1667
        match &expected.content {
1668
            FileContent::Dir => {}
1669
            FileContent::Text { bytes, materialized } => {
1670
                if materialized.is_conflicted() {
1671
                    if bytes == &e.content {
1672
                        return Ok(());
1673
                    }
1674
                    if has_conflict_markers(&e.content) {
1675
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1676
                        return Ok(());
1677
                    }
1678
                    // Resolution (spec §9.6): diff against the unconflicted lines
1679
                    // with each region as a gap, then retire every region atom.
1680
                    // Whatever the author kept from a region is re-inserted as
1681
                    // new atoms, which leaves the graph totally ordered.
1682
                    let regions = self.base.conflict_regions(&node);
1683
                    let mut edits = text_edits(node, &regions.plain, &e.content);
1684
                    if !regions.region_atoms.is_empty() {
1685
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
1686
                    }
1687
                    self.edits.extend(edits);
1688
                    self.contributors.extend(regions.contributors);
1689
                    self.resolves = true;
1690
                    if e.exec != expected.mode.exec {
1691
                        if let Some(old) = mode_atom {
1692
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1693
                        }
1694
                    }
1695
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
1696
                    return Ok(());
1697
                }
1698
                if bytes != &e.content {
1699
                    if is_binary(&e.content) {
1700
                        // Text → opaque (spec §7.5).
1701
                        if let Some(old) = mode_atom {
1702
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1703
                        }
1704
                        let live: Vec<_> = repo_live_keys(self.base, &node);
1705
                        if !live.is_empty() {
1706
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
1707
                        }
1708
                        let blob = repo.store_blob(&e.content)?;
1709
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1710
                        status = Some(ChangeStatus::Modified);
1711
                    } else {
1712
                        let old = self.base.live_lines(&node);
1713
                        let edits = text_edits(node, &old, &e.content);
1714
                        if !edits.is_empty() {
1715
                            self.edits.extend(edits);
1716
                            status = Some(ChangeStatus::Modified);
1717
                        }
1718
                    }
1719
                }
1720
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
1721
                if !switched_to_opaque && e.exec != expected.mode.exec {
1722
                    if let Some(old) = mode_atom {
1723
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1724
                        status.get_or_insert(ChangeStatus::ModeChanged);
1725
                    }
1726
                }
1727
            }
1728
            FileContent::Opaque { blobs } => {
1729
                let new_ref = Repo::blob_ref(&e.content);
1730
                if blobs.len() > 1 {
1731
                    if !blobs.contains(&new_ref) {
1732
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1733
                    }
1734
                    return Ok(());
1735
                }
1736
                let current = blobs.first().copied();
1737
                if current != Some(new_ref) {
1738
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
1739
                        // Opaque → text (spec §7.5).
1740
                        if let Some(old) = mode_atom {
1741
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1742
                        }
1743
                        self.edits.push(Edit::Replace { node, old: current, new: None });
1744
                        if !e.content.is_empty() {
1745
                            self.edits.push(Edit::Insert {
1746
                                node,
1747
                                after: crate::objects::AtomRef::Start,
1748
                                before: crate::objects::AtomRef::End,
1749
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
1750
                            });
1751
                        }
1752
                    } else {
1753
                        let blob = repo.store_blob(&e.content)?;
1754
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
1755
                    }
1756
                    status = Some(ChangeStatus::Modified);
1757
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
1758
                    if let Some(old) = mode_atom {
1759
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1760
                        status = Some(ChangeStatus::ModeChanged);
1761
                    }
1762
                }
1763
            }
1764
        }
1765
        if let Some(s) = status {
1766
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
1767
        }
1768
        Ok(())
1769
    }
1770
}
1771
1772
/// Whether a working copy touches a node that is conflicted in its base.
1773
fn touches_conflict(snap: &SnapshotResult) -> bool {
1774
    let conflicted: BTreeSet<NodeId> = snap.base_snapshot.files.values().filter(|f| f.is_conflicted()).map(|f| f.node).collect();
1775
    snap.wc.tree_ops.iter().any(|op| conflicted.contains(&op.node())) || snap.wc.edits.iter().any(|e| conflicted.contains(&e.node()))
1776
}
1777
1778
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
1779
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
1780
}
1781
1782
/// The changes a change's scope references (spec §8.1): introducers of every
1783
/// atom, placement, and mode it touches, and of every node it operates on.
1784
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
1785
    let mut out = BTreeSet::new();
1786
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
1787
        if !r.change.is_zero() {
1788
            out.insert(r.change);
1789
        }
1790
    };
1791
    let mut nodes: Vec<NodeId> = Vec::new();
1792
    for op in &change.tree_ops {
1793
        match op {
1794
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
1795
            TreeOp::RemoveNode { node, placement } => {
1796
                nodes.push(*node);
1797
                opref(placement, &mut out);
1798
            }
1799
            TreeOp::Move { node, old, parent, .. } => {
1800
                nodes.push(*node);
1801
                nodes.push(*parent);
1802
                opref(old, &mut out);
1803
            }
1804
            TreeOp::SetMode { node, old, .. } => {
1805
                nodes.push(*node);
1806
                opref(old, &mut out);
1807
            }
1808
        }
1809
    }
1810
    for e in &change.edits {
1811
        nodes.push(e.node());
1812
        match e {
1813
            Edit::Insert { after, before, .. } => {
1814
                for r in [after, before] {
1815
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
1816
                        if !change.is_zero() {
1817
                            out.insert(*change);
1818
                        }
1819
                    }
1820
                }
1821
            }
1822
            Edit::Delete { atoms, .. } => {
1823
                for r in atoms {
1824
                    if !r.change.is_zero() {
1825
                        out.insert(r.change);
1826
                    }
1827
                }
1828
            }
1829
            Edit::Replace { .. } => {}
1830
        }
1831
    }
1832
    for n in nodes {
1833
        if let Some(h) = base.tree().introducer(&n) {
1834
            out.insert(h);
1835
        }
1836
    }
1837
    out
1838
}
1839
1840
/// Paths of the nodes a change adds on top of `base_snapshot`.
1841
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
1842
    let by_node = base_snapshot.tree.by_node();
1843
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1844
    for op in &change.tree_ops {
1845
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1846
            new_names.insert(*node, (*parent, name.clone(), *kind));
1847
        }
1848
    }
1849
    let mut out = HashMap::new();
1850
    for (node, (_, _, kind)) in &new_names {
1851
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
1852
            out.insert(path, (*node, *kind));
1853
        }
1854
    }
1855
    out
1856
}
1857
1858
fn path_of_node(
1859
    n: &NodeId,
1860
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
1861
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
1862
) -> Option<String> {
1863
    if let Some(e) = by_node.get(n) {
1864
        return Some(e.path.clone());
1865
    }
1866
    let mut parts = Vec::new();
1867
    let mut cur = *n;
1868
    loop {
1869
        if cur.is_root() {
1870
            break;
1871
        }
1872
        if let Some(e) = by_node.get(&cur) {
1873
            if !e.path.is_empty() {
1874
                parts.push(e.path.clone());
1875
            }
1876
            break;
1877
        }
1878
        let (parent, name, _) = new_names.get(&cur)?;
1879
        parts.push(name.clone());
1880
        cur = *parent;
1881
    }
1882
    parts.reverse();
1883
    Some(parts.join("/"))
1884
}
1885
1886
/// Render a change's operations with line content, using `base` for deleted lines.
1887
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
1888
    let by_node = base_snapshot.tree.by_node();
1889
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1890
    for op in &change.tree_ops {
1891
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1892
            new_names.insert(*node, (*parent, name.clone(), *kind));
1893
        }
1894
    }
1895
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
1896
1897
    let mut out = String::new();
1898
    for op in &change.tree_ops {
1899
        match op {
1900
            TreeOp::AddNode { node, kind, mode, .. } => {
1901
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
1902
            }
1903
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
1904
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
1905
            TreeOp::SetMode { node, mode, .. } => {
1906
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
1907
            }
1908
        }
1909
    }
1910
    let mut last_node: Option<NodeId> = None;
1911
    for e in &change.edits {
1912
        let n = e.node();
1913
        if last_node != Some(n) {
1914
            out.push_str(&format!("--- {}\n", path_of(&n)));
1915
            last_node = Some(n);
1916
        }
1917
        match e {
1918
            Edit::Insert { lines, .. } => {
1919
                for l in lines {
1920
                    out.push('+');
1921
                    out.push_str(&String::from_utf8_lossy(l));
1922
                    if !l.ends_with(b"\n") {
1923
                        out.push('\n');
1924
                    }
1925
                }
1926
            }
1927
            Edit::Delete { atoms, .. } => {
1928
                for r in atoms {
1929
                    for i in r.from..r.to {
1930
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
1931
                        out.push('-');
1932
                        out.push_str(&String::from_utf8_lossy(content));
1933
                        if !content.ends_with(b"\n") {
1934
                            out.push('\n');
1935
                        }
1936
                    }
1937
                }
1938
            }
1939
            Edit::Replace { old, new, .. } => {
1940
                out.push_str(&format!(
1941
                    "  opaque content {} -> {}\n",
1942
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
1943
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
1944
                ));
1945
            }
1946
        }
1947
    }
1948
    out
1949
}
1950
1951
#[cfg(test)]
1952
mod tests {
1953
    use super::*;
1954
1955
    fn write(root: &Path, rel: &str, content: &[u8]) {
1956
        let p = root.join(rel);
1957
        fs::create_dir_all(p.parent().unwrap()).unwrap();
1958
        fs::write(p, content).unwrap();
1959
    }
1960
1961
    fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
1962
        s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
1963
    }
1964
1965
    #[test]
1966
    fn init_snapshot_commit_log_undo() {
1967
        let dir = tempfile::tempdir().unwrap();
1968
        let root = dir.path();
1969
        let repo = Repo::init(root).unwrap();
1970
        assert!(Repo::init(root).is_err());
1971
        let ops = repo.op_log().unwrap();
1972
        assert_eq!(ops.len(), 1);
1973
        assert_eq!(ops[0].1.command, "oxy init");
1974
1975
        // Empty working copy: snapshot records no operation (spec §12.3).
1976
        let s = repo.snapshot("oxy status").unwrap();
1977
        assert!(!s.changed);
1978
        assert_eq!(repo.op_log().unwrap().len(), 1);
1979
1980
        write(root, "src/main.rs", b"fn main() {}\n");
1981
        write(root, "README.md", b"# Hello\n");
1982
        write(root, ".oxyignore", b"/target\n");
1983
        write(root, "target/junk.o", b"\0\0\0");
1984
        let s = repo.snapshot("oxy status").unwrap();
1985
        assert!(s.changed);
1986
        assert_eq!(
1987
            statuses(&s),
1988
            vec![
1989
                (".oxyignore".into(), ChangeStatus::Added),
1990
                ("README.md".into(), ChangeStatus::Added),
1991
                ("src/main.rs".into(), ChangeStatus::Added),
1992
            ]
1993
        );
1994
        assert_eq!(repo.op_log().unwrap().len(), 2);
1995
        // Snapshotting again with nothing changed records no op.
1996
        let s2 = repo.snapshot("oxy status").unwrap();
1997
        assert!(!s2.changed);
1998
        assert_eq!(s2.wc_hash, s.wc_hash);
1999
        assert_eq!(repo.op_log().unwrap().len(), 2);
2000
2001
        let h1 = repo.commit("Initial import", &[]).unwrap();
2002
        let c1 = repo.store().get_change(&h1).unwrap();
2003
        assert_eq!(c1.kind, ChangeKind::Normal);
2004
        assert!(c1.deps.is_empty(), "first change has nothing to depend on");
2005
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
2006
        let (wc_hash, wc) = repo.working_copy().unwrap();
2007
        assert_eq!(wc.deps, vec![h1]);
2008
        assert!(wc.edits.is_empty());
2009
        assert!(repo.commit("nothing", &[]).is_err());
2010
2011
        // Modify one file, remove another, add a binary file.
2012
        write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
2013
        fs::remove_file(root.join("README.md")).unwrap();
2014
        write(root, "logo.png", b"\x89PNG\0\0\x01");
2015
        let s = repo.snapshot("oxy status").unwrap();
2016
        assert_eq!(
2017
            statuses(&s),
2018
            vec![
2019
                ("README.md".into(), ChangeStatus::Removed),
2020
                ("logo.png".into(), ChangeStatus::Added),
2021
                ("src/main.rs".into(), ChangeStatus::Modified),
2022
            ]
2023
        );
2024
        assert_ne!(s.wc_hash, wc_hash);
2025
        // The rewritten working copy keeps its ChangeId and depends on h1.
2026
        assert_eq!(s.wc.change_id, wc.change_id);
2027
        assert!(s.wc.deps.contains(&h1));
2028
2029
        let h2 = repo.commit("Second", &[]).unwrap();
2030
        let c2 = repo.store().get_change(&h2).unwrap();
2031
        assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
2032
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
2033
2034
        // The materialized head state matches the working copy.
2035
        let st = repo.load_state(&[h2]).unwrap();
2036
        let snap = st.materialize();
2037
        assert!(!snap.is_conflicted());
2038
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
2039
        assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
2040
        assert!(snap.files["logo.png"].mode.opaque);
2041
        if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
2042
            assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
2043
        } else {
2044
            panic!("logo should be opaque");
2045
        }
2046
2047
        // Log: newest first, working copy included.
2048
        let log = repo.log().unwrap();
2049
        assert_eq!(log.len(), 3);
2050
        assert!(log[0].is_working_copy);
2051
        assert_eq!(log[1].hash, h2);
2052
        assert!(log[1].is_head);
2053
        assert_eq!(log[2].hash, h1);
2054
        let d = repo.describe_change(&h2).unwrap();
2055
        assert!(d.contains("D README.md"), "{d}");
2056
        assert!(d.contains("+    println!(\"hi\");"), "{d}");
2057
        assert!(d.contains("-fn main() {}"), "{d}");
2058
2059
        // Resolution by prefix.
2060
        assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
2061
        assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
2062
        assert!(repo.resolve_change("zzzz").is_err());
2063
2064
        // Undo the commit: bookmark and working copy go back.
2065
        repo.undo().unwrap();
2066
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
2067
        let s = repo.snapshot("oxy status").unwrap();
2068
        assert!(!s.changed, "files on disk still match the pre-commit working copy");
2069
        assert_eq!(s.statuses.len(), 3);
2070
    }
2071
2072
    /// Make `main` conflicted: two commits on the same base that edit the same line.
2073
    fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) {
2074
        let dir = tempfile::tempdir().unwrap();
2075
        let root = dir.path();
2076
        let repo = Repo::init(root).unwrap();
2077
        write(root, "f.txt", b"a\nb\nc\n");
2078
        let h1 = repo.commit("base", &[]).unwrap();
2079
        write(root, "f.txt", b"a\nB\nc\n");
2080
        let h2 = repo.commit("upper", &[]).unwrap();
2081
        repo.checkout(&[h1], false, "checkout").unwrap();
2082
        write(root, "f.txt", b"a\nX\nc\n");
2083
        let h3 = repo.commit("ex", &[]).unwrap();
2084
        (dir, repo, h1, h2, h3)
2085
    }
2086
2087
    #[test]
2088
    fn concurrent_edits_conflict_and_can_be_resolved() {
2089
        let (dir, repo, h1, h2, h3) = conflicted_repo();
2090
        let root = dir.path();
2091
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
2092
        assert_eq!(heads, [h2, h3].into_iter().collect());
2093
        let st = repo.load_state(&[h2, h3]).unwrap();
2094
        assert!(st.materialize().is_conflicted());
2095
2096
        // Checking out main writes markers.
2097
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
2098
        assert!(r.conflicted);
2099
        let marked = fs::read_to_string(root.join("f.txt")).unwrap();
2100
        assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}");
2101
        assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}");
2102
        assert!(has_conflict_markers(marked.as_bytes()));
2103
        // Untouched markers: clean working copy; commit has nothing to do.
2104
        let s = repo.snapshot("status").unwrap();
2105
        assert!(s.statuses.is_empty());
2106
        assert!(!s.resolves);
2107
2108
        // Partially edited file that still has markers cannot be committed.
2109
        write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
2110
        let s = repo.snapshot("status").unwrap();
2111
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]);
2112
        assert!(repo.commit("no", &[]).is_err());
2113
2114
        // Resolve by keeping one side and adding a line elsewhere.
2115
        write(root, "f.txt", b"a\nX\nc\nd\n");
2116
        let s = repo.snapshot("status").unwrap();
2117
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
2118
        assert!(s.resolves);
2119
        let h4 = repo.commit("resolve", &[]).unwrap();
2120
        let c4 = repo.store().get_change(&h4).unwrap();
2121
        assert_eq!(c4.kind, ChangeKind::Resolution);
2122
        assert!(c4.deps.contains(&h2) && c4.deps.contains(&h3) && c4.deps.contains(&h1), "{:?}", c4.deps);
2123
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
2124
        let snap = repo.load_state(&[h4]).unwrap().materialize();
2125
        assert!(!snap.is_conflicted());
2126
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
2127
        assert!(!repo.snapshot("status").unwrap().changed);
2128
        // A peer holding only h2 and h3 and receiving h4 sees the same result,
2129
        // whatever order the changes arrive in.
2130
        let mut st = State::new();
2131
        for h in [h4, h3, h1, h2] {
2132
            st.add_unchecked(repo.store().get_change(&h).unwrap());
2133
        }
2134
        st.validate().unwrap();
2135
        assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
2136
    }
2137
2138
    #[test]
2139
    fn resolution_may_keep_both_sides_or_neither() {
2140
        let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
2141
        let root = dir.path();
2142
        repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
2143
        // Keep both sides in a chosen order.
2144
        write(root, "f.txt", b"a\nX\nB\nc\n");
2145
        let h = repo.commit("both", &[]).unwrap();
2146
        let snap = repo.load_state(&[h]).unwrap().materialize();
2147
        assert!(!snap.is_conflicted());
2148
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n"));
2149
        // Undo and instead drop the whole region.
2150
        repo.undo().unwrap();
2151
        write(root, "f.txt", b"a\nc\n");
2152
        let h = repo.commit("neither", &[]).unwrap();
2153
        let snap = repo.load_state(&[h]).unwrap().materialize();
2154
        assert!(!snap.is_conflicted());
2155
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
2156
    }
2157
2158
    #[test]
2159
    fn name_conflict_resolved_by_removing_a_candidate() {
2160
        let dir = tempfile::tempdir().unwrap();
2161
        let root = dir.path();
2162
        let repo = Repo::init(root).unwrap();
2163
        write(root, "keep.txt", b"k\n");
2164
        let h1 = repo.commit("base", &[]).unwrap();
2165
        write(root, "new.txt", b"one\n");
2166
        repo.commit("one", &[]).unwrap();
2167
        repo.checkout(&[h1], false, "checkout").unwrap();
2168
        write(root, "new.txt", b"two\n");
2169
        repo.commit("two", &[]).unwrap();
2170
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
2171
        assert!(r.conflicted);
2172
        assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists());
2173
        assert!(!root.join("new.txt").exists());
2174
        // Keep candidate 2 under the real name.
2175
        fs::remove_file(root.join("new.txt.oxy-conflict-1")).unwrap();
2176
        fs::rename(root.join("new.txt.oxy-conflict-2"), root.join("new.txt")).unwrap();
2177
        let h = repo.commit("pick two", &[]).unwrap();
2178
        assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution);
2179
        // The resolution does not touch keep.txt, so it does not depend on h1;
2180
        // the bookmark keeps both as heads and their union is the state.
2181
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
2182
        assert_eq!(heads, [h1, h].into_iter().collect());
2183
        let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
2184
        assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
2185
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]);
2186
        assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n"));
2187
    }
2188
2189
    #[test]
2190
    fn identities_and_signed_commits() {
2191
        let dir = tempfile::tempdir().unwrap();
2192
        let root = dir.path();
2193
        let mut repo = Repo::init(root).unwrap().with_keys_dir(root.join("keys"));
2194
        write(root, "a.txt", b"a\n");
2195
        let h0 = repo.commit("unsigned", &[]).unwrap();
2196
        assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
2197
        assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
2198
2199
        let (key, id) = repo.create_identity("Ada", "[email protected]").unwrap();
2200
        assert_eq!(id.hash(), key);
2201
        assert_eq!(repo.config.author_key, Some(key));
2202
        // Config survives reopen; the secret key is found again.
2203
        let reopened = Repo::open(root).unwrap().with_keys_dir(root.join("keys"));
2204
        assert_eq!(reopened.config.author_key, Some(key));
2205
        assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
2206
2207
        // Default core.sign = seal: commits carry the author but no signature.
2208
        write(root, "a.txt", b"a\nb\n");
2209
        let h1 = repo.commit("authored", &[]).unwrap();
2210
        assert_eq!(repo.store().get_change(&h1).unwrap().author, key);
2211
        assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
2212
        // Signing later works too.
2213
        repo.sign_change(&h1).unwrap();
2214
        assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
2215
2216
        repo.config.set("core.sign", "change").unwrap();
2217
        repo.save_config().unwrap();
2218
        write(root, "a.txt", b"a\nb\nc\n");
2219
        let h2 = repo.commit("signed", &[]).unwrap();
2220
        assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false });
2221
2222
        // A signature by a key that is not the author or a delegate is rejected.
2223
        let other = SecretKey::generate();
2224
        let other_id = other.identity("Mallory", "[email protected]", 0);
2225
        let other_key = repo.store().put_identity(&other_id).unwrap();
2226
        let forged = other.sign(Domain::Change, &other_key, &h0);
2227
        repo.store().put_signature(&h0, &forged).unwrap();
2228
        assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")));
2229
2230
        // Tampering with the stored signature invalidates it.
2231
        let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
2232
        sig.ed[0] ^= 1;
2233
        repo.store().put_signature(&h2, &sig).unwrap();
2234
        assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
2235
2236
        // Without the secret key, signing at commit fails cleanly.
2237
        let no_keys = Repo::open(root).unwrap().with_keys_dir(root.join("nowhere"));
2238
        write(root, "a.txt", b"z\n");
2239
        assert!(no_keys.commit("cannot sign", &[]).is_err());
2240
    }
2241
2242
    #[test]
2243
    fn checkout_moves_the_working_copy() {
2244
        let dir = tempfile::tempdir().unwrap();
2245
        let root = dir.path();
2246
        let repo = Repo::init(root).unwrap();
2247
        write(root, "a.txt", b"one\n");
2248
        write(root, "sub/deep/b.txt", b"b\n");
2249
        let h1 = repo.commit("first", &[]).unwrap();
2250
        write(root, "a.txt", b"two\n");
2251
        fs::remove_file(root.join("sub/deep/b.txt")).unwrap();
2252
        write(root, "bin.dat", &[0, 1, 2, 3]);
2253
        let h2 = repo.commit("second", &[]).unwrap();
2254
2255
        let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
2256
        assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false });
2257
        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
2258
        assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
2259
        assert!(!root.join("bin.dat").exists());
2260
        let (_, wc) = repo.working_copy().unwrap();
2261
        assert_eq!(wc.deps, vec![h1]);
2262
        assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy");
2263
        // The bookmark still points at h2.
2264
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
2265
2266
        // Dirty working copy refuses without --discard.
2267
        write(root, "a.txt", b"dirty\n");
2268
        assert!(repo.checkout(&[h2], false, "oxy checkout").is_err());
2269
        let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap();
2270
        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n");
2271
        assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]);
2272
        assert!(!root.join("sub").exists(), "empty directories are pruned");
2273
        assert_eq!(r.removed, 1);
2274
        assert!(!repo.snapshot("status").unwrap().changed);
2275
2276
        // Refuses to write through a symlinked directory.
2277
        #[cfg(unix)]
2278
        {
2279
            fs::remove_dir_all(root.join("sub")).ok();
2280
            std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap();
2281
            assert!(repo.checkout(&[h1], true, "oxy checkout").is_err());
2282
        }
2283
    }
2284
2285
    #[test]
2286
    fn large_files_are_chunked_and_read_back() {
2287
        let dir = tempfile::tempdir().unwrap();
2288
        let repo = Repo::init(dir.path()).unwrap();
2289
        let mut big = vec![0u8; 700 * 1024];
2290
        for (i, b) in big.iter_mut().enumerate() {
2291
            *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0];
2292
        }
2293
        let r = repo.store_blob(&big).unwrap();
2294
        let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap();
2295
        assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len());
2296
        assert_eq!(repo.read_blob(&r).unwrap(), big);
2297
        assert_eq!(Repo::blob_ref(&big), r);
2298
    }
2299
2300
    #[test]
2301
    fn clone_push_pull_with_seals_and_attestations() {
2302
        let dir = tempfile::tempdir().unwrap();
2303
        let root = dir.path();
2304
        let origin_path = root.join("origin");
2305
        let origin = Repo::init(&origin_path).unwrap();
2306
        let origin_url = origin_path.to_string_lossy().to_string();
2307
2308
        // A clones, creates an identity (default core.sign = seal), commits, pushes.
2309
        let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_keys_dir(root.join("keys-a"));
2310
        assert_eq!(a.id().unwrap(), origin.id().unwrap());
2311
        let (key_a, _) = a.create_identity("Ada", "[email protected]").unwrap();
2312
        write(&root.join("a"), "f.txt", b"a\n");
2313
        let h1 = a.commit("first", &[]).unwrap();
2314
        let results = a.push("origin", false).unwrap();
2315
        assert!(results[0].ok, "{}", results[0].reason);
2316
        assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
2317
        // The change was sealed at push and the reference attested.
2318
        assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a));
2319
        assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
2320
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2321
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }));
2322
        let again = a.push("origin", false).unwrap();
2323
        assert!(again[0].ok && again[0].reason.contains("up to date"));
2324
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2325
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again");
2326
2327
        // B clones and sees the file and the seal.
2328
        let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
2329
        assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
2330
        assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
2331
        assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. }));
2332
2333
        // B (no identity) commits and pushes unsigned; the unverified origin accepts.
2334
        write(&root.join("b"), "f.txt", b"a\nb\n");
2335
        let h2 = b.commit("second", &[]).unwrap();
2336
        assert!(b.push("origin", false).unwrap()[0].ok);
2337
        assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]);
2338
2339
        // A commits an independent change; push is refused until A pulls.
2340
        write(&root.join("a"), "g.txt", b"g\n");
2341
        let h3 = a.commit("third", &[]).unwrap();
2342
        assert!(a.push("origin", false).is_err());
2343
        let pr = a.pull("origin").unwrap();
2344
        assert!(pr.changed && pr.checked_out);
2345
        let mut want = vec![h2, h3];
2346
        want.sort();
2347
        assert_eq!(pr.heads, want);
2348
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n");
2349
        assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
2350
        let results = a.push("origin", false).unwrap();
2351
        assert!(results[0].ok, "{}", results[0].reason);
2352
        let mut origin_heads = origin.bookmark_heads().unwrap();
2353
        origin_heads.sort();
2354
        assert_eq!(origin_heads, want);
2355
        let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
2356
        assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. }));
2357
2358
        // B pulls and ends up identical.
2359
        let pr = b.pull("origin").unwrap();
2360
        assert_eq!(pr.heads, want);
2361
        assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n");
2362
        assert!(!b.snapshot("status").unwrap().changed);
2363
2364
        // Concurrent edits of the same line: the union is a reified conflict.
2365
        write(&root.join("a"), "f.txt", b"a\nA\n");
2366
        a.commit("a-edit", &[]).unwrap();
2367
        a.push("origin", false).unwrap();
2368
        write(&root.join("b"), "f.txt", b"a\nB\n");
2369
        b.commit("b-edit", &[]).unwrap();
2370
        let pr = b.pull("origin").unwrap();
2371
        assert!(pr.changed && pr.checked_out);
2372
        let text = fs::read_to_string(root.join("b/f.txt")).unwrap();
2373
        assert!(text.contains("<<<<<<< oxy conflict"), "{text}");
2374
        write(&root.join("b"), "f.txt", b"a\nAB\n");
2375
        let hr = b.commit("resolve", &[]).unwrap();
2376
        assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution);
2377
        assert!(b.push("origin", false).unwrap()[0].ok);
2378
        a.pull("origin").unwrap();
2379
        assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n");
2380
        assert_eq!(a.bookmark_heads().unwrap(), vec![hr]);
2381
    }
2382
2383
    #[test]
2384
    fn receive_push_rejects_bad_updates() {
2385
        let dir = tempfile::tempdir().unwrap();
2386
        let root = dir.path();
2387
        let origin = Repo::init(&root.join("origin")).unwrap();
2388
        let url = root.join("origin").to_string_lossy().to_string();
2389
        let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_keys_dir(root.join("keys"));
2390
        let (key, _) = a.create_identity("Ada", "a@b", ).unwrap();
2391
        write(&root.join("a"), "f.txt", b"x\n");
2392
        let h1 = a.commit("one", &[]).unwrap();
2393
        assert!(a.push("origin", false).unwrap()[0].ok);
2394
        let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap();
2395
        let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap();
2396
2397
        // Missing head.
2398
        let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
2399
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }]).unwrap();
2400
        assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
2401
2402
        // Stale old_state.
2403
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }]).unwrap();
2404
        assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
2405
2406
        // Non-fast-forward without force is refused; with force it passes.
2407
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }]).unwrap();
2408
        assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
2409
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }]).unwrap();
2410
        assert!(r[0].ok);
2411
        assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
2412
2413
        // Attestation with a stale serial is a rollback and is refused.
2414
        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();
2415
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }]).unwrap();
2416
        assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
2417
        // An attestation for a different update is refused too.
2418
        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();
2419
        let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }]).unwrap();
2420
        assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
2421
        let _ = key;
2422
    }
2423
2424
    #[test]
2425
    fn format1_repositories_are_migrated_on_open() {
2426
        use crate::oplog::{Format1Op, Format1View};
2427
        let dir = tempfile::tempdir().unwrap();
2428
        let root = dir.path();
2429
        let repo = Repo::init(root).unwrap();
2430
        write(root, "a.txt", b"a\n");
2431
        let h1 = repo.commit("one", &[]).unwrap();
2432
        let (wc_hash, _) = repo.working_copy().unwrap();
2433
        // Rewrite the op log the way format 1 stored it: two ops, head lists.
2434
        let mut v1 = Format1View::default();
2435
        v1.bookmarks.insert("main".into(), vec![]);
2436
        v1.workspaces.insert(repo.workspace(), wc_hash);
2437
        let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() };
2438
        let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap();
2439
        v1.bookmarks.insert("main".into(), vec![h1]);
2440
        let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 };
2441
        let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap();
2442
        write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap();
2443
        write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap();
2444
        drop(repo);
2445
2446
        let repo = Repo::open(root).unwrap();
2447
        assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE);
2448
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
2449
        let ops = repo.op_log().unwrap();
2450
        assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::<Vec<_>>(), ["oxy commit", "oxy init"]);
2451
        assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]);
2452
        assert!(!repo.snapshot("status").unwrap().changed);
2453
        // Reopening again is a no-op.
2454
        assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2);
2455
    }
2456
2457
    #[test]
2458
    fn undo_skips_snapshot_operations() {
2459
        let dir = tempfile::tempdir().unwrap();
2460
        let root = dir.path();
2461
        let repo = Repo::init(root).unwrap();
2462
        write(root, "a.txt", b"a\n");
2463
        let h1 = repo.commit("a", &[]).unwrap();
2464
        // A later command snapshots new edits on top of the commit.
2465
        write(root, "b.txt", b"b\n");
2466
        let s = repo.snapshot("oxy status").unwrap();
2467
        assert!(s.changed);
2468
        assert!(repo.head_op().unwrap().1.is_snapshot());
2469
        assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\"");
2470
        // Undo must undo the commit, not the snapshot.
2471
        repo.undo().unwrap();
2472
        assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
2473
        let s = repo.snapshot("oxy status").unwrap();
2474
        let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
2475
        assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy");
2476
        assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
2477
        // Only snapshots remain above `init`, so there is nothing left to undo.
2478
        assert_eq!(repo.last_command().unwrap().command, "oxy init");
2479
        assert!(repo.undo().is_err());
2480
    }
2481
2482
    #[test]
2483
    fn independent_changes_commute_and_make_multiple_heads() {
2484
        let dir = tempfile::tempdir().unwrap();
2485
        let root = dir.path();
2486
        let repo = Repo::init(root).unwrap();
2487
        write(root, "a.txt", b"a\n");
2488
        let h1 = repo.commit("a", &[]).unwrap();
2489
        write(root, "b.txt", b"b\n");
2490
        let h2 = repo.commit("b", &[]).unwrap();
2491
        let c2 = repo.store().get_change(&h2).unwrap();
2492
        assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
2493
        let mut heads = repo.bookmark_heads().unwrap();
2494
        heads.sort();
2495
        let mut want = vec![h1, h2];
2496
        want.sort();
2497
        assert_eq!(heads, want);
2498
        // Editing a.txt depends on h1 only.
2499
        write(root, "a.txt", b"a\na2\n");
2500
        let h3 = repo.commit("a2", &[]).unwrap();
2501
        assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
2502
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
2503
        assert_eq!(heads, [h2, h3].into_iter().collect());
2504
        // Explicit dependency.
2505
        write(root, "c.txt", b"c\n");
2506
        let h4 = repo.commit("c", &[h2]).unwrap();
2507
        assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
2508
    }
2509
2510
    #[test]
2511
    fn mode_change_and_text_binary_switch() {
2512
        let dir = tempfile::tempdir().unwrap();
2513
        let root = dir.path();
2514
        let repo = Repo::init(root).unwrap();
2515
        write(root, "run.sh", b"#!/bin/sh\n");
2516
        repo.commit("add script", &[]).unwrap();
2517
        #[cfg(unix)]
2518
        {
2519
            use std::os::unix::fs::PermissionsExt;
2520
            fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
2521
            let s = repo.snapshot("status").unwrap();
2522
            assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
2523
            let h = repo.commit("exec", &[]).unwrap();
2524
            let snap = repo.load_state(&[h]).unwrap().materialize();
2525
            assert!(snap.files["run.sh"].mode.exec);
2526
        }
2527
        // Text → binary → text.
2528
        write(root, "run.sh", b"\0binary");
2529
        let h = repo.commit("binary", &[]).unwrap();
2530
        let snap = repo.load_state(&[h]).unwrap().materialize();
2531
        assert!(snap.files["run.sh"].mode.opaque);
2532
        write(root, "run.sh", b"text again\n");
2533
        let h = repo.commit("text", &[]).unwrap();
2534
        let snap = repo.load_state(&[h]).unwrap().materialize();
2535
        assert!(!snap.files["run.sh"].mode.opaque);
2536
        assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
2537
        assert!(!snap.is_conflicted());
2538
    }
2539
}