OXYGENHUB
# 86e682c242c6 oxygen / crates / oxy-core / src / repo.rs
75.9 KiB · Rust · last changed Import the Oxygen specification and the first Rust implementation (root change) 1 month ago source blame history raw
ff0af1ee unknown 1
//! A repository on disk: the `.oxy` directory, one workspace, the operation
2
//! log, and the working-copy change (spec §12, §13.1).
3
//!
1983686f Andy McCall ↶ 4
//! Not yet implemented from the spec: seals, attestations and trust policy,
20247a03 Andy McCall ↶ 5
//! packs, checkpoints, multiple workspaces, and remotes. Changes are signed individually when `core.sign` is
1983686f Andy McCall ↶ 6
//! `change`; the default `seal` leaves them unsigned until push exists.
ff0af1ee unknown 7
8
use crate::diff::{ranges, split_lines, text_edits};
1983686f Andy McCall ↶ 9
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
10
use crate::identity::{self, Domain, Identity, SecretKey, Signature};
ff0af1ee unknown 11
use crate::ids::{ChangeId, NodeId, WorkspaceId};
86e682c2 Andy McCall ↶ 12
use crate::objects::{Blob, Change, ChangeKind, Edit, Hlc, Mode, NodeKind, OpRef, StateObject, TreeOp};
13
use crate::oplog::{Format1Op, Op, View, SNAPSHOT_SUFFIX};
ff0af1ee unknown 14
use crate::state::{FileContent, Snapshot, State};
15
use crate::store::{Store, StoreError};
16
use crate::tree::OpKey;
17
use std::collections::{BTreeMap, BTreeSet, HashMap};
18
use std::fs;
19
use std::io::Write;
20
use std::path::{Path, PathBuf};
21
22
pub const OXY_DIR: &str = ".oxy";
86e682c2 Andy McCall ↶ 23
pub const FORMAT_LINE: &str = "oxy repository format 2\n";
24
const FORMAT1_LINE: &str = "oxy repository format 1\n";
ff0af1ee unknown 25
const BINARY_SNIFF: usize = 8 * 1024;
26
27
#[derive(Debug, thiserror::Error)]
28
pub enum RepoError {
29
    #[error("io: {0}")]
30
    Io(#[from] std::io::Error),
31
    #[error(transparent)]
32
    Store(#[from] StoreError),
33
    #[error(transparent)]
34
    Object(#[from] crate::objects::ObjectError),
35
    #[error(transparent)]
36
    State(#[from] crate::state::StateError),
37
    #[error("{0}")]
38
    Msg(String),
39
}
40
41
pub type Result<T> = std::result::Result<T, RepoError>;
42
43
fn fail<T>(s: impl Into<String>) -> Result<T> {
44
    Err(RepoError::Msg(s.into()))
45
}
46
47
/// Repository-local settings (`.oxy/config`, `key = value` lines).
48
#[derive(Clone, Debug, PartialEq, Eq)]
49
pub struct Config {
50
    pub author_name: String,
51
    pub author_email: String,
1983686f Andy McCall ↶ 52
    /// The KeyId of the author's identity, once `create_identity` has run.
53
    pub author_key: Option<KeyId>,
54
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
55
    /// push-time seals (`false`, the spec default).
56
    pub sign_changes: bool,
ff0af1ee unknown 57
    pub bookmark: String,
58
}
59
60
impl Config {
61
    fn default_for_user() -> Config {
62
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
63
        Config {
64
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
65
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
1983686f Andy McCall ↶ 66
            author_key: None,
67
            sign_changes: false,
ff0af1ee unknown 68
            bookmark: "main".into(),
69
        }
70
    }
71
1983686f Andy McCall ↶ 72
    /// Set a key as `oxy config` does; returns false for unknown keys.
73
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
74
        match key {
75
            "author.name" => self.author_name = value.into(),
76
            "author.email" => self.author_email = value.into(),
77
            "author.key" => {
78
                self.author_key = if value.is_empty() {
79
                    None
80
                } else {
81
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
82
                }
83
            }
84
            "core.sign" => {
85
                self.sign_changes = match value {
86
                    "change" => true,
87
                    "seal" => false,
88
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
89
                }
90
            }
91
            "workspace.bookmark" => self.bookmark = value.into(),
92
            _ => return fail(format!("unknown configuration key {key:?}")),
93
        }
94
        Ok(())
95
    }
96
ff0af1ee unknown 97
    fn load(path: &Path) -> Result<Config> {
98
        let mut c = Config::default_for_user();
99
        for line in fs::read_to_string(path)?.lines() {
100
            let line = line.trim();
101
            if line.is_empty() || line.starts_with('#') {
102
                continue;
103
            }
104
            let Some((k, v)) = line.split_once('=') else { continue };
105
            let v = v.trim().trim_matches('"').to_string();
1983686f Andy McCall ↶ 106
            let _ = c.set(k.trim(), &v);
ff0af1ee unknown 107
        }
108
        Ok(c)
109
    }
110
111
    fn save(&self, path: &Path) -> Result<()> {
112
        let text = format!(
1983686f Andy McCall ↶ 113
            "author.name = \"{}\"\nauthor.email = \"{}\"\nauthor.key = \"{}\"\ncore.sign = \"{}\"\nworkspace.bookmark = \"{}\"\n",
114
            self.author_name,
115
            self.author_email,
116
            self.author_key.map(|k| k.to_hex()).unwrap_or_default(),
117
            if self.sign_changes { "change" } else { "seal" },
118
            self.bookmark
ff0af1ee unknown 119
        );
120
        write_atomic(path, text.as_bytes())
121
    }
122
}
123
124
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
125
    let tmp = path.with_extension("tmp");
126
    {
127
        let mut f = fs::File::create(&tmp)?;
128
        f.write_all(data)?;
129
        f.sync_all()?;
130
    }
131
    fs::rename(&tmp, path)?;
132
    Ok(())
133
}
134
135
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136
pub enum ChangeStatus {
137
    Added,
138
    Modified,
139
    Removed,
140
    ModeChanged,
20247a03 Andy McCall ↶ 141
    /// The file is conflicted in the base state and still contains conflict markers.
ff0af1ee unknown 142
    ConflictEdited,
20247a03 Andy McCall ↶ 143
    /// The file was conflicted in the base state and the working copy resolves it.
144
    Resolved,
145
}
146
147
/// Lines that mark a conflict region in a materialized file (spec §9.5).
148
pub fn has_conflict_markers(content: &[u8]) -> bool {
149
    split_lines(content).iter().any(|l| {
150
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
151
    })
ff0af1ee unknown 152
}
153
154
#[derive(Clone, Debug, PartialEq, Eq)]
155
pub struct PathStatus {
156
    pub path: String,
157
    pub status: ChangeStatus,
158
}
159
160
/// Result of snapshotting the working copy.
161
pub struct SnapshotResult {
162
    pub wc_hash: ChangeHash,
163
    pub wc: Change,
164
    /// The base state (closure of the working-copy change's dependencies).
165
    pub base: State,
166
    pub base_snapshot: Snapshot,
167
    pub statuses: Vec<PathStatus>,
168
    /// Whether the snapshot rewrote the working-copy change.
169
    pub changed: bool,
20247a03 Andy McCall ↶ 170
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
171
    pub resolves: bool,
ff0af1ee unknown 172
}
173
174
pub struct LogEntry {
175
    pub hash: ChangeHash,
176
    pub change: Change,
177
    pub is_working_copy: bool,
178
    pub is_head: bool,
179
}
180
181
struct FsEntry {
182
    kind: NodeKind,
183
    exec: bool,
184
    content: Vec<u8>,
185
}
186
187
pub struct Repo {
188
    root: PathBuf,
189
    oxy: PathBuf,
190
    store: Store,
191
    workspace: WorkspaceId,
1983686f Andy McCall ↶ 192
    keys_dir: PathBuf,
ff0af1ee unknown 193
    pub config: Config,
194
}
195
1983686f Andy McCall ↶ 196
/// Where secret keys live: `$OXY_HOME/keys`, else `$XDG_CONFIG_HOME/oxy/keys`,
197
/// else `~/.config/oxy/keys`.
198
pub fn default_keys_dir() -> PathBuf {
199
    if let Ok(h) = std::env::var("OXY_HOME") {
200
        return PathBuf::from(h).join("keys");
201
    }
202
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
203
        return PathBuf::from(x).join("oxy").join("keys");
204
    }
205
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
206
    PathBuf::from(home).join(".config").join("oxy").join("keys")
207
}
208
209
/// Outcome of verifying the signatures on a change (spec §14.3, §14.4).
210
#[derive(Clone, Debug, PartialEq, Eq)]
211
pub enum SignatureStatus {
212
    Unsigned,
213
    Valid { key: KeyId, name: String, email: String },
214
    Invalid(String),
215
}
216
ff0af1ee unknown 217
pub fn is_binary(content: &[u8]) -> bool {
218
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
219
}
220
221
impl Repo {
222
    // ------------------------------------------------------------ lifecycle
223
224
    pub fn init(root: &Path) -> Result<Repo> {
225
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
226
        let oxy = root.join(OXY_DIR);
227
        if oxy.exists() {
228
            return fail(format!("{} is already an oxy repository", root.display()));
229
        }
230
        fs::create_dir_all(oxy.join("ops"))?;
231
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
232
        let config = Config::default_for_user();
233
        config.save(&oxy.join("config"))?;
234
        let workspace = WorkspaceId::random();
235
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
236
        let store = Store::open(oxy.join("objects"))?;
1983686f Andy McCall ↶ 237
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), config };
ff0af1ee unknown 238
239
        let wc = repo.new_working_copy(vec![], "");
240
        let wc_hash = repo.store.put_change(&wc)?;
241
        let mut view = View::default();
86e682c2 Andy McCall ↶ 242
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
ff0af1ee unknown 243
        view.workspaces.insert(workspace, wc_hash);
244
        repo.record_op(&[], "oxy init", view)?;
245
        Ok(repo)
246
    }
