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