247
248
    /// Open the repository containing `start` (searching upwards).
249
    pub fn open(start: &Path) -> Result<Repo> {
250
        let start = start.canonicalize()?;
251
        let mut dir: Option<&Path> = Some(&start);
252
        while let Some(d) = dir {
253
            let oxy = d.join(OXY_DIR);
254
            if oxy.is_dir() {
255
                let format = fs::read_to_string(oxy.join("format"))?;
256
                let config = Config::load(&oxy.join("config"))?;
257
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
258
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
259
                let store = Store::open(oxy.join("objects"))?;
86e682c2 Andy McCall ↶ 260
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), config };
261
                if format == FORMAT1_LINE {
262
                    repo.migrate_format1()?;
263
                } else if format != FORMAT_LINE {
264
                    return fail(format!("unsupported repository format: {}", format.trim()));
265
                }
266
                return Ok(repo);
ff0af1ee unknown 267
            }
268
            dir = d.parent();
269
        }
270
        fail(format!("no oxy repository found at or above {}", start.display()))
271
    }
272
86e682c2 Andy McCall ↶ 273
    /// Rewrite a format-1 operation log, whose bookmarks were head lists,
274
    /// into format 2, where each bookmark names a `state` object.
275
    fn migrate_format1(&self) -> Result<()> {
276
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
277
        let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
278
        // Collect the chain root-first.
279
        let mut chain: Vec<Format1Op> = Vec::new();
280
        let mut cur = Some(head);
281
        while let Some(r) = cur {
282
            let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?;
283
            cur = op.parents.first().copied();
284
            chain.push(op);
285
        }
286
        chain.reverse();
287
        let mut new_parent: Option<ObjectRef> = None;
288
        for old in chain {
289
            let mut view = View::default();
290
            for (name, heads) in &old.view.bookmarks {
291
                view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
292
            }
293
            view.workspaces = old.view.workspaces.clone();
294
            let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view };
295
            new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?);
296
        }
297
        write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?;
298
        write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?;
299
        Ok(())
300
    }
301
ff0af1ee unknown 302
    pub fn root(&self) -> &Path {
303
        &self.root
304
    }
305
    pub fn store(&self) -> &Store {
306
        &self.store
307
    }
308
    pub fn workspace(&self) -> WorkspaceId {
309
        self.workspace
310
    }
311
1983686f Andy McCall ↶ 312
    /// Use a different secret key directory (tests, or `--keys-dir`).
313
    pub fn with_keys_dir(mut self, dir: impl Into<PathBuf>) -> Repo {
314
        self.keys_dir = dir.into();
315
        self
316
    }
317
318
    pub fn save_config(&self) -> Result<()> {
319
        self.config.save(&self.oxy.join("config"))
320
    }
321
322
    fn author(&self) -> KeyId {
323
        self.config.author_key.unwrap_or(ObjectRef::ZERO)
324
    }
325
326
    // ------------------------------------------------------------ identity
327
328
    /// Generate a composite key pair, store its identity object, keep the
329
    /// secret in the key directory, and make it this repository's author.
330
    pub fn create_identity(&mut self, name: &str, email: &str) -> Result<(KeyId, Identity)> {
331
        let sk = SecretKey::generate();
332
        let id = sk.identity(name, email, Hlc::now().wall_ms);
333
        let key = self.store.put_identity(&id)?;
334
        fs::create_dir_all(&self.keys_dir)?;
335
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
336
        write_atomic(&path, &sk.encode(&key))?;
337
        #[cfg(unix)]
338
        {
339
            use std::os::unix::fs::PermissionsExt;
340
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
341
        }
342
        self.config.author_name = name.into();
343
        self.config.author_email = email.into();
344
        self.config.author_key = Some(key);
345
        self.save_config()?;
346
        Ok((key, id))
347
    }
348
349
    /// The secret key for `author.key`, if configured and present on disk.
350
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
351
        let Some(key) = self.config.author_key else { return Ok(None) };
352
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
353
        match fs::read(&path) {
354
            Ok(b) => {
355
                let (k, sk) = SecretKey::decode(&b)?;
356
                if k != key {
357
                    return fail(format!("{} holds a different key than its name", path.display()));
358
                }
359
                Ok(Some((k, sk)))
360
            }
361
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
362
            Err(e) => Err(e.into()),
363
        }
364
    }
365
366
    pub fn identity(&self, key: &KeyId) -> Result<Identity> {
367
        Ok(self.store.get_identity(key)?)
368
    }
369
370
    /// Sign a stored change with the configured secret key.
371
    pub fn sign_change(&self, hash: &ChangeHash) -> Result<Signature> {
372
        let Some((key, sk)) = self.secret_key()? else {
373
            return fail("no secret key: run `oxy identity create` first");
374
        };
375
        let sig = sk.sign(Domain::Change, &key, hash);
376
        self.store.put_signature(hash, &sig)?;
377
        Ok(sig)
378
    }
379
380
    /// Check the signatures on a change against the author-or-delegate rule.
381
    pub fn verify_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
382
        let change = self.store.get_change(hash)?;
383
        let sigs = self.store.signatures(hash)?;
384
        if sigs.is_empty() {
385
            return Ok(SignatureStatus::Unsigned);
386
        }
387
        let mut last_err = String::new();
388
        for sig in &sigs {
389
            let id = match self.store.get_identity(&sig.key) {
390
                Ok(id) => id,
391
                Err(_) => {
392
                    last_err = format!("unknown key {}", sig.key.short(12));
393
                    continue;
394
                }
395
            };
396
            let authorised = sig.key == change.author
397
                || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
398
            if !authorised {
399
                last_err = format!("key {} is neither the author nor a delegate", sig.key.short(12));
400
                continue;
401
            }
402
            match identity::verify(&id, sig, Domain::Change, hash) {
403
                Ok(()) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email }),
404
                Err(e) => last_err = e.to_string(),
405
            }
406
        }
407
        Ok(SignatureStatus::Invalid(last_err))
408
    }
409
ff0af1ee unknown 410
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
411
        let mut c = Change {
412
            change_id: ChangeId::random(),
413
            deps,
1983686f Andy McCall ↶ 414
            author: self.author(),
ff0af1ee unknown 415
            hlc: Hlc::now(),
416
            message: message.into(),
417
            tree_ops: vec![],
418
            edits: vec![],
419
            kind: ChangeKind::WorkingCopy,
420
        };
421
        c.normalize();
422
        c
423
    }
424
425
    // ------------------------------------------------------------ operation log
426
427
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
428
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
429
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
430
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
431
        Ok((r, op))
432
    }
433
434
    pub fn view(&self) -> Result<View> {
435
        Ok(self.head_op()?.1.view)
436
    }
437
438
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
439
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
440
        let r = self.store.put(ObjectType::Op, &op.encode())?;
441
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
442
        Ok(r)
443
    }
444
445
    pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> {
446
        let mut out = Vec::new();
447
        let (mut r, mut op) = self.head_op()?;
448
        loop {
449
            let next = op.parents.first().copied();
450
            out.push((r, op));
451
            match next {
452
                Some(p) => {
453
                    r = p;
454
                    op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
455
                }
456
                None => break,
457
            }
458
        }
459
        Ok(out)
460
    }
461
55e5a16a unknown ↶ 462
    /// Undo the last user command (spec §12.1). Operations that only recorded
463
    /// a working-copy snapshot are skipped, since the files are still on disk
464
    /// and the next snapshot recreates them.
ff0af1ee unknown 465
    pub fn undo(&self) -> Result<Op> {
55e5a16a unknown ↶ 466
        let (_, mut op) = self.head_op()?;
467
        while op.is_snapshot() {
468
            let Some(parent) = op.parents.first() else { break };
469
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
470
        }
ff0af1ee unknown 471
        let Some(parent) = op.parents.first() else {
472
            return fail("nothing to undo");
473
        };
474
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
475
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
476
        Ok(parent_op)
477
    }
478
55e5a16a unknown ↶ 479
    /// The last operation that was not a snapshot.
480
    pub fn last_command(&self) -> Result<Op> {
481
        let (_, mut op) = self.head_op()?;
482
        while op.is_snapshot() {
483
            let Some(parent) = op.parents.first() else { break };
484
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
485
        }
486
        Ok(op)
487
    }
488
ff0af1ee unknown 489
    // ------------------------------------------------------------ states
490
491
    /// Load the dependency closure of `heads` from the store.
492
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
493
        let mut state = State::new();
494
        let mut stack: Vec<ChangeHash> = heads.to_vec();
495
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
496
        while let Some(h) = stack.pop() {
497
            if !seen.insert(h) {
498
                continue;
499
            }
500
            let c = self.store.get_change(&h)?;
501
            stack.extend(c.deps.iter().copied());
502
            state.add_unchecked(c);
503
        }
504
        state.validate()?;
505
        Ok(state)
506
    }
507
508
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
509
        let view = self.view()?;
510
        let h = *view
511
            .workspaces
512
            .get(&self.workspace)
513
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
514
        Ok((h, self.store.get_change(&h)?))
515
    }
516
86e682c2 Andy McCall ↶ 517
    /// Heads of a bookmark's state (spec §10.1).
518
    pub fn heads_of(&self, view: &View, bookmark: &str) -> Result<Vec<ChangeHash>> {
519
        match view.bookmarks.get(bookmark) {
520
            Some(st) => Ok(self.store.get_state(st)?.heads),
521
            None => Ok(vec![]),
522
        }
523
    }
524
ff0af1ee unknown 525
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
86e682c2 Andy McCall ↶ 526
        self.heads_of(&self.view()?, &self.config.bookmark)
ff0af1ee unknown 527
    }
528
529
    // ------------------------------------------------------------ blobs
530
531
    fn blob_for(content: &[u8]) -> Blob {
532
        Blob {
533
            length: content.len() as u64,
f9c98dee unknown ↶ 534
            chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(),
ff0af1ee unknown 535
        }
536
    }
537
538
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
539
        Self::blob_for(content).hash()
540
    }
541
542
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
f9c98dee unknown ↶ 543
        for c in crate::chunk::chunks(content) {
ff0af1ee unknown 544
            self.store.put_chunk(c)?;
545
        }
546
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
547
    }
548
549
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
550
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
551
        let mut out = Vec::with_capacity(blob.length as usize);
552
        for c in &blob.chunks {
553
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
554
        }
555
        Ok(out)
556
    }
557
558
    // ------------------------------------------------------------ working copy
559
560
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
561
        let mut wb = ignore::WalkBuilder::new(&self.root);
562
        wb.hidden(false)
563
            .git_ignore(false)
564
            .git_global(false)
565
            .git_exclude(false)
566
            .require_git(false)
567
            .follow_links(false)
568
            .add_custom_ignore_filename(".oxyignore");
569
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
570
        let mut out = BTreeMap::new();
571
        for entry in wb.build() {
572
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
573
            let path = entry.path();
574
            if path == self.root {
575
                continue;
576
            }
577
            let rel = path.strip_prefix(&self.root).unwrap();
578
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
579
            let Some(ft) = entry.file_type() else { continue };
580
            if ft.is_dir() {
581
                continue;
582
            }
583
            if ft.is_symlink() {
584
                let target = fs::read_link(path)?;
585
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
586
            } else if ft.is_file() {
587
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
588
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
589
            }
590
        }
591
        Ok(out)
592
    }
593
594
    /// Snapshot the working copy into the working-copy change (spec §12.3).
595
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
596
        let (op_ref, op) = self.head_op()?;
597
        let (old_hash, old_wc) = self.working_copy()?;
598
        let base = self.load_state(&old_wc.deps)?;
599
        let base_snapshot = base.materialize();
600
        let fs_entries = self.walk_working_copy()?;
601
602
        // Reuse the NodeIds the previous working-copy version assigned to new
603
        // paths, so an unchanged working copy hashes identically (spec §12.3).
604
        let reuse = added_node_paths(&old_wc, &base_snapshot);
605
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
606
        for (path, fs_entry) in &fs_entries {
607
            match base_snapshot.files.get(path) {
608
                None => b.add_path(self, path, fs_entry)?,
609
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
610
            }
611
        }
612
        for (path, expected) in &base_snapshot.files {
613
            if fs_entries.contains_key(path) {
614
                continue;
615
            }
616
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
617
            if !still_needed {
618
                b.remove_path(path, expected);
619
            }
620
        }
621
622
        let mut wc = Change {
623
            change_id: old_wc.change_id,
624
            deps: vec![],
1983686f Andy McCall ↶ 625
            author: self.author(),
ff0af1ee unknown 626
            hlc: old_wc.hlc,
627
            message: old_wc.message.clone(),
628
            tree_ops: b.tree_ops,
629
            edits: b.edits,
630
            kind: ChangeKind::WorkingCopy,
631
        };
632
        let base_heads = base.maximal(&old_wc.deps);
633
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
634
        deps.extend(referenced_changes(&wc, &base));
20247a03 Andy McCall ↶ 635
        deps.extend(b.contributors.iter().copied());
ff0af1ee unknown 636
        wc.deps = deps.into_iter().collect();
637
        wc.normalize();
20247a03 Andy McCall ↶ 638
        let resolves = b.resolves;
ff0af1ee unknown 639
640
        let mut statuses = b.statuses;
641
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
642
        // Empty snapshot: hash the candidate before storing; only record an op on change.
643
        let new_hash = wc.hash();
644
        let changed = new_hash != old_hash;
645
        if changed {
646
            self.store.put_change(&wc)?;
647
            let mut view = op.view.clone();
648
            view.workspaces.insert(self.workspace, new_hash);
55e5a16a unknown ↶ 649
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
ff0af1ee unknown 650
        }
20247a03 Andy McCall ↶ 651
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves })
ff0af1ee unknown 652
    }
653
654
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
655
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
656
        let snap = self.snapshot("oxy commit")?;
657
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
658
            return fail("nothing to commit: the working copy matches its base");
659
        }
20247a03 Andy McCall ↶ 660
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
661
            return fail(format!("{} still contains conflict markers", s.path));
ff0af1ee unknown 662
        }
663
        if message.trim().is_empty() {
664
            return fail("a commit message is required");
665
        }
666
        let (op_ref, op) = self.head_op()?;
667
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
20247a03 Andy McCall ↶ 668
        if snap.resolves {
669
            // A resolution depends on every change that contributed to the
670
            // conflict (spec §8.1); those are already among the working copy's
671
            // dependencies, so keep every one that is not merely a base head.
672
            let heads: BTreeSet<ChangeHash> = snap.base.maximal(&snap.wc.deps).into_iter().collect();
673
            deps.extend(snap.wc.deps.iter().filter(|d| !heads.contains(d) || touches_conflict(&snap)).copied());
674
        }
ff0af1ee unknown 675
        for d in extra_deps {
676
            if !snap.base.contains(d) {
677
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
678
            }
679
            deps.insert(*d);
680
        }
1983686f Andy McCall ↶ 681
        if self.config.sign_changes && self.secret_key()?.is_none() {
682
            return fail("core.sign is \"change\" but no secret key is available for author.key");
683
        }
ff0af1ee unknown 684
        let mut change = Change {
685
            change_id: snap.wc.change_id,
686
            deps: deps.into_iter().collect(),
1983686f Andy McCall ↶ 687
            author: self.author(),
ff0af1ee unknown 688
            hlc: Hlc::now(),
689
            message: message.trim_end().to_string(),
690
            tree_ops: snap.wc.tree_ops.clone(),
691
            edits: snap.wc.edits.clone(),
20247a03 Andy McCall ↶ 692
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
ff0af1ee unknown 693
        };
694
        change.normalize();
695
        let hash = self.store.put_change(&change)?;
1983686f Andy McCall ↶ 696
        if self.config.sign_changes {
697
            self.sign_change(&hash)?;
698
        }
ff0af1ee unknown 699
700
        // New bookmark heads: maximal elements of old heads plus the new change.
20247a03 Andy McCall ↶ 701
        // The working copy continues on its own base plus the new change, which
702
        // is exactly what the files on disk contain; the bookmark may hold
703
        // other heads (and conflicts) the working copy has not checked out.
86e682c2 Andy McCall ↶ 704
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
20247a03 Andy McCall ↶ 705
        let old_base = snap.base.maximal(&snap.wc.deps);
706
        let mut all: Vec<ChangeHash> = old_heads.clone();
707
        all.extend(old_base.iter().copied());
708
        all.push(hash);
709
        let mut st = self.load_state(&all)?;
ff0af1ee unknown 710
        st.add(change)?;
20247a03 Andy McCall ↶ 711
        let mut with_new = old_heads.clone();
712
        with_new.push(hash);
ff0af1ee unknown 713
        let new_heads = st.maximal(&with_new);
20247a03 Andy McCall ↶ 714
        let mut wc_base = old_base.clone();
715
        wc_base.push(hash);
716
        let wc_base = st.maximal(&wc_base);
ff0af1ee unknown 717
20247a03 Andy McCall ↶ 718
        let wc = self.new_working_copy(wc_base, "");
ff0af1ee unknown 719
        let wc_hash = self.store.put_change(&wc)?;
720
        let mut view = op.view.clone();
86e682c2 Andy McCall ↶ 721
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
ff0af1ee unknown 722
        view.workspaces.insert(self.workspace, wc_hash);
723
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
724
        Ok(hash)
725
    }
726
f9c98dee unknown ↶ 727
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
728
    /// the working directory is rewritten to that state and a fresh
729
    /// working-copy change is started on it. The bookmark does not move.
730
    pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> {
731
        let snap = self.snapshot(command)?;
732
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
733
            return fail("the working copy has changes; commit them first or pass --discard");
734
        }
735
        let target_state = self.load_state(heads)?;
736
        let heads = target_state.maximal(heads);
737
        let target = target_state.materialize();
738
        let current = self.walk_working_copy()?;
739
        let mut report = CheckoutReport::default();
740
741
        // Remove tracked paths that the target does not have.
742
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
743
        for path in current.keys() {
744
            if !target.files.contains_key(path) {
745
                let full = self.safe_path(path)?;
746
                fs::remove_file(&full)?;
747
                report.removed += 1;
748
                if let Some(parent) = full.parent() {
749
                    dirs_to_prune.insert(parent.to_path_buf());
750
                }
751
            }
752
        }
753
        // Write target files that differ.
754
        for (path, f) in &target.files {
755
            match &f.content {
756
                FileContent::Dir => continue,
757
                FileContent::Text { bytes, .. } => {
758
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec);
759
                    if !same {
760
                        self.write_file(path, bytes, f.mode.exec)?;
761
                        report.written += 1;
762
                    }
763
                }
764
                FileContent::Opaque { blobs } => {
765
                    let Some(blob) = blobs.first() else { continue };
766
                    let bytes = self.read_blob(blob)?;
767
                    if f.kind == NodeKind::Symlink {
768
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
769
                        if !same {
770
                            self.write_symlink(path, &bytes)?;
771
                            report.written += 1;
772
                        }
773
                    } else {
774
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec);
775
                        if !same {
776
                            self.write_file(path, &bytes, f.mode.exec)?;
777
                            report.written += 1;
778
                        }
779
                    }
780
                    if blobs.len() > 1 {
781
                        for (i, b) in blobs.iter().enumerate().skip(1) {
782
                            self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?;
783
                        }
784
                    }
785
                }
786
            }
787
        }
788
        // Prune directories left empty, deepest first.
789
        for d in dirs_to_prune.iter().rev() {
790
            let mut d = d.clone();
791
            while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) {
792
                fs::remove_dir(&d)?;
793
                let Some(p) = d.parent() else { break };
794
                d = p.to_path_buf();
795
            }
796
        }
797
798
        let (op_ref, op) = self.head_op()?;
799
        let wc = self.new_working_copy(heads, "");
800
        let wc_hash = self.store.put_change(&wc)?;
801
        let mut view = op.view.clone();
802
        view.workspaces.insert(self.workspace, wc_hash);
803
        self.record_op(&[op_ref], command, view)?;
804
        report.conflicted = target.is_conflicted();
805
        Ok(report)
806
    }
807
808
    /// The absolute path for a repository path, refusing anything that would
809
    /// escape the workspace or write through a symlink (spec §7.7).
810
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
811
        let mut full = self.root.clone();
812
        let comps: Vec<&str> = rel.split('/').collect();
813
        for (i, comp) in comps.iter().enumerate() {
814
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
815
                return fail(format!("refusing unsafe path {rel:?}"));
816
            }
817
            if i == 0 && *comp == OXY_DIR {
818
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
819
            }
820
            if i + 1 < comps.len() {
821
                full.push(comp);
822
                if let Ok(m) = fs::symlink_metadata(&full) {
823
                    if m.file_type().is_symlink() {
824
                        return fail(format!("refusing to write through symlink {}", full.display()));
825
                    }
826
                }
827
            } else {
828
                full.push(comp);
829
            }
830
        }
831
        Ok(full)
832
    }
833
834
    fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
835
        let full = self.safe_path(rel)?;
836
        if let Some(parent) = full.parent() {
837
            fs::create_dir_all(parent)?;
838
        }
839
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
840
            remove_any(&full)?;
841
        }
842
        fs::write(&full, bytes)?;
843
        set_executable(&full, exec)?;
844
        Ok(())
845
    }
846
847
    fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
848
        let full = self.safe_path(rel)?;
849
        if let Some(parent) = full.parent() {
850
            fs::create_dir_all(parent)?;
851
        }
852
        if fs::symlink_metadata(&full).is_ok() {
853
            remove_any(&full)?;
854
        }
855
        #[cfg(unix)]
856
        {
857
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
858
            Ok(())
859
        }
860
        #[cfg(not(unix))]
861
        {
862
            // Spec §7.7: without symlink support, write the target as a plain file.
863
            fs::write(&full, target)?;
864
            Ok(())
865
        }
866
    }
867
ff0af1ee unknown 868
    /// Changes reachable from the bookmark and the working copy, newest first.
869
    pub fn log(&self) -> Result<Vec<LogEntry>> {
870
        let (wc_hash, _) = self.working_copy()?;
871
        let mut heads = self.bookmark_heads()?;
872
        heads.push(wc_hash);
873
        let state = self.load_state(&heads)?;
874
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
875
        let mut entries: Vec<LogEntry> = state
876
            .changes()
877
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
878
            .collect();
1983686f Andy McCall ↶ 879
        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)));
ff0af1ee unknown 880
        Ok(entries)
881
    }
882
883
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
884
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
885
        if spec == "@" {
886
            return Ok(self.working_copy()?.0);
887
        }
55e5a16a unknown ↶ 888
        if spec.is_empty() || spec == "#" {
889
            return fail("empty change specifier");
890
        }
ff0af1ee unknown 891
        let entries = self.log()?;
892
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
893
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
894
        } else {
895
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
896
        };
897
        match matches.len() {
898
            1 => Ok(matches[0].hash),
899
            0 => fail(format!("no change matches {spec:?}")),
900
            _ => {
901
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
902
                if ids.len() == 1 {
903
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
904
                } else {
905
                    fail(format!("{spec:?} is ambiguous"))
906
                }
907
            }
908
        }
909
    }
910
911
    /// Human-readable description of what a change does, with line content.
912
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
913
        let change = self.store.get_change(hash)?;
914
        let base = self.load_state(&change.deps)?;
915
        let base_snapshot = base.materialize();
916
        Ok(render_change(&change, &base, &base_snapshot))
917
    }
918
}
919
f9c98dee unknown ↶ 920
#[derive(Debug, Default, Clone, PartialEq, Eq)]
921
pub struct CheckoutReport {
922
    pub written: usize,
923
    pub removed: usize,
924
    pub conflicted: bool,
925
}
926
927
fn remove_any(path: &Path) -> std::io::Result<()> {
928
    let m = fs::symlink_metadata(path)?;
929
    if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
930
}
931
ff0af1ee unknown 932
#[cfg(unix)]
f9c98dee unknown ↶ 933
fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> {
934
    use std::os::unix::fs::PermissionsExt;
935
    let mut perms = fs::metadata(path)?.permissions();
936
    let mode = perms.mode();
937
    let want = if exec { mode | 0o111 } else { mode & !0o111 };
938
    if want != mode {
939
        perms.set_mode(want);
940
        fs::set_permissions(path, perms)?;
941
    }
942
    Ok(())
943
}
944
#[cfg(not(unix))]
945
fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> {
946
    Ok(())
947
}
948
949
#[cfg(unix)]
ff0af1ee unknown 950
fn is_executable(m: &fs::Metadata) -> bool {
951
    use std::os::unix::fs::PermissionsExt;
952
    m.permissions().mode() & 0o111 != 0
953
}
954
#[cfg(not(unix))]
955
fn is_executable(_m: &fs::Metadata) -> bool {
956
    false
957
}
958
959
fn op_ref(k: OpKey) -> OpRef {
960
    OpRef { change: k.0, index: k.1 }
961
}
962
963
fn split_path(path: &str) -> (&str, &str) {
964
    match path.rfind('/') {
965
        Some(i) => (&path[..i], &path[i + 1..]),
966
        None => ("", path),
967
    }
968
}
969
970
/// Builds the tree ops and edits of a working-copy change against a base.
971
struct ChangeBuilder<'a> {
972
    base: &'a State,
973
    base_snapshot: &'a Snapshot,
974
    tree_ops: Vec<TreeOp>,
975
    edits: Vec<Edit>,
976
    statuses: Vec<PathStatus>,
977
    dir_nodes: HashMap<String, NodeId>,
978
    reuse: HashMap<String, (NodeId, NodeKind)>,
20247a03 Andy McCall ↶ 979
    /// Changes that contributed to conflicts this working copy resolves.
980
    contributors: BTreeSet<ChangeHash>,
981
    resolves: bool,
ff0af1ee unknown 982
}
983
984
impl<'a> ChangeBuilder<'a> {
985
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
986
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
987
        dir_nodes.insert(String::new(), NodeId::ROOT);
988
        for (p, f) in &base_snapshot.files {
989
            if f.kind == NodeKind::Dir {
990
                dir_nodes.insert(p.clone(), f.node);
991
            }
992
        }
20247a03 Andy McCall ↶ 993
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
ff0af1ee unknown 994
    }
995
996
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
997
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
998
    }
999
1000
    fn ensure_dir(&mut self, path: &str) -> NodeId {
1001
        if let Some(n) = self.dir_nodes.get(path) {
1002
            return *n;
1003
        }
1004
        let mut parent = NodeId::ROOT;
1005
        let mut prefix = String::new();
1006
        for comp in path.split('/') {
1007
            if !prefix.is_empty() {
1008
                prefix.push('/');
1009
            }
1010
            prefix.push_str(comp);
1011
            parent = match self.dir_nodes.get(&prefix) {
1012
                Some(n) => *n,
1013
                None => {
1014
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
1015
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
1016
                    self.dir_nodes.insert(prefix.clone(), node);
1017
                    node
1018
                }
1019
            };
1020
        }
1021
        parent
1022
    }
1023
1024
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
1025
        let (dir, name) = split_path(path);
1026
        let parent = self.ensure_dir(dir);
1027
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
1028
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
1029
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
1030
        if opaque {
1031
            let blob = repo.store_blob(&e.content)?;
1032
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1033
        } else if !e.content.is_empty() {
1034
            self.edits.push(Edit::Insert {
1035
                node,
1036
                after: crate::objects::AtomRef::Start,
1037
                before: crate::objects::AtomRef::End,
1038
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
1039
            });
1040
        }
1041
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
1042
        Ok(())
1043
    }
1044
1045
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
1046
        let entry = &self.base_snapshot.tree.entries[path];
1047
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
20247a03 Andy McCall ↶ 1048
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
1049
            self.resolves = true;
1050
        }
ff0af1ee unknown 1051
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
1052
    }
1053
1054
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
1055
        if expected.kind != e.kind {
1056
            self.remove_path(path, expected);
1057
            self.statuses.pop();
1058
            self.add_path(repo, path, e)?;
1059
            self.statuses.pop();
1060
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
1061
            return Ok(());
1062
        }
1063
        let entry = &self.base_snapshot.tree.entries[path];
1064
        let node = expected.node;
1065
        let mode_atom = entry.mode_atom.map(op_ref);
1066
        let mut status: Option<ChangeStatus> = None;
1067
        match &expected.content {
1068
            FileContent::Dir => {}
1069
            FileContent::Text { bytes, materialized } => {
1070
                if materialized.is_conflicted() {
20247a03 Andy McCall ↶ 1071
                    if bytes == &e.content {
1072
                        return Ok(());
1073
                    }
1074
                    if has_conflict_markers(&e.content) {
ff0af1ee unknown 1075
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
20247a03 Andy McCall ↶ 1076
                        return Ok(());
ff0af1ee unknown 1077
                    }
20247a03 Andy McCall ↶ 1078
                    // Resolution (spec §9.6): diff against the unconflicted lines
1079
                    // with each region as a gap, then retire every region atom.
1080
                    // Whatever the author kept from a region is re-inserted as
1081
                    // new atoms, which leaves the graph totally ordered.
1082
                    let regions = self.base.conflict_regions(&node);
1083
                    let mut edits = text_edits(node, &regions.plain, &e.content);
1084
                    if !regions.region_atoms.is_empty() {
1085
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
1086
                    }
1087
                    self.edits.extend(edits);
1088
                    self.contributors.extend(regions.contributors);
1089
                    self.resolves = true;
1090
                    if e.exec != expected.mode.exec {
1091
                        if let Some(old) = mode_atom {
1092
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1093
                        }
1094
                    }
1095
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
ff0af1ee unknown 1096
                    return Ok(());
1097
                }
1098
                if bytes != &e.content {
1099
                    if is_binary(&e.content) {
1100
                        // Text → opaque (spec §7.5).
1101
                        if let Some(old) = mode_atom {
1102
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1103
                        }
1104
                        let live: Vec<_> = repo_live_keys(self.base, &node);
1105
                        if !live.is_empty() {
1106
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
1107
                        }
1108
                        let blob = repo.store_blob(&e.content)?;
1109
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
1110
                        status = Some(ChangeStatus::Modified);
1111
                    } else {
1112
                        let old = self.base.live_lines(&node);
1113
                        let edits = text_edits(node, &old, &e.content);
1114
                        if !edits.is_empty() {
1115
                            self.edits.extend(edits);
1116
                            status = Some(ChangeStatus::Modified);
1117
                        }
1118
                    }
1119
                }
55e5a16a unknown ↶ 1120
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
1121
                if !switched_to_opaque && e.exec != expected.mode.exec {
1122
                    if let Some(old) = mode_atom {
1123
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1124
                        status.get_or_insert(ChangeStatus::ModeChanged);
ff0af1ee unknown 1125
                    }
1126
                }
1127
            }
1128
            FileContent::Opaque { blobs } => {
1129
                let new_ref = Repo::blob_ref(&e.content);
1130
                if blobs.len() > 1 {
1131
                    if !blobs.contains(&new_ref) {
1132
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
1133
                    }
1134
                    return Ok(());
1135
                }
1136
                let current = blobs.first().copied();
1137
                if current != Some(new_ref) {
1138
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
1139
                        // Opaque → text (spec §7.5).
1140
                        if let Some(old) = mode_atom {
1141
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
1142
                        }
1143
                        self.edits.push(Edit::Replace { node, old: current, new: None });
1144
                        if !e.content.is_empty() {
1145
                            self.edits.push(Edit::Insert {
1146
                                node,
1147
                                after: crate::objects::AtomRef::Start,
1148
                                before: crate::objects::AtomRef::End,
1149
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
1150
                            });
1151
                        }
1152
                    } else {
1153
                        let blob = repo.store_blob(&e.content)?;
1154
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
1155
                    }
1156
                    status = Some(ChangeStatus::Modified);
1157
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
1158
                    if let Some(old) = mode_atom {
1159
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
1160
                        status = Some(ChangeStatus::ModeChanged);
1161
                    }
1162
                }
1163
            }
1164
        }
1165
        if let Some(s) = status {
1166
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
1167
        }
1168
        Ok(())
1169
    }
1170
}
1171
20247a03 Andy McCall ↶ 1172
/// Whether a working copy touches a node that is conflicted in its base.
1173
fn touches_conflict(snap: &SnapshotResult) -> bool {
1174
    let conflicted: BTreeSet<NodeId> = snap.base_snapshot.files.values().filter(|f| f.is_conflicted()).map(|f| f.node).collect();
1175
    snap.wc.tree_ops.iter().any(|op| conflicted.contains(&op.node())) || snap.wc.edits.iter().any(|e| conflicted.contains(&e.node()))
1176
}
1177
ff0af1ee unknown 1178
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
1179
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
1180
}
1181
1182
/// The changes a change's scope references (spec §8.1): introducers of every
1183
/// atom, placement, and mode it touches, and of every node it operates on.
1184
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
1185
    let mut out = BTreeSet::new();
1186
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
1187
        if !r.change.is_zero() {
1188
            out.insert(r.change);
1189
        }
1190
    };
1191
    let mut nodes: Vec<NodeId> = Vec::new();
1192
    for op in &change.tree_ops {
1193
        match op {
1194
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
1195
            TreeOp::RemoveNode { node, placement } => {
1196
                nodes.push(*node);
1197
                opref(placement, &mut out);
1198
            }
1199
            TreeOp::Move { node, old, parent, .. } => {
1200
                nodes.push(*node);
1201
                nodes.push(*parent);
1202
                opref(old, &mut out);
1203
            }
1204
            TreeOp::SetMode { node, old, .. } => {
1205
                nodes.push(*node);
1206
                opref(old, &mut out);
1207
            }
1208
        }
1209
    }
1210
    for e in &change.edits {
1211
        nodes.push(e.node());
1212
        match e {
1213
            Edit::Insert { after, before, .. } => {
1214
                for r in [after, before] {
1215
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
1216
                        if !change.is_zero() {
1217
                            out.insert(*change);
1218
                        }
1219
                    }
1220
                }
1221
            }
1222
            Edit::Delete { atoms, .. } => {
1223
                for r in atoms {
1224
                    if !r.change.is_zero() {
1225
                        out.insert(r.change);
1226
                    }
1227
                }
1228
            }
1229
            Edit::Replace { .. } => {}
1230
        }
1231
    }
1232
    for n in nodes {
1233
        if let Some(h) = base.tree().introducer(&n) {
1234
            out.insert(h);
1235
        }
1236
    }
1237
    out
1238
}
1239
1240
/// Paths of the nodes a change adds on top of `base_snapshot`.
1241
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
1242
    let by_node = base_snapshot.tree.by_node();
1243
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1244
    for op in &change.tree_ops {
1245
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1246
            new_names.insert(*node, (*parent, name.clone(), *kind));
1247
        }
1248
    }
1249
    let mut out = HashMap::new();
1250
    for (node, (_, _, kind)) in &new_names {
1251
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
1252
            out.insert(path, (*node, *kind));
1253
        }
1254
    }
1255
    out
1256
}
1257
1258
fn path_of_node(
1259
    n: &NodeId,
1260
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
1261
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
1262
) -> Option<String> {
1263
    if let Some(e) = by_node.get(n) {
1264
        return Some(e.path.clone());
1265
    }
1266
    let mut parts = Vec::new();
1267
    let mut cur = *n;
1268
    loop {
1269
        if cur.is_root() {
1270
            break;
1271
        }
1272
        if let Some(e) = by_node.get(&cur) {
1273
            if !e.path.is_empty() {
1274
                parts.push(e.path.clone());
1275
            }
1276
            break;
1277
        }
1278
        let (parent, name, _) = new_names.get(&cur)?;
1279
        parts.push(name.clone());
1280
        cur = *parent;
1281
    }
1282
    parts.reverse();
1283
    Some(parts.join("/"))
1284
}
1285
1286
/// Render a change's operations with line content, using `base` for deleted lines.
1287
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
1288
    let by_node = base_snapshot.tree.by_node();
1289
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
1290
    for op in &change.tree_ops {
1291
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
1292
            new_names.insert(*node, (*parent, name.clone(), *kind));
1293
        }
1294
    }
1295
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
1296
1297
    let mut out = String::new();
1298
    for op in &change.tree_ops {
1299
        match op {
1300
            TreeOp::AddNode { node, kind, mode, .. } => {
1301
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
1302
            }
1303
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
1304
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
1305
            TreeOp::SetMode { node, mode, .. } => {
1306
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
1307
            }
1308
        }
1309
    }
1310
    let mut last_node: Option<NodeId> = None;
1311
    for e in &change.edits {
1312
        let n = e.node();
1313
        if last_node != Some(n) {
1314
            out.push_str(&format!("--- {}\n", path_of(&n)));
1315
            last_node = Some(n);
1316
        }
1317
        match e {
1318
            Edit::Insert { lines, .. } => {
1319
                for l in lines {
1320
                    out.push('+');
1321
                    out.push_str(&String::from_utf8_lossy(l));
1322
                    if !l.ends_with(b"\n") {
1323
                        out.push('\n');
1324
                    }
1325
                }
1326
            }
1327
            Edit::Delete { atoms, .. } => {
1328
                for r in atoms {
1329
                    for i in r.from..r.to {
1330
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
1331
                        out.push('-');
1332
                        out.push_str(&String::from_utf8_lossy(content));
1333
                        if !content.ends_with(b"\n") {
1334
                            out.push('\n');
1335
                        }
1336
                    }
1337
                }
1338
            }
1339
            Edit::Replace { old, new, .. } => {
1340
                out.push_str(&format!(
1341
                    "  opaque content {} -> {}\n",
1342
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
1343
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
1344
                ));
1345
            }
1346
        }
1347
    }
1348
    out
1349
}
1350
1351
#[cfg(test)]
1352
mod tests {
1353
    use super::*;
1354
1355
    fn write(root: &Path, rel: &str, content: &[u8]) {
1356
        let p = root.join(rel);
1357
        fs::create_dir_all(p.parent().unwrap()).unwrap();
1358
        fs::write(p, content).unwrap();
1359
    }
1360
1361
    fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
1362
        s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
1363
    }
1364
1365
    #[test]
1366
    fn init_snapshot_commit_log_undo() {
1367
        let dir = tempfile::tempdir().unwrap();
1368
        let root = dir.path();
1369
        let repo = Repo::init(root).unwrap();
1370
        assert!(Repo::init(root).is_err());
1371
        let ops = repo.op_log().unwrap();
1372
        assert_eq!(ops.len(), 1);
1373
        assert_eq!(ops[0].1.command, "oxy init");
1374
1375
        // Empty working copy: snapshot records no operation (spec §12.3).
1376
        let s = repo.snapshot("oxy status").unwrap();
1377
        assert!(!s.changed);
1378
        assert_eq!(repo.op_log().unwrap().len(), 1);
1379
1380
        write(root, "src/main.rs", b"fn main() {}\n");
1381
        write(root, "README.md", b"# Hello\n");
1382
        write(root, ".oxyignore", b"/target\n");
1383
        write(root, "target/junk.o", b"\0\0\0");
1384
        let s = repo.snapshot("oxy status").unwrap();
1385
        assert!(s.changed);
1386
        assert_eq!(
1387
            statuses(&s),
1388
            vec![
1389
                (".oxyignore".into(), ChangeStatus::Added),
1390
                ("README.md".into(), ChangeStatus::Added),
1391
                ("src/main.rs".into(), ChangeStatus::Added),
1392
            ]
1393
        );
1394
        assert_eq!(repo.op_log().unwrap().len(), 2);
1395
        // Snapshotting again with nothing changed records no op.
1396
        let s2 = repo.snapshot("oxy status").unwrap();
1397
        assert!(!s2.changed);
1398
        assert_eq!(s2.wc_hash, s.wc_hash);
1399
        assert_eq!(repo.op_log().unwrap().len(), 2);
1400
1401
        let h1 = repo.commit("Initial import", &[]).unwrap();
1402
        let c1 = repo.store().get_change(&h1).unwrap();
1403
        assert_eq!(c1.kind, ChangeKind::Normal);
1404
        assert!(c1.deps.is_empty(), "first change has nothing to depend on");
1405
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1406
        let (wc_hash, wc) = repo.working_copy().unwrap();
1407
        assert_eq!(wc.deps, vec![h1]);
1408
        assert!(wc.edits.is_empty());
1409
        assert!(repo.commit("nothing", &[]).is_err());
1410
1411
        // Modify one file, remove another, add a binary file.
1412
        write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
1413
        fs::remove_file(root.join("README.md")).unwrap();
1414
        write(root, "logo.png", b"\x89PNG\0\0\x01");
1415
        let s = repo.snapshot("oxy status").unwrap();
1416
        assert_eq!(
1417
            statuses(&s),
1418
            vec![
1419
                ("README.md".into(), ChangeStatus::Removed),
1420
                ("logo.png".into(), ChangeStatus::Added),
1421
                ("src/main.rs".into(), ChangeStatus::Modified),
1422
            ]
1423
        );
1424
        assert_ne!(s.wc_hash, wc_hash);
1425
        // The rewritten working copy keeps its ChangeId and depends on h1.
1426
        assert_eq!(s.wc.change_id, wc.change_id);
1427
        assert!(s.wc.deps.contains(&h1));
1428
1429
        let h2 = repo.commit("Second", &[]).unwrap();
1430
        let c2 = repo.store().get_change(&h2).unwrap();
1431
        assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
1432
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
1433
1434
        // The materialized head state matches the working copy.
1435
        let st = repo.load_state(&[h2]).unwrap();
1436
        let snap = st.materialize();
1437
        assert!(!snap.is_conflicted());
1438
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
1439
        assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
1440
        assert!(snap.files["logo.png"].mode.opaque);
1441
        if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
1442
            assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
1443
        } else {
1444
            panic!("logo should be opaque");
1445
        }
1446
1447
        // Log: newest first, working copy included.
1448
        let log = repo.log().unwrap();
1449
        assert_eq!(log.len(), 3);
1450
        assert!(log[0].is_working_copy);
1451
        assert_eq!(log[1].hash, h2);
1452
        assert!(log[1].is_head);
1453
        assert_eq!(log[2].hash, h1);
1454
        let d = repo.describe_change(&h2).unwrap();
1455
        assert!(d.contains("D README.md"), "{d}");
1456
        assert!(d.contains("+    println!(\"hi\");"), "{d}");
1457
        assert!(d.contains("-fn main() {}"), "{d}");
1458
1459
        // Resolution by prefix.
1460
        assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
1461
        assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
1462
        assert!(repo.resolve_change("zzzz").is_err());
1463
1464
        // Undo the commit: bookmark and working copy go back.
1465
        repo.undo().unwrap();
1466
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1467
        let s = repo.snapshot("oxy status").unwrap();
1468
        assert!(!s.changed, "files on disk still match the pre-commit working copy");
1469
        assert_eq!(s.statuses.len(), 3);
1470
    }
1471
20247a03 Andy McCall ↶ 1472
    /// Make `main` conflicted: two commits on the same base that edit the same line.
1473
    fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) {
1474
        let dir = tempfile::tempdir().unwrap();
1475
        let root = dir.path();
1476
        let repo = Repo::init(root).unwrap();
1477
        write(root, "f.txt", b"a\nb\nc\n");
1478
        let h1 = repo.commit("base", &[]).unwrap();
1479
        write(root, "f.txt", b"a\nB\nc\n");
1480
        let h2 = repo.commit("upper", &[]).unwrap();
1481
        repo.checkout(&[h1], false, "checkout").unwrap();
1482
        write(root, "f.txt", b"a\nX\nc\n");
1483
        let h3 = repo.commit("ex", &[]).unwrap();
1484
        (dir, repo, h1, h2, h3)
1485
    }
1486
1487
    #[test]
1488
    fn concurrent_edits_conflict_and_can_be_resolved() {
1489
        let (dir, repo, h1, h2, h3) = conflicted_repo();
1490
        let root = dir.path();
1491
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1492
        assert_eq!(heads, [h2, h3].into_iter().collect());
1493
        let st = repo.load_state(&[h2, h3]).unwrap();
1494
        assert!(st.materialize().is_conflicted());
1495
1496
        // Checking out main writes markers.
1497
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1498
        assert!(r.conflicted);
1499
        let marked = fs::read_to_string(root.join("f.txt")).unwrap();
1500
        assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}");
1501
        assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}");
1502
        assert!(has_conflict_markers(marked.as_bytes()));
1503
        // Untouched markers: clean working copy; commit has nothing to do.
1504
        let s = repo.snapshot("status").unwrap();
1505
        assert!(s.statuses.is_empty());
1506
        assert!(!s.resolves);
1507
1508
        // Partially edited file that still has markers cannot be committed.
1509
        write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
1510
        let s = repo.snapshot("status").unwrap();
1511
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]);
1512
        assert!(repo.commit("no", &[]).is_err());
1513
1514
        // Resolve by keeping one side and adding a line elsewhere.
1515
        write(root, "f.txt", b"a\nX\nc\nd\n");
1516
        let s = repo.snapshot("status").unwrap();
1517
        assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
1518
        assert!(s.resolves);
1519
        let h4 = repo.commit("resolve", &[]).unwrap();
1520
        let c4 = repo.store().get_change(&h4).unwrap();
1521
        assert_eq!(c4.kind, ChangeKind::Resolution);
1522
        assert!(c4.deps.contains(&h2) && c4.deps.contains(&h3) && c4.deps.contains(&h1), "{:?}", c4.deps);
1523
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
1524
        let snap = repo.load_state(&[h4]).unwrap().materialize();
1525
        assert!(!snap.is_conflicted());
1526
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
1527
        assert!(!repo.snapshot("status").unwrap().changed);
1528
        // A peer holding only h2 and h3 and receiving h4 sees the same result,
1529
        // whatever order the changes arrive in.
1530
        let mut st = State::new();
1531
        for h in [h4, h3, h1, h2] {
1532
            st.add_unchecked(repo.store().get_change(&h).unwrap());
1533
        }
1534
        st.validate().unwrap();
1535
        assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
1536
    }
1537
1538
    #[test]
1539
    fn resolution_may_keep_both_sides_or_neither() {
1540
        let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
1541
        let root = dir.path();
1542
        repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1543
        // Keep both sides in a chosen order.
1544
        write(root, "f.txt", b"a\nX\nB\nc\n");
1545
        let h = repo.commit("both", &[]).unwrap();
1546
        let snap = repo.load_state(&[h]).unwrap().materialize();
1547
        assert!(!snap.is_conflicted());
1548
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n"));
1549
        // Undo and instead drop the whole region.
1550
        repo.undo().unwrap();
1551
        write(root, "f.txt", b"a\nc\n");
1552
        let h = repo.commit("neither", &[]).unwrap();
1553
        let snap = repo.load_state(&[h]).unwrap().materialize();
1554
        assert!(!snap.is_conflicted());
1555
        assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
1556
    }
1557
1558
    #[test]
1559
    fn name_conflict_resolved_by_removing_a_candidate() {
1560
        let dir = tempfile::tempdir().unwrap();
1561
        let root = dir.path();
1562
        let repo = Repo::init(root).unwrap();
1563
        write(root, "keep.txt", b"k\n");
1564
        let h1 = repo.commit("base", &[]).unwrap();
1565
        write(root, "new.txt", b"one\n");
1566
        repo.commit("one", &[]).unwrap();
1567
        repo.checkout(&[h1], false, "checkout").unwrap();
1568
        write(root, "new.txt", b"two\n");
1569
        repo.commit("two", &[]).unwrap();
1570
        let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
1571
        assert!(r.conflicted);
1572
        assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists());
1573
        assert!(!root.join("new.txt").exists());
1574
        // Keep candidate 2 under the real name.
1575
        fs::remove_file(root.join("new.txt.oxy-conflict-1")).unwrap();
1576
        fs::rename(root.join("new.txt.oxy-conflict-2"), root.join("new.txt")).unwrap();
1577
        let h = repo.commit("pick two", &[]).unwrap();
1578
        assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution);
1579
        // The resolution does not touch keep.txt, so it does not depend on h1;
1580
        // the bookmark keeps both as heads and their union is the state.
1581
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1582
        assert_eq!(heads, [h1, h].into_iter().collect());
1583
        let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
1584
        assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
1585
        assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]);
1586
        assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n"));
1587
    }
1588
ff0af1ee unknown 1589
    #[test]
1983686f Andy McCall ↶ 1590
    fn identities_and_signed_commits() {
1591
        let dir = tempfile::tempdir().unwrap();
1592
        let root = dir.path();
1593
        let mut repo = Repo::init(root).unwrap().with_keys_dir(root.join("keys"));
1594
        write(root, "a.txt", b"a\n");
1595
        let h0 = repo.commit("unsigned", &[]).unwrap();
1596
        assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
1597
        assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
1598
1599
        let (key, id) = repo.create_identity("Ada", "[email protected]").unwrap();
1600
        assert_eq!(id.hash(), key);
1601
        assert_eq!(repo.config.author_key, Some(key));
1602
        // Config survives reopen; the secret key is found again.
1603
        let reopened = Repo::open(root).unwrap().with_keys_dir(root.join("keys"));
1604
        assert_eq!(reopened.config.author_key, Some(key));
1605
        assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
1606
1607
        // Default core.sign = seal: commits carry the author but no signature.
1608
        write(root, "a.txt", b"a\nb\n");
1609
        let h1 = repo.commit("authored", &[]).unwrap();
1610
        assert_eq!(repo.store().get_change(&h1).unwrap().author, key);
1611
        assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
1612
        // Signing later works too.
1613
        repo.sign_change(&h1).unwrap();
1614
        assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
1615
1616
        repo.config.set("core.sign", "change").unwrap();
1617
        repo.save_config().unwrap();
1618
        write(root, "a.txt", b"a\nb\nc\n");
1619
        let h2 = repo.commit("signed", &[]).unwrap();
1620
        assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into() });
1621
1622
        // A signature by a key that is not the author or a delegate is rejected.
1623
        let other = SecretKey::generate();
1624
        let other_id = other.identity("Mallory", "[email protected]", 0);
1625
        let other_key = repo.store().put_identity(&other_id).unwrap();
1626
        let forged = other.sign(Domain::Change, &other_key, &h0);
1627
        repo.store().put_signature(&h0, &forged).unwrap();
1628
        assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")));
1629
1630
        // Tampering with the stored signature invalidates it.
1631
        let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
1632
        sig.ed[0] ^= 1;
1633
        repo.store().put_signature(&h2, &sig).unwrap();
1634
        assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
1635
1636
        // Without the secret key, signing at commit fails cleanly.
1637
        let no_keys = Repo::open(root).unwrap().with_keys_dir(root.join("nowhere"));
1638
        write(root, "a.txt", b"z\n");
1639
        assert!(no_keys.commit("cannot sign", &[]).is_err());
1640
    }
1641
1642
    #[test]
f9c98dee unknown ↶ 1643
    fn checkout_moves_the_working_copy() {
1644
        let dir = tempfile::tempdir().unwrap();
1645
        let root = dir.path();
1646
        let repo = Repo::init(root).unwrap();
1647
        write(root, "a.txt", b"one\n");
1648
        write(root, "sub/deep/b.txt", b"b\n");
1649
        let h1 = repo.commit("first", &[]).unwrap();
1650
        write(root, "a.txt", b"two\n");
1651
        fs::remove_file(root.join("sub/deep/b.txt")).unwrap();
1652
        write(root, "bin.dat", &[0, 1, 2, 3]);
1653
        let h2 = repo.commit("second", &[]).unwrap();
1654
1655
        let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
1656
        assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false });
1657
        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
1658
        assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
1659
        assert!(!root.join("bin.dat").exists());
1660
        let (_, wc) = repo.working_copy().unwrap();
1661
        assert_eq!(wc.deps, vec![h1]);
1662
        assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy");
1663
        // The bookmark still points at h2.
1664
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
1665
1666
        // Dirty working copy refuses without --discard.
1667
        write(root, "a.txt", b"dirty\n");
1668
        assert!(repo.checkout(&[h2], false, "oxy checkout").is_err());
1669
        let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap();
1670
        assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n");
1671
        assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]);
1672
        assert!(!root.join("sub").exists(), "empty directories are pruned");
1673
        assert_eq!(r.removed, 1);
1674
        assert!(!repo.snapshot("status").unwrap().changed);
1675
1676
        // Refuses to write through a symlinked directory.
1677
        #[cfg(unix)]
1678
        {
1679
            fs::remove_dir_all(root.join("sub")).ok();
1680
            std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap();
1681
            assert!(repo.checkout(&[h1], true, "oxy checkout").is_err());
1682
        }
1683
    }
1684
1685
    #[test]
1686
    fn large_files_are_chunked_and_read_back() {
1687
        let dir = tempfile::tempdir().unwrap();
1688
        let repo = Repo::init(dir.path()).unwrap();
1689
        let mut big = vec![0u8; 700 * 1024];
1690
        for (i, b) in big.iter_mut().enumerate() {
1691
            *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0];
1692
        }
1693
        let r = repo.store_blob(&big).unwrap();
1694
        let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap();
1695
        assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len());
1696
        assert_eq!(repo.read_blob(&r).unwrap(), big);
1697
        assert_eq!(Repo::blob_ref(&big), r);
1698
    }
1699
1700
    #[test]
86e682c2 Andy McCall ↶ 1701
    fn format1_repositories_are_migrated_on_open() {
1702
        use crate::oplog::{Format1Op, Format1View};
1703
        let dir = tempfile::tempdir().unwrap();
1704
        let root = dir.path();
1705
        let repo = Repo::init(root).unwrap();
1706
        write(root, "a.txt", b"a\n");
1707
        let h1 = repo.commit("one", &[]).unwrap();
1708
        let (wc_hash, _) = repo.working_copy().unwrap();
1709
        // Rewrite the op log the way format 1 stored it: two ops, head lists.
1710
        let mut v1 = Format1View::default();
1711
        v1.bookmarks.insert("main".into(), vec![]);
1712
        v1.workspaces.insert(repo.workspace(), wc_hash);
1713
        let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() };
1714
        let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap();
1715
        v1.bookmarks.insert("main".into(), vec![h1]);
1716
        let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 };
1717
        let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap();
1718
        write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap();
1719
        write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap();
1720
        drop(repo);
1721
1722
        let repo = Repo::open(root).unwrap();
1723
        assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE);
1724
        assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
1725
        let ops = repo.op_log().unwrap();
1726
        assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::<Vec<_>>(), ["oxy commit", "oxy init"]);
1727
        assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]);
1728
        assert!(!repo.snapshot("status").unwrap().changed);
1729
        // Reopening again is a no-op.
1730
        assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2);
1731
    }
1732
1733
    #[test]
55e5a16a unknown ↶ 1734
    fn undo_skips_snapshot_operations() {
1735
        let dir = tempfile::tempdir().unwrap();
1736
        let root = dir.path();
1737
        let repo = Repo::init(root).unwrap();
1738
        write(root, "a.txt", b"a\n");
1739
        let h1 = repo.commit("a", &[]).unwrap();
1740
        // A later command snapshots new edits on top of the commit.
1741
        write(root, "b.txt", b"b\n");
1742
        let s = repo.snapshot("oxy status").unwrap();
1743
        assert!(s.changed);
1744
        assert!(repo.head_op().unwrap().1.is_snapshot());
1745
        assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\"");
1746
        // Undo must undo the commit, not the snapshot.
1747
        repo.undo().unwrap();
1748
        assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
1749
        let s = repo.snapshot("oxy status").unwrap();
1750
        let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
1751
        assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy");
1752
        assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
1753
        // Only snapshots remain above `init`, so there is nothing left to undo.
1754
        assert_eq!(repo.last_command().unwrap().command, "oxy init");
1755
        assert!(repo.undo().is_err());
1756
    }
1757
1758
    #[test]
ff0af1ee unknown 1759
    fn independent_changes_commute_and_make_multiple_heads() {
1760
        let dir = tempfile::tempdir().unwrap();
1761
        let root = dir.path();
1762
        let repo = Repo::init(root).unwrap();
1763
        write(root, "a.txt", b"a\n");
1764
        let h1 = repo.commit("a", &[]).unwrap();
1765
        write(root, "b.txt", b"b\n");
1766
        let h2 = repo.commit("b", &[]).unwrap();
1767
        let c2 = repo.store().get_change(&h2).unwrap();
1768
        assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
1769
        let mut heads = repo.bookmark_heads().unwrap();
1770
        heads.sort();
1771
        let mut want = vec![h1, h2];
1772
        want.sort();
1773
        assert_eq!(heads, want);
1774
        // Editing a.txt depends on h1 only.
1775
        write(root, "a.txt", b"a\na2\n");
1776
        let h3 = repo.commit("a2", &[]).unwrap();
1777
        assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1778
        let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
1779
        assert_eq!(heads, [h2, h3].into_iter().collect());
1780
        // Explicit dependency.
1781
        write(root, "c.txt", b"c\n");
1782
        let h4 = repo.commit("c", &[h2]).unwrap();
1783
        assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
1784
    }
1785
1786
    #[test]
1787
    fn mode_change_and_text_binary_switch() {
1788
        let dir = tempfile::tempdir().unwrap();
1789
        let root = dir.path();
1790
        let repo = Repo::init(root).unwrap();
1791
        write(root, "run.sh", b"#!/bin/sh\n");
1792
        repo.commit("add script", &[]).unwrap();
1793
        #[cfg(unix)]
1794
        {
1795
            use std::os::unix::fs::PermissionsExt;
1796
            fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
1797
            let s = repo.snapshot("status").unwrap();
1798
            assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
1799
            let h = repo.commit("exec", &[]).unwrap();
1800
            let snap = repo.load_state(&[h]).unwrap().materialize();
1801
            assert!(snap.files["run.sh"].mode.exec);
1802
        }
1803
        // Text → binary → text.
1804
        write(root, "run.sh", b"\0binary");
1805
        let h = repo.commit("binary", &[]).unwrap();
1806
        let snap = repo.load_state(&[h]).unwrap().materialize();
1807
        assert!(snap.files["run.sh"].mode.opaque);
1808
        write(root, "run.sh", b"text again\n");
1809
        let h = repo.commit("text", &[]).unwrap();
1810
        let snap = repo.load_state(&[h]).unwrap().materialize();
1811
        assert!(!snap.files["run.sh"].mode.opaque);
1812
        assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
1813
        assert!(!snap.is_conflicted());
1814
    }
1815
}