OXYGENHUB

Split repo.rs into repo/{config,snapshot,checkout,identity,sync,tests}; hermetic OXY_HOME in tests; identity recorded in user config 982864068a59

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 18:58 UTC
Signature
✔ signed by Andy McCall
Hash
982864068a598f2092b46c1fc9f1a9d4ea3f078266fadede4e710f76109dd5db
Depends on
750251b21a96 ff0af1ee299d
Depended on by
aded4110f0bd 2f0b4b0651ea c397a8c36a9f 6f4226bfb87f 0588de69a84d 569802ebae0e 642da2636de2 64dc5197f9df c0d4075cadc0 b5e98094eb8f 80b069facc69 2452b4407ab7 ac4ec124b9f2 4afe288b7946 9bd98cfb4f7e 544d0541a7e5 c8d9059e4e70 157d02e95586 4476cdbb0443 570e35310f31 f3811eb20bea 7bd805ffb418 d3b270909c78 45a6f5ec85cf b63fc7a77fc6 f0ef9778e587 ee130dd9eef2 1ec6fdbc6bcb d8524a9f0117 e10ba80000e1 ad2496f7e608 d36ba2f79f95 028545dfa7a4 3652104ae7d3 c2e1d09047db 69953b507188 44f1ebe7aa5b 733ea16a9701 767ac6cd0545 e59512f2f075 4b9d06778581 68dcf21fe055 c96354484bd9 c895bc52e4e0 a2387d547373 f53cbe7a204e f0a517d28237 c63fed2e0465 c6e69e4b8d8f 74b4a3c62ea4 e5d9b0513f3e 3a4b3e018326 68a924846379 f2f91ceb0e9f aa4939e07a09 9b723f69b032 fe9586d93fb1 b689779e9e74 530a0fc650a2 9d580f2132f3 34fad4a92451 d6e0d5b8b339 cb8b8082fa73 bf0247ee3590 ac03130a2b95 5552e69c7325 e8e6e0cac26a 79b5a90ffd43 7505194116e7 19303b4a06fb 170bb436e1d5 f6dccc4533c8 cbe8499a5a8c 6eae06b6ec04 42ccf504aa50 72484195899a bf3a67819b59 7993d465675b 80677b7a3513 d069701a4997 dd21fd82687d 33be24095b4b 614f55e14eee ff1a64f81c1c 648189388f1a 543bbf64d127 2c9ddac3c387 47ce8b8e2d4b 7ba155f20aa0 9ae4d15a0e4b c3d2ef6104c2 8d80356371c5 9f9e72b93e4e b2ef93bb2366 a46f3e4f50b1 827281eed2ed b35df36b7dc5 dd6147dcf9c2 5b4c51307b14 360aa74a6277 236f35c0baba 0389c3e82f6c e96a430c78f3 cd4eb55e3b3d b30a880fa409 e226a6cc2f71 10b4fd430eab 1ec3f008e96a 77445673174b 3604179679eb 98a1620a3840 14edbaaf5291 ff1f62fee654 fd280228bdc2 11e2b98a3a39 d4dbab97e948 091fef80755d acadb5ed28a1 e266b7d4065c 638fb21420d7 f9466021286a f57bf6702628 d616faf5d136 ce14f0d68b15 4cce912a0953 36e51aa806c6 3f1b8fe25ec9 1966f5db15ee 76740263089c abaa8d517631 a07f60a37fb2 05059d434aca 992467d31d26 23ff1288a5dd d0990c2bff74 f9575918477f 40a40bb7b798 e83354cdac22 2306a4d24779 8457a6cc6984 d0d52107b074 4d6c9c1b358c d77e1702d770 0f93f49ce059 57d8dc05c9d4 a87c7b9d563a 2c75672fb2fe 519c7c061bcb 598009a75f6c 01a47cd7f0c8 17f94b13ae54 192fad1e46a2 8b276e0106d1 69816d688300 0554aaa7ddef d3cd7ef95da6 a4fad5b085c7 c5ce0367eddf 57eaac9107df 86e1824f2d0b 7d9dab131937 1a3b62032ed9 4fe7d6045860 6caacfff1d4f 6f8aeb996558 dfb23a3a8cfa 314db9b4f3f8 958fb27dda7c fc1a466a1e66 83d61e803ccd f7510b63cceb 8ab265dc4a51 e7ac6c2e84e4 6a81dbab471d 1c2bd9abdd4f be451356e3ef 61e3bce7ea5b 0912a0337a95 8c30d814f2cc f8c695764c95 54cd08a1e7bf cc7a00565161 e8d38e47e9d7 e3d071cecfe2 7df9007b163c afaba5f1306d 696bbf29d3eb 0bdc3f91a39f bc489b6c7f69 c863c7a7a2d1 62794ecc7f74 3e5772ccf9da 19555025a2ac e7ddd552b619 07eb99be126e 4dcb8c0916b7 387335ff7f4d 3526d339519f eae595568832 84b9e9ac0ab4 4213c41dfc64 d82e44097323 9d333fc9ca1b 119bbf50cb86 7aa2dc9369e3 b0e64e81e4fb 05e71ed6cdf1 75167672ebcd e02bf0cd0533 c792033af344 d8c086ca3eb2 c6aa5bd9d1c8 8b678b298fee 728da0f92161 957fee30826e 4dee277c327a 7b9809e67c3d b3c5a59a875a b837d2b55853 9ac1fbe211b4 694805cca585 50a046e439e9 b7f102fcbb17 8f10a07a1dbe 2b872d074ddb 30ea9c1e70fa 6e723b81f5b9 6706bdcfb63e 21b2aec279ff 28fc23d7706a 23b217d75848 6d3eefbce2f0 cdb5beba7f05 4e88e774777a 132b884b9a8d 2863456a0f6d 5259a3af8ee6 7421309f5372 135212de5370 252e8cb23fbc c3069537d7c5 79ab8528ff62 ba160f60219e c07e0e7d2d85 614541c3cebd d02e545dc265 83e0677b07fe 2b5ee371060e
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
10 atoms, 9 tree operations, 9 files

Browse the tree at this change

added crates/oxy-core/src/repo/checkout.rs +153 −0
1 +
//! Moving the working copy to another state (spec §12.3, §7.7).
2 +
3 +
use super::*;
4 +
5 +
#[derive(Debug, Default, Clone, PartialEq, Eq)]
6 +
pub struct CheckoutReport {
7 +
    pub written: usize,
8 +
    pub removed: usize,
9 +
    pub conflicted: bool,
10 +
}
11 +
12 +
impl Repo {
13 +
    /// Move the working copy to the state whose heads are `heads` (spec §12.3):
14 +
    /// the working directory is rewritten to that state and a fresh
15 +
    /// working-copy change is started on it. The bookmark does not move.
16 +
    pub fn checkout(&self, heads: &[ChangeHash], discard: bool, command: &str) -> Result<CheckoutReport> {
17 +
        let snap = self.snapshot(command)?;
18 +
        if !discard && (!snap.wc.tree_ops.is_empty() || !snap.wc.edits.is_empty()) {
19 +
            return fail("the working copy has changes; commit them first or pass --discard");
20 +
        }
21 +
        let target_state = self.load_state(heads)?;
22 +
        let heads = target_state.maximal(heads);
23 +
        let target = target_state.materialize();
24 +
        let current = self.walk_working_copy()?;
25 +
        let mut report = CheckoutReport::default();
26 +
27 +
        // Remove tracked paths that the target does not have.
28 +
        let mut dirs_to_prune: BTreeSet<PathBuf> = BTreeSet::new();
29 +
        for path in current.keys() {
30 +
            if !target.files.contains_key(path) {
31 +
                let full = self.safe_path(path)?;
32 +
                fs::remove_file(&full)?;
33 +
                report.removed += 1;
34 +
                if let Some(parent) = full.parent() {
35 +
                    dirs_to_prune.insert(parent.to_path_buf());
36 +
                }
37 +
            }
38 +
        }
39 +
        // Write target files that differ.
40 +
        for (path, f) in &target.files {
41 +
            match &f.content {
42 +
                FileContent::Dir => continue,
43 +
                FileContent::Text { bytes, .. } => {
44 +
                    let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && &c.content == bytes && c.exec == f.mode.exec);
45 +
                    if !same {
46 +
                        self.write_file(path, bytes, f.mode.exec)?;
47 +
                        report.written += 1;
48 +
                    }
49 +
                }
50 +
                FileContent::Opaque { blobs } => {
51 +
                    let Some(blob) = blobs.first() else { continue };
52 +
                    let bytes = self.read_blob(blob)?;
53 +
                    if f.kind == NodeKind::Symlink {
54 +
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::Symlink && c.content == bytes);
55 +
                        if !same {
56 +
                            self.write_symlink(path, &bytes)?;
57 +
                            report.written += 1;
58 +
                        }
59 +
                    } else {
60 +
                        let same = current.get(path).is_some_and(|c| c.kind == NodeKind::File && c.content == bytes && c.exec == f.mode.exec);
61 +
                        if !same {
62 +
                            self.write_file(path, &bytes, f.mode.exec)?;
63 +
                            report.written += 1;
64 +
                        }
65 +
                    }
66 +
                    if blobs.len() > 1 {
67 +
                        for (i, b) in blobs.iter().enumerate().skip(1) {
68 +
                            self.write_file(&format!("{path}.oxy-conflict-{}", i + 1), &self.read_blob(b)?, f.mode.exec)?;
69 +
                        }
70 +
                    }
71 +
                }
72 +
            }
73 +
        }
74 +
        // Prune directories left empty, deepest first.
75 +
        for d in dirs_to_prune.iter().rev() {
76 +
            let mut d = d.clone();
77 +
            while d != self.root && fs::read_dir(&d).map(|mut i| i.next().is_none()).unwrap_or(false) {
78 +
                fs::remove_dir(&d)?;
79 +
                let Some(p) = d.parent() else { break };
80 +
                d = p.to_path_buf();
81 +
            }
82 +
        }
83 +
84 +
        let (op_ref, op) = self.head_op()?;
85 +
        let wc = self.new_working_copy(heads, "");
86 +
        let wc_hash = self.store.put_change(&wc)?;
87 +
        let mut view = op.view.clone();
88 +
        view.workspaces.insert(self.workspace, wc_hash);
89 +
        self.record_op(&[op_ref], command, view)?;
90 +
        report.conflicted = target.is_conflicted();
91 +
        Ok(report)
92 +
    }
93 +
94 +
    /// The absolute path for a repository path, refusing anything that would
95 +
    /// escape the workspace or write through a symlink (spec §7.7).
96 +
    fn safe_path(&self, rel: &str) -> Result<PathBuf> {
97 +
        let mut full = self.root.clone();
98 +
        let comps: Vec<&str> = rel.split('/').collect();
99 +
        for (i, comp) in comps.iter().enumerate() {
100 +
            if comp.is_empty() || *comp == "." || *comp == ".." || comp.contains('\\') {
101 +
                return fail(format!("refusing unsafe path {rel:?}"));
102 +
            }
103 +
            if i == 0 && *comp == OXY_DIR {
104 +
                return fail(format!("refusing to write inside {OXY_DIR}: {rel:?}"));
105 +
            }
106 +
            if i + 1 < comps.len() {
107 +
                full.push(comp);
108 +
                if let Ok(m) = fs::symlink_metadata(&full) {
109 +
                    if m.file_type().is_symlink() {
110 +
                        return fail(format!("refusing to write through symlink {}", full.display()));
111 +
                    }
112 +
                }
113 +
            } else {
114 +
                full.push(comp);
115 +
            }
116 +
        }
117 +
        Ok(full)
118 +
    }
119 +
120 +
    fn write_file(&self, rel: &str, bytes: &[u8], exec: bool) -> Result<()> {
121 +
        let full = self.safe_path(rel)?;
122 +
        if let Some(parent) = full.parent() {
123 +
            fs::create_dir_all(parent)?;
124 +
        }
125 +
        if fs::symlink_metadata(&full).is_ok_and(|m| m.file_type().is_symlink() || m.is_dir()) {
126 +
            remove_any(&full)?;
127 +
        }
128 +
        fs::write(&full, bytes)?;
129 +
        set_executable(&full, exec)?;
130 +
        Ok(())
131 +
    }
132 +
133 +
    fn write_symlink(&self, rel: &str, target: &[u8]) -> Result<()> {
134 +
        let full = self.safe_path(rel)?;
135 +
        if let Some(parent) = full.parent() {
136 +
            fs::create_dir_all(parent)?;
137 +
        }
138 +
        if fs::symlink_metadata(&full).is_ok() {
139 +
            remove_any(&full)?;
140 +
        }
141 +
        #[cfg(unix)]
142 +
        {
143 +
            std::os::unix::fs::symlink(String::from_utf8_lossy(target).as_ref(), &full)?;
144 +
            Ok(())
145 +
        }
146 +
        #[cfg(not(unix))]
147 +
        {
148 +
            // Spec §7.7: without symlink support, write the target as a plain file.
149 +
            fs::write(&full, target)?;
150 +
            Ok(())
151 +
        }
152 +
    }
153 +
}
added crates/oxy-core/src/repo/config.rs +159 −0
1 +
//! Layered configuration: defaults, the user file, the repository file.
2 +
3 +
use super::*;
4 +
5 +
/// Effective settings: defaults, then the user file (`~/.config/oxy/config.toml`,
6 +
/// spec §19), then the repository file (`.oxy/config`). Both files are
7 +
/// `key = "value"` lines. `save` writes only the repository layer.
8 +
#[derive(Clone, Debug, PartialEq, Eq)]
9 +
pub struct Config {
10 +
    /// Keys set in the repository file (or via `set`), written by `save`.
11 +
    repo_layer: BTreeMap<String, String>,
12 +
    pub author_name: String,
13 +
    pub author_email: String,
14 +
    /// The KeyId of the author's identity, once `create_identity` has run.
15 +
    pub author_key: Option<KeyId>,
16 +
    /// `core.sign`: sign every change at commit (`true`) or leave signing to
17 +
    /// push-time seals (`false`, the spec default).
18 +
    pub sign_changes: bool,
19 +
    pub bookmark: String,
20 +
    /// `remote.<name>.url`.
21 +
    pub remotes: BTreeMap<String, String>,
22 +
    /// `remote.<name>.token`: bearer token for HTTP pushes.
23 +
    pub remote_tokens: BTreeMap<String, String>,
24 +
}
25 +
26 +
impl Config {
27 +
    pub(super) fn default_for_user() -> Config {
28 +
        let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
29 +
        Config {
30 +
            repo_layer: BTreeMap::new(),
31 +
            author_name: std::env::var("OXY_AUTHOR_NAME").unwrap_or_else(|_| user.clone()),
32 +
            author_email: std::env::var("OXY_AUTHOR_EMAIL").unwrap_or_else(|_| format!("{user}@localhost")),
33 +
            author_key: None,
34 +
            sign_changes: false,
35 +
            bookmark: "main".into(),
36 +
            remotes: BTreeMap::new(),
37 +
            remote_tokens: BTreeMap::new(),
38 +
        }
39 +
    }
40 +
41 +
    /// Set a key in the repository layer, as `oxy config` does.
42 +
    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
43 +
        self.apply(key, value)?;
44 +
        if value.is_empty() {
45 +
            self.repo_layer.remove(key);
46 +
        } else {
47 +
            self.repo_layer.insert(key.to_string(), value.to_string());
48 +
        }
49 +
        Ok(())
50 +
    }
51 +
52 +
    /// Set a key in the user file without touching any repository.
53 +
    pub fn set_user(user_path: &Path, key: &str, value: &str) -> Result<()> {
54 +
        Config::default_for_user().apply(key, value)?;
55 +
        let mut entries = read_config_file(user_path)?;
56 +
        if value.is_empty() {
57 +
            entries.remove(key);
58 +
        } else {
59 +
            entries.insert(key.to_string(), value.to_string());
60 +
        }
61 +
        if let Some(parent) = user_path.parent() {
62 +
            fs::create_dir_all(parent)?;
63 +
        }
64 +
        write_config_file(user_path, &entries)
65 +
    }
66 +
67 +
    /// Apply a key to the effective values.
68 +
    pub(super) fn apply(&mut self, key: &str, value: &str) -> Result<()> {
69 +
        match key {
70 +
            "author.name" => self.author_name = value.into(),
71 +
            "author.email" => self.author_email = value.into(),
72 +
            "author.key" => {
73 +
                self.author_key = if value.is_empty() {
74 +
                    None
75 +
                } else {
76 +
                    Some(KeyId::from_hex(value).ok_or_else(|| RepoError::Msg("author.key must be a 64-character hex KeyId".into()))?)
77 +
                }
78 +
            }
79 +
            "core.sign" => {
80 +
                self.sign_changes = match value {
81 +
                    "change" => true,
82 +
                    "seal" => false,
83 +
                    _ => return fail("core.sign must be \"change\" or \"seal\""),
84 +
                }
85 +
            }
86 +
            "workspace.bookmark" => self.bookmark = value.into(),
87 +
            k if k.starts_with("remote.") && (k.ends_with(".url") || k.ends_with(".token")) => {
88 +
                let (name, is_url) = match k.strip_suffix(".url") {
89 +
                    Some(n) => (&n["remote.".len()..], true),
90 +
                    None => (&k["remote.".len()..k.len() - ".token".len()], false),
91 +
                };
92 +
                if name.is_empty() || name.contains('/') {
93 +
                    return fail(format!("invalid remote name in {key:?}"));
94 +
                }
95 +
                let map = if is_url { &mut self.remotes } else { &mut self.remote_tokens };
96 +
                if value.is_empty() {
97 +
                    map.remove(name);
98 +
                    if is_url {
99 +
                        self.remote_tokens.remove(name);
100 +
                    }
101 +
                } else {
102 +
                    map.insert(name.to_string(), value.into());
103 +
                }
104 +
            }
105 +
            _ => return fail(format!("unknown configuration key {key:?}")),
106 +
        }
107 +
        Ok(())
108 +
    }
109 +
110 +
    /// Defaults, then the user file if it exists, then the repository file.
111 +
    pub(super) fn load(user_path: Option<&Path>, repo_path: &Path) -> Result<Config> {
112 +
        let mut c = Config::default_for_user();
113 +
        if let Some(u) = user_path {
114 +
            for (k, v) in read_config_file(u)? {
115 +
                let _ = c.apply(&k, &v);
116 +
            }
117 +
        }
118 +
        for (k, v) in read_config_file(repo_path)? {
119 +
            if c.apply(&k, &v).is_ok() {
120 +
                c.repo_layer.insert(k, v);
121 +
            }
122 +
        }
123 +
        Ok(c)
124 +
    }
125 +
126 +
    pub(super) fn save(&self, path: &Path) -> Result<()> {
127 +
        write_config_file(path, &self.repo_layer)
128 +
    }
129 +
130 +
    /// Keys explicitly set for this repository.
131 +
    pub fn repo_layer(&self) -> &BTreeMap<String, String> {
132 +
        &self.repo_layer
133 +
    }
134 +
}
135 +
136 +
/// Read `key = "value"` lines; a missing file is empty.
137 +
pub fn read_config_file(path: &Path) -> Result<BTreeMap<String, String>> {
138 +
    let text = match fs::read_to_string(path) {
139 +
        Ok(t) => t,
140 +
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
141 +
        Err(e) => return Err(e.into()),
142 +
    };
143 +
    let mut out = BTreeMap::new();
144 +
    for line in text.lines() {
145 +
        let line = line.trim();
146 +
        if line.is_empty() || line.starts_with('#') {
147 +
            continue;
148 +
        }
149 +
        let Some((k, v)) = line.split_once('=') else { continue };
150 +
        out.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
151 +
    }
152 +
    Ok(out)
153 +
}
154 +
155 +
fn write_config_file(path: &Path, entries: &BTreeMap<String, String>) -> Result<()> {
156 +
    let text: String = entries.iter().map(|(k, v)| format!("{k} = \"{v}\"\n")).collect();
157 +
    write_atomic(path, text.as_bytes())
158 +
}
159 +
added crates/oxy-core/src/repo/identity.rs +162 −0
1 +
//! Identities, signing, and signature verification (spec §14).
2 +
3 +
use super::*;
4 +
5 +
/// Outcome of verifying the signatures on a change (spec §14.3, §14.4).
6 +
#[derive(Clone, Debug, PartialEq, Eq)]
7 +
pub enum SignatureStatus {
8 +
    Unsigned,
9 +
    Valid { key: KeyId, name: String, email: String, sealed: bool },
10 +
    Invalid(String),
11 +
}
12 +
13 +
impl Repo {
14 +
    // ------------------------------------------------------------ identity
15 +
16 +
    /// Generate a composite key pair, store its identity object, keep the
17 +
    /// secret in the key directory, and record the author at `scope`.
18 +
    pub fn create_identity(&mut self, name: &str, email: &str, scope: IdentityScope) -> Result<(KeyId, Identity)> {
19 +
        let sk = SecretKey::generate();
20 +
        let id = sk.identity(name, email, Hlc::now().wall_ms);
21 +
        let key = self.store.put_identity(&id)?;
22 +
        fs::create_dir_all(&self.keys_dir)?;
23 +
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
24 +
        write_atomic(&path, &sk.encode(&key))?;
25 +
        write_atomic(&self.keys_dir.join(format!("{}.identity", key.to_hex())), &id.encode())?;
26 +
        #[cfg(unix)]
27 +
        {
28 +
            use std::os::unix::fs::PermissionsExt;
29 +
            fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
30 +
        }
31 +
        match scope {
32 +
            IdentityScope::Repo => {
33 +
                self.config.set("author.name", name)?;
34 +
                self.config.set("author.email", email)?;
35 +
                self.config.set("author.key", &key.to_hex())?;
36 +
                self.save_config()?;
37 +
            }
38 +
            IdentityScope::User => {
39 +
                Config::set_user(&self.user_config, "author.name", name)?;
40 +
                Config::set_user(&self.user_config, "author.email", email)?;
41 +
                Config::set_user(&self.user_config, "author.key", &key.to_hex())?;
42 +
                self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?;
43 +
            }
44 +
        }
45 +
        Ok((key, id))
46 +
    }
47 +
48 +
    /// The secret key for `author.key`, if configured and present on disk.
49 +
    /// The identity object is kept beside the secret so a repository that has
50 +
    /// never seen it (a fresh clone) can store it when it first signs.
51 +
    pub fn secret_key(&self) -> Result<Option<(KeyId, SecretKey)>> {
52 +
        let Some(key) = self.config.author_key else { return Ok(None) };
53 +
        let path = self.keys_dir.join(format!("{}.secret", key.to_hex()));
54 +
        match fs::read(&path) {
55 +
            Ok(b) => {
56 +
                let (k, sk) = SecretKey::decode(&b)?;
57 +
                if k != key {
58 +
                    return fail(format!("{} holds a different key than its name", path.display()));
59 +
                }
60 +
                if !self.store.has(&key) {
61 +
                    let id_path = self.keys_dir.join(format!("{}.identity", key.to_hex()));
62 +
                    if let Ok(bytes) = fs::read(&id_path) {
63 +
                        let id = Identity::decode(&bytes)?;
64 +
                        if id.hash() == key {
65 +
                            self.store.put_identity(&id)?;
66 +
                        }
67 +
                    }
68 +
                }
69 +
                Ok(Some((k, sk)))
70 +
            }
71 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
72 +
            Err(e) => Err(e.into()),
73 +
        }
74 +
    }
75 +
76 +
    pub fn identity(&self, key: &KeyId) -> Result<Identity> {
77 +
        Ok(self.store.get_identity(key)?)
78 +
    }
79 +
80 +
    /// Sign a stored change with the configured secret key.
81 +
    pub fn sign_change(&self, hash: &ChangeHash) -> Result<Signature> {
82 +
        let Some((key, sk)) = self.secret_key()? else {
83 +
            return fail("no secret key: run `oxy identity create` first");
84 +
        };
85 +
        let sig = sk.sign(Domain::Change, &key, hash);
86 +
        self.store.put_signature(hash, &sig)?;
87 +
        Ok(sig)
88 +
    }
89 +
90 +
    /// Check the signatures on a change against the author-or-delegate rule.
91 +
    pub fn verify_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
92 +
        let change = self.store.get_change(hash)?;
93 +
        let sigs = self.store.signatures(hash)?;
94 +
        if sigs.is_empty() {
95 +
            return Ok(SignatureStatus::Unsigned);
96 +
        }
97 +
        let mut last_err = String::new();
98 +
        for sig in &sigs {
99 +
            match self.check_signer(&change, sig, Domain::Change, hash) {
100 +
                Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: false }),
101 +
                Err(e) => last_err = e,
102 +
            }
103 +
        }
104 +
        Ok(SignatureStatus::Invalid(last_err))
105 +
    }
106 +
107 +
    /// Verify `sig` over `target` and check the author-or-delegate rule for `change`.
108 +
    fn check_signer(&self, change: &Change, sig: &Signature, domain: Domain, target: &ObjectRef) -> std::result::Result<Identity, String> {
109 +
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
110 +
        let authorised =
111 +
            sig.key == change.author || self.store.get_identity(&change.author).map(|a| a.delegates.contains(&sig.key)).unwrap_or(false);
112 +
        if !authorised {
113 +
            return Err(format!("key {} is neither the author nor a delegate", sig.key.short(12)));
114 +
        }
115 +
        verify_signature(&id, sig, domain, target).map_err(|e| e.to_string())?;
116 +
        Ok(id)
117 +
    }
118 +
119 +
    /// Like `verify_change`, but a change with no valid individual signature
120 +
    /// is also authenticated by a valid seal covering it (spec §14.4).
121 +
    pub fn authenticate_change(&self, hash: &ChangeHash) -> Result<SignatureStatus> {
122 +
        let direct = self.verify_change(hash)?;
123 +
        if matches!(direct, SignatureStatus::Valid { .. }) {
124 +
            return Ok(direct);
125 +
        }
126 +
        let change = self.store.get_change(hash)?;
127 +
        let mut last_err = match &direct {
128 +
            SignatureStatus::Invalid(e) => e.clone(),
129 +
            _ => String::new(),
130 +
        };
131 +
        for seal_ref in self.store.seals_for(hash)? {
132 +
            let attest = self.store.get_attest(&seal_ref)?;
133 +
            let AttestBody::Seal { hashes } = &attest.body else { continue };
134 +
            if !hashes.contains(hash) {
135 +
                continue;
136 +
            }
137 +
            for sig in self.store.signatures(&seal_ref)? {
138 +
                match self.check_signer(&change, &sig, Domain::Attest, &seal_ref) {
139 +
                    Ok(id) => return Ok(SignatureStatus::Valid { key: sig.key, name: id.name, email: id.email, sealed: true }),
140 +
                    Err(e) => last_err = e,
141 +
                }
142 +
            }
143 +
        }
144 +
        if last_err.is_empty() { Ok(SignatureStatus::Unsigned) } else { Ok(SignatureStatus::Invalid(last_err)) }
145 +
    }
146 +
147 +
    /// Store and sign an attestation with the configured key.
148 +
    pub(crate) fn sign_attest(&self, attest: &Attest) -> Result<SignedAttest> {
149 +
        let Some((key, sk)) = self.secret_key()? else {
150 +
            return fail("no secret key: run `oxy identity create` first");
151 +
        };
152 +
        let r = self.store.put_attest(attest)?;
153 +
        let sig = sk.sign(Domain::Attest, &key, &r);
154 +
        self.store.put_signature(&r, &sig)?;
155 +
        if let AttestBody::Seal { hashes } = &attest.body {
156 +
            for h in hashes {
157 +
                self.store.index_seal(h, &r)?;
158 +
            }
159 +
        }
160 +
        Ok(SignedAttest { attest: attest.clone(), sigs: vec![sig] })
161 +
    }
162 +
}
added crates/oxy-core/src/repo/mod.rs +557 −0
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::{verify as verify_signature, 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 +
mod checkout;
52 +
mod config;
53 +
mod identity;
54 +
mod snapshot;
55 +
mod sync;
56 +
#[cfg(test)]
57 +
mod tests;
58 +
59 +
pub use checkout::CheckoutReport;
60 +
pub use config::{read_config_file, Config};
61 +
pub use identity::SignatureStatus;
62 +
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
63 +
pub use sync::PullReport;
64 +
65 +
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
66 +
    let tmp = path.with_extension("tmp");
67 +
    {
68 +
        let mut f = fs::File::create(&tmp)?;
69 +
        f.write_all(data)?;
70 +
        f.sync_all()?;
71 +
    }
72 +
    fs::rename(&tmp, path)?;
73 +
    Ok(())
74 +
}
75 +
76 +
pub struct LogEntry {
77 +
    pub hash: ChangeHash,
78 +
    pub change: Change,
79 +
    pub is_working_copy: bool,
80 +
    pub is_head: bool,
81 +
}
82 +
83 +
struct FsEntry {
84 +
    kind: NodeKind,
85 +
    exec: bool,
86 +
    content: Vec<u8>,
87 +
}
88 +
89 +
pub struct Repo {
90 +
    root: PathBuf,
91 +
    oxy: PathBuf,
92 +
    store: Store,
93 +
    workspace: WorkspaceId,
94 +
    keys_dir: PathBuf,
95 +
    user_config: PathBuf,
96 +
    pub config: Config,
97 +
}
98 +
99 +
/// The user's oxy directory: `$OXY_HOME`, else `$XDG_CONFIG_HOME/oxy`, else
100 +
/// `~/.config/oxy`. It holds `config.toml` (spec §19) and `keys/`.
101 +
pub fn default_home() -> PathBuf {
102 +
    if let Ok(h) = std::env::var("OXY_HOME") {
103 +
        return PathBuf::from(h);
104 +
    }
105 +
    if let Ok(x) = std::env::var("XDG_CONFIG_HOME") {
106 +
        return PathBuf::from(x).join("oxy");
107 +
    }
108 +
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
109 +
    PathBuf::from(home).join(".config").join("oxy")
110 +
}
111 +
112 +
pub fn default_keys_dir() -> PathBuf {
113 +
    default_home().join("keys")
114 +
}
115 +
116 +
pub fn default_user_config() -> PathBuf {
117 +
    default_home().join("config.toml")
118 +
}
119 +
120 +
/// Where `create_identity` records the author.
121 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122 +
pub enum IdentityScope {
123 +
    /// The user file: every repository of this user signs with it.
124 +
    User,
125 +
    /// This repository only.
126 +
    Repo,
127 +
}
128 +
129 +
impl Repo {
130 +
    // ------------------------------------------------------------ lifecycle
131 +
132 +
    pub fn init(root: &Path) -> Result<Repo> {
133 +
        let mut id = [0u8; 16];
134 +
        rand::rngs::OsRng.fill_bytes(&mut id);
135 +
        Self::init_with_id(root, id)
136 +
    }
137 +
138 +
    /// Initialize a repository with a given repository ID (clones share the
139 +
    /// ID of their origin, spec §14.4).
140 +
    pub fn init_with_id(root: &Path, id: [u8; 16]) -> Result<Repo> {
141 +
        fs::create_dir_all(root)?;
142 +
        let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
143 +
        let oxy = root.join(OXY_DIR);
144 +
        if oxy.exists() {
145 +
            return fail(format!("{} is already an oxy repository", root.display()));
146 +
        }
147 +
        fs::create_dir_all(oxy.join("ops"))?;
148 +
        write_atomic(&oxy.join("format"), FORMAT_LINE.as_bytes())?;
149 +
        write_atomic(&oxy.join("id"), hex::encode(id).as_bytes())?;
150 +
        let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
151 +
        config.save(&oxy.join("config"))?;
152 +
        let workspace = WorkspaceId::random();
153 +
        write_atomic(&oxy.join("workspace"), workspace.to_hex().as_bytes())?;
154 +
        let store = Store::open(oxy.join("objects"))?;
155 +
        let repo = Repo { root, oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
156 +
157 +
        let wc = repo.new_working_copy(vec![], "");
158 +
        let wc_hash = repo.store.put_change(&wc)?;
159 +
        let mut view = View::default();
160 +
        view.bookmarks.insert(repo.config.bookmark.clone(), repo.store.put_state(&StateObject::new(vec![]))?);
161 +
        view.workspaces.insert(workspace, wc_hash);
162 +
        repo.record_op(&[], "oxy init", view)?;
163 +
        Ok(repo)
164 +
    }
165 +
166 +
    /// Open the repository containing `start` (searching upwards).
167 +
    pub fn open(start: &Path) -> Result<Repo> {
168 +
        let start = start.canonicalize()?;
169 +
        let mut dir: Option<&Path> = Some(&start);
170 +
        while let Some(d) = dir {
171 +
            let oxy = d.join(OXY_DIR);
172 +
            if oxy.is_dir() {
173 +
                let format = fs::read_to_string(oxy.join("format"))?;
174 +
                let config = Config::load(Some(&default_user_config()), &oxy.join("config"))?;
175 +
                let workspace = WorkspaceId::from_hex(fs::read_to_string(oxy.join("workspace"))?.trim())
176 +
                    .ok_or_else(|| RepoError::Msg("corrupt .oxy/workspace".into()))?;
177 +
                let store = Store::open(oxy.join("objects"))?;
178 +
                let repo = Repo { root: d.to_path_buf(), oxy, store, workspace, keys_dir: default_keys_dir(), user_config: default_user_config(), config };
179 +
                if format == FORMAT1_LINE {
180 +
                    repo.migrate_format1()?;
181 +
                } else if format != FORMAT_LINE {
182 +
                    return fail(format!("unsupported repository format: {}", format.trim()));
183 +
                }
184 +
                return Ok(repo);
185 +
            }
186 +
            dir = d.parent();
187 +
        }
188 +
        fail(format!("no oxy repository found at or above {}", start.display()))
189 +
    }
190 +
191 +
    /// Rewrite a format-1 operation log, whose bookmarks were head lists,
192 +
    /// into format 2, where each bookmark names a `state` object.
193 +
    fn migrate_format1(&self) -> Result<()> {
194 +
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
195 +
        let head = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
196 +
        // Collect the chain root-first.
197 +
        let mut chain: Vec<Format1Op> = Vec::new();
198 +
        let mut cur = Some(head);
199 +
        while let Some(r) = cur {
200 +
            let op = Op::decode_format1(&self.store.get_typed(&r, ObjectType::Op)?)?;
201 +
            cur = op.parents.first().copied();
202 +
            chain.push(op);
203 +
        }
204 +
        chain.reverse();
205 +
        let mut new_parent: Option<ObjectRef> = None;
206 +
        for old in chain {
207 +
            let mut view = View::default();
208 +
            for (name, heads) in &old.view.bookmarks {
209 +
                view.bookmarks.insert(name.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
210 +
            }
211 +
            view.workspaces = old.view.workspaces.clone();
212 +
            let op = Op { parents: new_parent.into_iter().collect(), hlc: old.hlc, command: old.command, view };
213 +
            new_parent = Some(self.store.put(ObjectType::Op, &op.encode())?);
214 +
        }
215 +
        write_atomic(&self.oxy.join("ops").join("head"), new_parent.unwrap().to_hex().as_bytes())?;
216 +
        write_atomic(&self.oxy.join("format"), FORMAT_LINE.as_bytes())?;
217 +
        Ok(())
218 +
    }
219 +
220 +
    /// The repository ID (spec §14.4). Repositories made before IDs existed get one on first use.
221 +
    pub fn id(&self) -> Result<[u8; 16]> {
222 +
        let path = self.oxy.join("id");
223 +
        match fs::read_to_string(&path) {
224 +
            Ok(h) => hex::decode(h.trim()).ok().and_then(|v| v.try_into().ok()).ok_or_else(|| RepoError::Msg("corrupt .oxy/id".into())),
225 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
226 +
                let mut id = [0u8; 16];
227 +
                rand::rngs::OsRng.fill_bytes(&mut id);
228 +
                write_atomic(&path, hex::encode(id).as_bytes())?;
229 +
                Ok(id)
230 +
            }
231 +
            Err(e) => Err(e.into()),
232 +
        }
233 +
    }
234 +
235 +
    pub fn root(&self) -> &Path {
236 +
        &self.root
237 +
    }
238 +
    pub fn store(&self) -> &Store {
239 +
        &self.store
240 +
    }
241 +
    pub fn workspace(&self) -> WorkspaceId {
242 +
        self.workspace
243 +
    }
244 +
245 +
    /// Use a different user directory for keys and the user config file
246 +
    /// (tests, or `$OXY_HOME`). Reloads the effective configuration.
247 +
    pub fn with_home(mut self, dir: impl Into<PathBuf>) -> Repo {
248 +
        let dir = dir.into();
249 +
        self.keys_dir = dir.join("keys");
250 +
        self.user_config = dir.join("config.toml");
251 +
        if let Ok(c) = Config::load(Some(&self.user_config), &self.oxy.join("config")) {
252 +
            self.config = c;
253 +
        }
254 +
        self
255 +
    }
256 +
257 +
    pub fn user_config_path(&self) -> &Path {
258 +
        &self.user_config
259 +
    }
260 +
261 +
    /// Set a key in the user file and refresh the effective configuration.
262 +
    pub fn set_user_config(&mut self, key: &str, value: &str) -> Result<()> {
263 +
        Config::set_user(&self.user_config, key, value)?;
264 +
        self.config = Config::load(Some(&self.user_config), &self.oxy.join("config"))?;
265 +
        Ok(())
266 +
    }
267 +
268 +
    pub fn save_config(&self) -> Result<()> {
269 +
        self.config.save(&self.oxy.join("config"))
270 +
    }
271 +
272 +
    fn author(&self) -> KeyId {
273 +
        self.config.author_key.unwrap_or(ObjectRef::ZERO)
274 +
    }
275 +
}
276 +
277 +
impl Repo {
278 +
    fn new_working_copy(&self, deps: Vec<ChangeHash>, message: &str) -> Change {
279 +
        let mut c = Change {
280 +
            change_id: ChangeId::random(),
281 +
            deps,
282 +
            author: self.author(),
283 +
            hlc: Hlc::now(),
284 +
            message: message.into(),
285 +
            tree_ops: vec![],
286 +
            edits: vec![],
287 +
            kind: ChangeKind::WorkingCopy,
288 +
        };
289 +
        c.normalize();
290 +
        c
291 +
    }
292 +
293 +
    // ------------------------------------------------------------ operation log
294 +
295 +
    pub fn head_op(&self) -> Result<(ObjectRef, Op)> {
296 +
        let hex = fs::read_to_string(self.oxy.join("ops").join("head"))?;
297 +
        let r = ObjectRef::from_hex(hex.trim()).ok_or_else(|| RepoError::Msg("corrupt .oxy/ops/head".into()))?;
298 +
        let op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
299 +
        Ok((r, op))
300 +
    }
301 +
302 +
    pub fn view(&self) -> Result<View> {
303 +
        Ok(self.head_op()?.1.view)
304 +
    }
305 +
306 +
    fn record_op(&self, parents: &[ObjectRef], command: &str, view: View) -> Result<ObjectRef> {
307 +
        let op = Op { parents: parents.to_vec(), hlc: Hlc::now(), command: command.into(), view };
308 +
        let r = self.store.put(ObjectType::Op, &op.encode())?;
309 +
        write_atomic(&self.oxy.join("ops").join("head"), r.to_hex().as_bytes())?;
310 +
        Ok(r)
311 +
    }
312 +
313 +
    pub fn op_log(&self) -> Result<Vec<(ObjectRef, Op)>> {
314 +
        let mut out = Vec::new();
315 +
        let (mut r, mut op) = self.head_op()?;
316 +
        loop {
317 +
            let next = op.parents.first().copied();
318 +
            out.push((r, op));
319 +
            match next {
320 +
                Some(p) => {
321 +
                    r = p;
322 +
                    op = Op::decode(&self.store.get_typed(&r, ObjectType::Op)?)?;
323 +
                }
324 +
                None => break,
325 +
            }
326 +
        }
327 +
        Ok(out)
328 +
    }
329 +
330 +
    /// Undo the last user command (spec §12.1). Operations that only recorded
331 +
    /// a working-copy snapshot are skipped, since the files are still on disk
332 +
    /// and the next snapshot recreates them.
333 +
    pub fn undo(&self) -> Result<Op> {
334 +
        let (_, mut op) = self.head_op()?;
335 +
        while op.is_snapshot() {
336 +
            let Some(parent) = op.parents.first() else { break };
337 +
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
338 +
        }
339 +
        let Some(parent) = op.parents.first() else {
340 +
            return fail("nothing to undo");
341 +
        };
342 +
        let parent_op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
343 +
        write_atomic(&self.oxy.join("ops").join("head"), parent.to_hex().as_bytes())?;
344 +
        Ok(parent_op)
345 +
    }
346 +
347 +
    /// The last operation that was not a snapshot.
348 +
    pub fn last_command(&self) -> Result<Op> {
349 +
        let (_, mut op) = self.head_op()?;
350 +
        while op.is_snapshot() {
351 +
            let Some(parent) = op.parents.first() else { break };
352 +
            op = Op::decode(&self.store.get_typed(parent, ObjectType::Op)?)?;
353 +
        }
354 +
        Ok(op)
355 +
    }
356 +
357 +
    // ------------------------------------------------------------ states
358 +
359 +
    /// Load the dependency closure of `heads` from the store.
360 +
    pub fn load_state(&self, heads: &[ChangeHash]) -> Result<State> {
361 +
        let mut state = State::new();
362 +
        let mut stack: Vec<ChangeHash> = heads.to_vec();
363 +
        let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
364 +
        while let Some(h) = stack.pop() {
365 +
            if !seen.insert(h) {
366 +
                continue;
367 +
            }
368 +
            let c = self.store.get_change(&h)?;
369 +
            stack.extend(c.deps.iter().copied());
370 +
            state.add_unchecked(c);
371 +
        }
372 +
        state.validate()?;
373 +
        Ok(state)
374 +
    }
375 +
376 +
    pub fn working_copy(&self) -> Result<(ChangeHash, Change)> {
377 +
        let view = self.view()?;
378 +
        let h = *view
379 +
            .workspaces
380 +
            .get(&self.workspace)
381 +
            .ok_or_else(|| RepoError::Msg("this workspace is not in the current view".into()))?;
382 +
        Ok((h, self.store.get_change(&h)?))
383 +
    }
384 +
385 +
    /// Heads of a bookmark's state (spec §10.1).
386 +
    pub fn heads_of(&self, view: &View, bookmark: &str) -> Result<Vec<ChangeHash>> {
387 +
        match view.bookmarks.get(bookmark) {
388 +
            Some(st) => Ok(self.store.get_state(st)?.heads),
389 +
            None => Ok(vec![]),
390 +
        }
391 +
    }
392 +
393 +
    pub fn bookmark_heads(&self) -> Result<Vec<ChangeHash>> {
394 +
        self.heads_of(&self.view()?, &self.config.bookmark)
395 +
    }
396 +
397 +
    // ------------------------------------------------------------ blobs
398 +
399 +
    fn blob_for(content: &[u8]) -> Blob {
400 +
        Blob {
401 +
            length: content.len() as u64,
402 +
            chunks: crate::chunk::chunks(content).into_iter().map(|c| hash_object(ObjectType::Chunk, c)).collect(),
403 +
        }
404 +
    }
405 +
406 +
    pub fn blob_ref(content: &[u8]) -> ObjectRef {
407 +
        Self::blob_for(content).hash()
408 +
    }
409 +
410 +
    pub fn store_blob(&self, content: &[u8]) -> Result<ObjectRef> {
411 +
        for c in crate::chunk::chunks(content) {
412 +
            self.store.put_chunk(c)?;
413 +
        }
414 +
        Ok(self.store.put(ObjectType::Blob, &Self::blob_for(content).encode())?)
415 +
    }
416 +
417 +
    pub fn read_blob(&self, r: &ObjectRef) -> Result<Vec<u8>> {
418 +
        let blob = Blob::decode(&self.store.get_typed(r, ObjectType::Blob)?)?;
419 +
        let mut out = Vec::with_capacity(blob.length as usize);
420 +
        for c in &blob.chunks {
421 +
            out.extend(self.store.get_typed(c, ObjectType::Chunk)?);
422 +
        }
423 +
        Ok(out)
424 +
    }
425 +
426 +
    // ------------------------------------------------------------ working copy
427 +
428 +
    fn walk_working_copy(&self) -> Result<BTreeMap<String, FsEntry>> {
429 +
        let mut wb = ignore::WalkBuilder::new(&self.root);
430 +
        wb.hidden(false)
431 +
            .git_ignore(false)
432 +
            .git_global(false)
433 +
            .git_exclude(false)
434 +
            .require_git(false)
435 +
            .follow_links(false)
436 +
            .add_custom_ignore_filename(".oxyignore");
437 +
        wb.filter_entry(|e| e.file_name() != OXY_DIR);
438 +
        let mut out = BTreeMap::new();
439 +
        for entry in wb.build() {
440 +
            let entry = entry.map_err(|e| RepoError::Msg(e.to_string()))?;
441 +
            let path = entry.path();
442 +
            if path == self.root {
443 +
                continue;
444 +
            }
445 +
            let rel = path.strip_prefix(&self.root).unwrap();
446 +
            let rel: String = rel.components().map(|c| c.as_os_str().to_string_lossy().to_string()).collect::<Vec<_>>().join("/");
447 +
            let Some(ft) = entry.file_type() else { continue };
448 +
            if ft.is_dir() {
449 +
                continue;
450 +
            }
451 +
            if ft.is_symlink() {
452 +
                let target = fs::read_link(path)?;
453 +
                out.insert(rel, FsEntry { kind: NodeKind::Symlink, exec: false, content: target.to_string_lossy().as_bytes().to_vec() });
454 +
            } else if ft.is_file() {
455 +
                let exec = is_executable(&entry.metadata().map_err(|e| RepoError::Msg(e.to_string()))?);
456 +
                out.insert(rel, FsEntry { kind: NodeKind::File, exec, content: fs::read(path)? });
457 +
            }
458 +
        }
459 +
        Ok(out)
460 +
    }
461 +
}
462 +
463 +
impl Repo {
464 +
    /// Changes reachable from the bookmark and the working copy, newest first.
465 +
    pub fn log(&self) -> Result<Vec<LogEntry>> {
466 +
        let (wc_hash, _) = self.working_copy()?;
467 +
        let mut heads = self.bookmark_heads()?;
468 +
        heads.push(wc_hash);
469 +
        let state = self.load_state(&heads)?;
470 +
        let head_set: BTreeSet<ChangeHash> = self.bookmark_heads()?.into_iter().collect();
471 +
        let mut entries: Vec<LogEntry> = state
472 +
            .changes()
473 +
            .map(|(h, c)| LogEntry { hash: *h, change: c.clone(), is_working_copy: *h == wc_hash, is_head: head_set.contains(h) })
474 +
            .collect();
475 +
        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)));
476 +
        Ok(entries)
477 +
    }
478 +
479 +
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
480 +
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
481 +
        if spec == "@" {
482 +
            return Ok(self.working_copy()?.0);
483 +
        }
484 +
        if spec.is_empty() || spec == "#" {
485 +
            return fail("empty change specifier");
486 +
        }
487 +
        let entries = self.log()?;
488 +
        let matches: Vec<&LogEntry> = if let Some(hp) = spec.strip_prefix('#') {
489 +
            entries.iter().filter(|e| e.hash.starts_with_hex(hp)).collect()
490 +
        } else {
491 +
            entries.iter().filter(|e| e.change.change_id.starts_with_hex(spec)).collect()
492 +
        };
493 +
        match matches.len() {
494 +
            1 => Ok(matches[0].hash),
495 +
            0 => fail(format!("no change matches {spec:?}")),
496 +
            _ => {
497 +
                let ids: BTreeSet<ChangeId> = matches.iter().map(|e| e.change.change_id).collect();
498 +
                if ids.len() == 1 {
499 +
                    fail(format!("change {spec} is divergent: {} variants", matches.len()))
500 +
                } else {
501 +
                    fail(format!("{spec:?} is ambiguous"))
502 +
                }
503 +
            }
504 +
        }
505 +
    }
506 +
507 +
    /// Human-readable description of what a change does, with line content.
508 +
    pub fn describe_change(&self, hash: &ChangeHash) -> Result<String> {
509 +
        let change = self.store.get_change(hash)?;
510 +
        let base = self.load_state(&change.deps)?;
511 +
        let base_snapshot = base.materialize();
512 +
        Ok(render_change(&change, &base, &base_snapshot))
513 +
    }
514 +
}
515 +
516 +
fn remove_any(path: &Path) -> std::io::Result<()> {
517 +
    let m = fs::symlink_metadata(path)?;
518 +
    if m.is_dir() { fs::remove_dir_all(path) } else { fs::remove_file(path) }
519 +
}
520 +
521 +
#[cfg(unix)]
522 +
fn set_executable(path: &Path, exec: bool) -> std::io::Result<()> {
523 +
    use std::os::unix::fs::PermissionsExt;
524 +
    let mut perms = fs::metadata(path)?.permissions();
525 +
    let mode = perms.mode();
526 +
    let want = if exec { mode | 0o111 } else { mode & !0o111 };
527 +
    if want != mode {
528 +
        perms.set_mode(want);
529 +
        fs::set_permissions(path, perms)?;
530 +
    }
531 +
    Ok(())
532 +
}
533 +
#[cfg(not(unix))]
534 +
fn set_executable(_path: &Path, _exec: bool) -> std::io::Result<()> {
535 +
    Ok(())
536 +
}
537 +
538 +
#[cfg(unix)]
539 +
fn is_executable(m: &fs::Metadata) -> bool {
540 +
    use std::os::unix::fs::PermissionsExt;
541 +
    m.permissions().mode() & 0o111 != 0
542 +
}
543 +
#[cfg(not(unix))]
544 +
fn is_executable(_m: &fs::Metadata) -> bool {
545 +
    false
546 +
}
547 +
548 +
fn op_ref(k: OpKey) -> OpRef {
549 +
    OpRef { change: k.0, index: k.1 }
550 +
}
551 +
552 +
fn split_path(path: &str) -> (&str, &str) {
553 +
    match path.rfind('/') {
554 +
        Some(i) => (&path[..i], &path[i + 1..]),
555 +
        None => ("", path),
556 +
    }
557 +
}
added crates/oxy-core/src/repo/snapshot.rs +556 −0
1 +
//! Working-copy snapshots, commits, and the change builder (spec §12.3).
2 +
3 +
use super::*;
4 +
5 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6 +
pub enum ChangeStatus {
7 +
    Added,
8 +
    Modified,
9 +
    Removed,
10 +
    ModeChanged,
11 +
    /// The file is conflicted in the base state and still contains conflict markers.
12 +
    ConflictEdited,
13 +
    /// The file was conflicted in the base state and the working copy resolves it.
14 +
    Resolved,
15 +
}
16 +
17 +
/// Lines that mark a conflict region in a materialized file (spec §9.5).
18 +
pub fn has_conflict_markers(content: &[u8]) -> bool {
19 +
    split_lines(content).iter().any(|l| {
20 +
        l.starts_with(b"<<<<<<< oxy conflict") || l.starts_with(b"======= oxy conflict") || l.starts_with(b">>>>>>> oxy conflict")
21 +
    })
22 +
}
23 +
24 +
#[derive(Clone, Debug, PartialEq, Eq)]
25 +
pub struct PathStatus {
26 +
    pub path: String,
27 +
    pub status: ChangeStatus,
28 +
}
29 +
30 +
/// Result of snapshotting the working copy.
31 +
pub struct SnapshotResult {
32 +
    pub wc_hash: ChangeHash,
33 +
    pub wc: Change,
34 +
    /// The base state (closure of the working-copy change's dependencies).
35 +
    pub base: State,
36 +
    pub base_snapshot: Snapshot,
37 +
    pub statuses: Vec<PathStatus>,
38 +
    /// Whether the snapshot rewrote the working-copy change.
39 +
    pub changed: bool,
40 +
    /// Whether the working copy resolves conflicts of its base (spec §9.6).
41 +
    pub resolves: bool,
42 +
    /// Changes that contributed to the conflicts being resolved.
43 +
    pub contributors: Vec<ChangeHash>,
44 +
}
45 +
46 +
pub fn is_binary(content: &[u8]) -> bool {
47 +
    content[..content.len().min(BINARY_SNIFF)].contains(&0)
48 +
}
49 +
50 +
impl Repo {
51 +
    /// Snapshot the working copy into the working-copy change (spec §12.3).
52 +
    pub fn snapshot(&self, command: &str) -> Result<SnapshotResult> {
53 +
        let (op_ref, op) = self.head_op()?;
54 +
        let (old_hash, old_wc) = self.working_copy()?;
55 +
        let base = self.load_state(&old_wc.deps)?;
56 +
        let base_snapshot = base.materialize();
57 +
        let fs_entries = self.walk_working_copy()?;
58 +
59 +
        // Reuse the NodeIds the previous working-copy version assigned to new
60 +
        // paths, so an unchanged working copy hashes identically (spec §12.3).
61 +
        let reuse = added_node_paths(&old_wc, &base_snapshot);
62 +
        let mut b = ChangeBuilder::new(&base, &base_snapshot, reuse);
63 +
        for (path, fs_entry) in &fs_entries {
64 +
            match base_snapshot.files.get(path) {
65 +
                None => b.add_path(self, path, fs_entry)?,
66 +
                Some(expected) => b.update_path(self, path, fs_entry, expected)?,
67 +
            }
68 +
        }
69 +
        for (path, expected) in &base_snapshot.files {
70 +
            if fs_entries.contains_key(path) {
71 +
                continue;
72 +
            }
73 +
            let still_needed = expected.kind == NodeKind::Dir && fs_entries.keys().any(|p| p.starts_with(&format!("{path}/")));
74 +
            if !still_needed {
75 +
                b.remove_path(path, expected);
76 +
            }
77 +
        }
78 +
79 +
        let mut wc = Change {
80 +
            change_id: old_wc.change_id,
81 +
            deps: vec![],
82 +
            author: self.author(),
83 +
            hlc: old_wc.hlc,
84 +
            message: old_wc.message.clone(),
85 +
            tree_ops: b.tree_ops,
86 +
            edits: b.edits,
87 +
            kind: ChangeKind::WorkingCopy,
88 +
        };
89 +
        let base_heads = base.maximal(&old_wc.deps);
90 +
        let mut deps: BTreeSet<ChangeHash> = base_heads.into_iter().collect();
91 +
        deps.extend(referenced_changes(&wc, &base));
92 +
        deps.extend(b.contributors.iter().copied());
93 +
        wc.deps = deps.into_iter().collect();
94 +
        wc.normalize();
95 +
        let resolves = b.resolves;
96 +
        let contributors: Vec<ChangeHash> = b.contributors.iter().copied().collect();
97 +
98 +
        let mut statuses = b.statuses;
99 +
        statuses.sort_by(|a, b| a.path.cmp(&b.path));
100 +
        // Empty snapshot: hash the candidate before storing; only record an op on change.
101 +
        let new_hash = wc.hash();
102 +
        let changed = new_hash != old_hash;
103 +
        if changed {
104 +
            self.store.put_change(&wc)?;
105 +
            let mut view = op.view.clone();
106 +
            view.workspaces.insert(self.workspace, new_hash);
107 +
            self.record_op(&[op_ref], &format!("{command}{SNAPSHOT_SUFFIX}"), view)?;
108 +
        }
109 +
        Ok(SnapshotResult { wc_hash: new_hash, wc, base, base_snapshot, statuses, changed, resolves, contributors })
110 +
    }
111 +
112 +
    /// Turn the working-copy change into a normal change on the bookmark (spec §12.3).
113 +
    pub fn commit(&self, message: &str, extra_deps: &[ChangeHash]) -> Result<ChangeHash> {
114 +
        let snap = self.snapshot("oxy commit")?;
115 +
        if snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty() {
116 +
            return fail("nothing to commit: the working copy matches its base");
117 +
        }
118 +
        if let Some(s) = snap.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
119 +
            return fail(format!("{} still contains conflict markers", s.path));
120 +
        }
121 +
        if message.trim().is_empty() {
122 +
            return fail("a commit message is required");
123 +
        }
124 +
        let (op_ref, op) = self.head_op()?;
125 +
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
126 +
        // A resolution depends on every change that contributed to the
127 +
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
128 +
        // ordinary operations whose references already cover the contributors.
129 +
        deps.extend(snap.contributors.iter().copied());
130 +
        for d in extra_deps {
131 +
            if !snap.base.contains(d) {
132 +
                return fail(format!("--depends-on {d}: not in the working copy's base state"));
133 +
            }
134 +
            deps.insert(*d);
135 +
        }
136 +
        if self.config.sign_changes && self.secret_key()?.is_none() {
137 +
            return fail("core.sign is \"change\" but no secret key is available for author.key");
138 +
        }
139 +
        let mut change = Change {
140 +
            change_id: snap.wc.change_id,
141 +
            deps: deps.into_iter().collect(),
142 +
            author: self.author(),
143 +
            hlc: Hlc::now(),
144 +
            message: message.trim_end().to_string(),
145 +
            tree_ops: snap.wc.tree_ops.clone(),
146 +
            edits: snap.wc.edits.clone(),
147 +
            kind: if snap.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
148 +
        };
149 +
        change.normalize();
150 +
        let hash = self.store.put_change(&change)?;
151 +
        if self.config.sign_changes {
152 +
            self.sign_change(&hash)?;
153 +
        }
154 +
155 +
        // New bookmark heads: maximal elements of old heads plus the new change.
156 +
        // The working copy continues on its own base plus the new change, which
157 +
        // is exactly what the files on disk contain; the bookmark may hold
158 +
        // other heads (and conflicts) the working copy has not checked out.
159 +
        let old_heads = self.heads_of(&op.view, &self.config.bookmark)?;
160 +
        let old_base = snap.base.maximal(&snap.wc.deps);
161 +
        let mut all: Vec<ChangeHash> = old_heads.clone();
162 +
        all.extend(old_base.iter().copied());
163 +
        all.push(hash);
164 +
        let mut st = self.load_state(&all)?;
165 +
        st.add(change)?;
166 +
        let mut with_new = old_heads.clone();
167 +
        with_new.push(hash);
168 +
        let new_heads = st.maximal(&with_new);
169 +
        let mut wc_base = old_base.clone();
170 +
        wc_base.push(hash);
171 +
        let wc_base = st.maximal(&wc_base);
172 +
173 +
        let wc = self.new_working_copy(wc_base, "");
174 +
        let wc_hash = self.store.put_change(&wc)?;
175 +
        let mut view = op.view.clone();
176 +
        view.bookmarks.insert(self.config.bookmark.clone(), self.store.put_state(&StateObject::new(new_heads))?);
177 +
        view.workspaces.insert(self.workspace, wc_hash);
178 +
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
179 +
        Ok(hash)
180 +
    }
181 +
}
182 +
183 +
/// Builds the tree ops and edits of a working-copy change against a base.
184 +
struct ChangeBuilder<'a> {
185 +
    base: &'a State,
186 +
    base_snapshot: &'a Snapshot,
187 +
    tree_ops: Vec<TreeOp>,
188 +
    edits: Vec<Edit>,
189 +
    statuses: Vec<PathStatus>,
190 +
    dir_nodes: HashMap<String, NodeId>,
191 +
    reuse: HashMap<String, (NodeId, NodeKind)>,
192 +
    /// Changes that contributed to conflicts this working copy resolves.
193 +
    contributors: BTreeSet<ChangeHash>,
194 +
    resolves: bool,
195 +
}
196 +
197 +
impl<'a> ChangeBuilder<'a> {
198 +
    fn new(base: &'a State, base_snapshot: &'a Snapshot, reuse: HashMap<String, (NodeId, NodeKind)>) -> Self {
199 +
        let mut dir_nodes: HashMap<String, NodeId> = HashMap::new();
200 +
        dir_nodes.insert(String::new(), NodeId::ROOT);
201 +
        for (p, f) in &base_snapshot.files {
202 +
            if f.kind == NodeKind::Dir {
203 +
                dir_nodes.insert(p.clone(), f.node);
204 +
            }
205 +
        }
206 +
        ChangeBuilder { base, base_snapshot, tree_ops: vec![], edits: vec![], statuses: vec![], dir_nodes, reuse, contributors: BTreeSet::new(), resolves: false }
207 +
    }
208 +
209 +
    fn reused_node(&self, path: &str, kind: NodeKind) -> Option<NodeId> {
210 +
        self.reuse.get(path).filter(|(_, k)| *k == kind).map(|(n, _)| *n)
211 +
    }
212 +
213 +
    fn ensure_dir(&mut self, path: &str) -> NodeId {
214 +
        if let Some(n) = self.dir_nodes.get(path) {
215 +
            return *n;
216 +
        }
217 +
        let mut parent = NodeId::ROOT;
218 +
        let mut prefix = String::new();
219 +
        for comp in path.split('/') {
220 +
            if !prefix.is_empty() {
221 +
                prefix.push('/');
222 +
            }
223 +
            prefix.push_str(comp);
224 +
            parent = match self.dir_nodes.get(&prefix) {
225 +
                Some(n) => *n,
226 +
                None => {
227 +
                    let node = self.reused_node(&prefix, NodeKind::Dir).unwrap_or_else(NodeId::random);
228 +
                    self.tree_ops.push(TreeOp::AddNode { node, kind: NodeKind::Dir, mode: Mode::default(), parent, name: comp.to_string() });
229 +
                    self.dir_nodes.insert(prefix.clone(), node);
230 +
                    node
231 +
                }
232 +
            };
233 +
        }
234 +
        parent
235 +
    }
236 +
237 +
    fn add_path(&mut self, repo: &Repo, path: &str, e: &FsEntry) -> Result<()> {
238 +
        let (dir, name) = split_path(path);
239 +
        let parent = self.ensure_dir(dir);
240 +
        let node = self.reused_node(path, e.kind).unwrap_or_else(NodeId::random);
241 +
        let opaque = e.kind == NodeKind::Symlink || is_binary(&e.content);
242 +
        self.tree_ops.push(TreeOp::AddNode { node, kind: e.kind, mode: Mode { exec: e.exec, opaque }, parent, name: name.to_string() });
243 +
        if opaque {
244 +
            let blob = repo.store_blob(&e.content)?;
245 +
            self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
246 +
        } else if !e.content.is_empty() {
247 +
            self.edits.push(Edit::Insert {
248 +
                node,
249 +
                after: crate::objects::AtomRef::Start,
250 +
                before: crate::objects::AtomRef::End,
251 +
                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
252 +
            });
253 +
        }
254 +
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Added });
255 +
        Ok(())
256 +
    }
257 +
258 +
    fn remove_path(&mut self, path: &str, expected: &crate::state::FileState) {
259 +
        let entry = &self.base_snapshot.tree.entries[path];
260 +
        self.tree_ops.push(TreeOp::RemoveNode { node: expected.node, placement: op_ref(entry.placement) });
261 +
        if path.contains(".oxy-conflict-") || expected.is_conflicted() {
262 +
            self.resolves = true;
263 +
        }
264 +
        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Removed });
265 +
    }
266 +
267 +
    fn update_path(&mut self, repo: &Repo, path: &str, e: &FsEntry, expected: &crate::state::FileState) -> Result<()> {
268 +
        if expected.kind != e.kind {
269 +
            self.remove_path(path, expected);
270 +
            self.statuses.pop();
271 +
            self.add_path(repo, path, e)?;
272 +
            self.statuses.pop();
273 +
            self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Modified });
274 +
            return Ok(());
275 +
        }
276 +
        let entry = &self.base_snapshot.tree.entries[path];
277 +
        let node = expected.node;
278 +
        let mode_atom = entry.mode_atom.map(op_ref);
279 +
        let mut status: Option<ChangeStatus> = None;
280 +
        match &expected.content {
281 +
            FileContent::Dir => {}
282 +
            FileContent::Text { bytes, materialized } => {
283 +
                if materialized.is_conflicted() {
284 +
                    if bytes == &e.content {
285 +
                        return Ok(());
286 +
                    }
287 +
                    if has_conflict_markers(&e.content) {
288 +
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
289 +
                        return Ok(());
290 +
                    }
291 +
                    // Resolution (spec §9.6): diff against the unconflicted lines
292 +
                    // with each region as a gap, then retire every region atom.
293 +
                    // Whatever the author kept from a region is re-inserted as
294 +
                    // new atoms, which leaves the graph totally ordered.
295 +
                    let regions = self.base.conflict_regions(&node);
296 +
                    let mut edits = text_edits(node, &regions.plain, &e.content);
297 +
                    if !regions.region_atoms.is_empty() {
298 +
                        edits.insert(0, Edit::Delete { node, atoms: ranges(&regions.region_atoms) });
299 +
                    }
300 +
                    self.edits.extend(edits);
301 +
                    self.contributors.extend(regions.contributors);
302 +
                    self.resolves = true;
303 +
                    if e.exec != expected.mode.exec {
304 +
                        if let Some(old) = mode_atom {
305 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
306 +
                        }
307 +
                    }
308 +
                    self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::Resolved });
309 +
                    return Ok(());
310 +
                }
311 +
                if bytes != &e.content {
312 +
                    if is_binary(&e.content) {
313 +
                        // Text → opaque (spec §7.5).
314 +
                        if let Some(old) = mode_atom {
315 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
316 +
                        }
317 +
                        let live: Vec<_> = repo_live_keys(self.base, &node);
318 +
                        if !live.is_empty() {
319 +
                            self.edits.push(Edit::Delete { node, atoms: ranges(&live) });
320 +
                        }
321 +
                        let blob = repo.store_blob(&e.content)?;
322 +
                        self.edits.push(Edit::Replace { node, old: None, new: Some(blob) });
323 +
                        status = Some(ChangeStatus::Modified);
324 +
                    } else {
325 +
                        let old = self.base.live_lines(&node);
326 +
                        let edits = text_edits(node, &old, &e.content);
327 +
                        if !edits.is_empty() {
328 +
                            self.edits.extend(edits);
329 +
                            status = Some(ChangeStatus::Modified);
330 +
                        }
331 +
                    }
332 +
                }
333 +
                let switched_to_opaque = status == Some(ChangeStatus::Modified) && is_binary(&e.content);
334 +
                if !switched_to_opaque && e.exec != expected.mode.exec {
335 +
                    if let Some(old) = mode_atom {
336 +
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
337 +
                        status.get_or_insert(ChangeStatus::ModeChanged);
338 +
                    }
339 +
                }
340 +
            }
341 +
            FileContent::Opaque { blobs } => {
342 +
                let new_ref = Repo::blob_ref(&e.content);
343 +
                if blobs.len() > 1 {
344 +
                    if !blobs.contains(&new_ref) {
345 +
                        self.statuses.push(PathStatus { path: path.to_string(), status: ChangeStatus::ConflictEdited });
346 +
                    }
347 +
                    return Ok(());
348 +
                }
349 +
                let current = blobs.first().copied();
350 +
                if current != Some(new_ref) {
351 +
                    if e.kind == NodeKind::File && !is_binary(&e.content) {
352 +
                        // Opaque → text (spec §7.5).
353 +
                        if let Some(old) = mode_atom {
354 +
                            self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: false } });
355 +
                        }
356 +
                        self.edits.push(Edit::Replace { node, old: current, new: None });
357 +
                        if !e.content.is_empty() {
358 +
                            self.edits.push(Edit::Insert {
359 +
                                node,
360 +
                                after: crate::objects::AtomRef::Start,
361 +
                                before: crate::objects::AtomRef::End,
362 +
                                lines: split_lines(&e.content).iter().map(|l| l.to_vec()).collect(),
363 +
                            });
364 +
                        }
365 +
                    } else {
366 +
                        let blob = repo.store_blob(&e.content)?;
367 +
                        self.edits.push(Edit::Replace { node, old: current, new: Some(blob) });
368 +
                    }
369 +
                    status = Some(ChangeStatus::Modified);
370 +
                } else if e.kind == NodeKind::File && e.exec != expected.mode.exec {
371 +
                    if let Some(old) = mode_atom {
372 +
                        self.tree_ops.push(TreeOp::SetMode { node, old, mode: Mode { exec: e.exec, opaque: true } });
373 +
                        status = Some(ChangeStatus::ModeChanged);
374 +
                    }
375 +
                }
376 +
            }
377 +
        }
378 +
        if let Some(s) = status {
379 +
            self.statuses.push(PathStatus { path: path.to_string(), status: s });
380 +
        }
381 +
        Ok(())
382 +
    }
383 +
}
384 +
385 +
fn repo_live_keys(base: &State, node: &NodeId) -> Vec<crate::graph::AtomKey> {
386 +
    base.live_lines(node).into_iter().map(|(k, _)| k).collect()
387 +
}
388 +
389 +
/// The changes a change's scope references (spec §8.1): introducers of every
390 +
/// atom, placement, and mode it touches, and of every node it operates on.
391 +
pub fn referenced_changes(change: &Change, base: &State) -> BTreeSet<ChangeHash> {
392 +
    let mut out = BTreeSet::new();
393 +
    let opref = |r: &OpRef, out: &mut BTreeSet<ChangeHash>| {
394 +
        if !r.change.is_zero() {
395 +
            out.insert(r.change);
396 +
        }
397 +
    };
398 +
    let mut nodes: Vec<NodeId> = Vec::new();
399 +
    for op in &change.tree_ops {
400 +
        match op {
401 +
            TreeOp::AddNode { parent, .. } => nodes.push(*parent),
402 +
            TreeOp::RemoveNode { node, placement } => {
403 +
                nodes.push(*node);
404 +
                opref(placement, &mut out);
405 +
            }
406 +
            TreeOp::Move { node, old, parent, .. } => {
407 +
                nodes.push(*node);
408 +
                nodes.push(*parent);
409 +
                opref(old, &mut out);
410 +
            }
411 +
            TreeOp::SetMode { node, old, .. } => {
412 +
                nodes.push(*node);
413 +
                opref(old, &mut out);
414 +
            }
415 +
        }
416 +
    }
417 +
    for e in &change.edits {
418 +
        nodes.push(e.node());
419 +
        match e {
420 +
            Edit::Insert { after, before, .. } => {
421 +
                for r in [after, before] {
422 +
                    if let crate::objects::AtomRef::Atom { change, .. } = r {
423 +
                        if !change.is_zero() {
424 +
                            out.insert(*change);
425 +
                        }
426 +
                    }
427 +
                }
428 +
            }
429 +
            Edit::Delete { atoms, .. } => {
430 +
                for r in atoms {
431 +
                    if !r.change.is_zero() {
432 +
                        out.insert(r.change);
433 +
                    }
434 +
                }
435 +
            }
436 +
            Edit::Replace { .. } => {}
437 +
        }
438 +
    }
439 +
    for n in nodes {
440 +
        if let Some(h) = base.tree().introducer(&n) {
441 +
            out.insert(h);
442 +
        }
443 +
    }
444 +
    out
445 +
}
446 +
447 +
/// Paths of the nodes a change adds on top of `base_snapshot`.
448 +
pub fn added_node_paths(change: &Change, base_snapshot: &Snapshot) -> HashMap<String, (NodeId, NodeKind)> {
449 +
    let by_node = base_snapshot.tree.by_node();
450 +
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
451 +
    for op in &change.tree_ops {
452 +
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
453 +
            new_names.insert(*node, (*parent, name.clone(), *kind));
454 +
        }
455 +
    }
456 +
    let mut out = HashMap::new();
457 +
    for (node, (_, _, kind)) in &new_names {
458 +
        if let Some(path) = path_of_node(node, &by_node, &new_names) {
459 +
            out.insert(path, (*node, *kind));
460 +
        }
461 +
    }
462 +
    out
463 +
}
464 +
465 +
fn path_of_node(
466 +
    n: &NodeId,
467 +
    by_node: &HashMap<NodeId, &crate::tree::TreeEntry>,
468 +
    new_names: &HashMap<NodeId, (NodeId, String, NodeKind)>,
469 +
) -> Option<String> {
470 +
    if let Some(e) = by_node.get(n) {
471 +
        return Some(e.path.clone());
472 +
    }
473 +
    let mut parts = Vec::new();
474 +
    let mut cur = *n;
475 +
    loop {
476 +
        if cur.is_root() {
477 +
            break;
478 +
        }
479 +
        if let Some(e) = by_node.get(&cur) {
480 +
            if !e.path.is_empty() {
481 +
                parts.push(e.path.clone());
482 +
            }
483 +
            break;
484 +
        }
485 +
        let (parent, name, _) = new_names.get(&cur)?;
486 +
        parts.push(name.clone());
487 +
        cur = *parent;
488 +
    }
489 +
    parts.reverse();
490 +
    Some(parts.join("/"))
491 +
}
492 +
493 +
/// Render a change's operations with line content, using `base` for deleted lines.
494 +
pub fn render_change(change: &Change, base: &State, base_snapshot: &Snapshot) -> String {
495 +
    let by_node = base_snapshot.tree.by_node();
496 +
    let mut new_names: HashMap<NodeId, (NodeId, String, NodeKind)> = HashMap::new();
497 +
    for op in &change.tree_ops {
498 +
        if let TreeOp::AddNode { node, parent, name, kind, .. } = op {
499 +
            new_names.insert(*node, (*parent, name.clone(), *kind));
500 +
        }
501 +
    }
502 +
    let path_of = |n: &NodeId| -> String { path_of_node(n, &by_node, &new_names).unwrap_or_else(|| format!("<node {}>", n.short(8))) };
503 +
504 +
    let mut out = String::new();
505 +
    for op in &change.tree_ops {
506 +
        match op {
507 +
            TreeOp::AddNode { node, kind, mode, .. } => {
508 +
                out.push_str(&format!("A {} ({:?}{}{})\n", path_of(node), kind, if mode.exec { ", exec" } else { "" }, if mode.opaque { ", opaque" } else { "" }));
509 +
            }
510 +
            TreeOp::RemoveNode { node, .. } => out.push_str(&format!("D {}\n", path_of(node))),
511 +
            TreeOp::Move { node, name, .. } => out.push_str(&format!("R {} -> {}\n", path_of(node), name)),
512 +
            TreeOp::SetMode { node, mode, .. } => {
513 +
                out.push_str(&format!("M {} mode: exec={} opaque={}\n", path_of(node), mode.exec, mode.opaque));
514 +
            }
515 +
        }
516 +
    }
517 +
    let mut last_node: Option<NodeId> = None;
518 +
    for e in &change.edits {
519 +
        let n = e.node();
520 +
        if last_node != Some(n) {
521 +
            out.push_str(&format!("--- {}\n", path_of(&n)));
522 +
            last_node = Some(n);
523 +
        }
524 +
        match e {
525 +
            Edit::Insert { lines, .. } => {
526 +
                for l in lines {
527 +
                    out.push('+');
528 +
                    out.push_str(&String::from_utf8_lossy(l));
529 +
                    if !l.ends_with(b"\n") {
530 +
                        out.push('\n');
531 +
                    }
532 +
                }
533 +
            }
534 +
            Edit::Delete { atoms, .. } => {
535 +
                for r in atoms {
536 +
                    for i in r.from..r.to {
537 +
                        let content = base.file(&n).and_then(|g| g.content(&(r.change, i))).unwrap_or(b"<unknown>\n");
538 +
                        out.push('-');
539 +
                        out.push_str(&String::from_utf8_lossy(content));
540 +
                        if !content.ends_with(b"\n") {
541 +
                            out.push('\n');
542 +
                        }
543 +
                    }
544 +
                }
545 +
            }
546 +
            Edit::Replace { old, new, .. } => {
547 +
                out.push_str(&format!(
548 +
                    "  opaque content {} -> {}\n",
549 +
                    old.map(|o| o.short(12)).unwrap_or_else(|| "none".into()),
550 +
                    new.map(|o| o.short(12)).unwrap_or_else(|| "none".into())
551 +
                ));
552 +
            }
553 +
        }
554 +
    }
555 +
    out
556 +
}
added crates/oxy-core/src/repo/sync.rs +498 −0
1 +
//! Serving and talking to remotes (spec §10.2, §16).
2 +
3 +
use super::*;
4 +
5 +
#[derive(Debug, Clone, PartialEq, Eq)]
6 +
pub struct PullReport {
7 +
    pub heads: Vec<ChangeHash>,
8 +
    pub changed: bool,
9 +
    pub checked_out: bool,
10 +
}
11 +
12 +
impl Repo {
13 +
    // ------------------------------------------------------------ serving (spec §16)
14 +
15 +
    fn ref_attest_path(&self, bookmark: &str) -> PathBuf {
16 +
        self.oxy.join("attest").join(bookmark)
17 +
    }
18 +
19 +
    /// The latest reference attestation stored for a bookmark.
20 +
    pub fn latest_ref_attest(&self, bookmark: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
21 +
        match fs::read_to_string(self.ref_attest_path(bookmark)) {
22 +
            Ok(h) => {
23 +
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt attestation pointer".into()))?;
24 +
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
25 +
            }
26 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
27 +
            Err(e) => Err(e.into()),
28 +
        }
29 +
    }
30 +
31 +
    fn set_ref_attest(&self, bookmark: &str, r: &ObjectRef) -> Result<()> {
32 +
        fs::create_dir_all(self.oxy.join("attest"))?;
33 +
        write_atomic(&self.ref_attest_path(bookmark), r.to_hex().as_bytes())
34 +
    }
35 +
36 +
    pub fn remote_refs(&self) -> Result<Vec<RemoteRef>> {
37 +
        let view = self.view()?;
38 +
        let mut out = Vec::new();
39 +
        for (name, state) in &view.bookmarks {
40 +
            let latest = self.latest_ref_attest(name)?.map(|(_, a)| a);
41 +
            let last_serial = latest.as_ref().and_then(|a| match a.attest.body {
42 +
                AttestBody::Ref { serial, .. } => Some(serial),
43 +
                _ => None,
44 +
            });
45 +
            let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state));
46 +
            out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial });
47 +
        }
48 +
        Ok(out)
49 +
    }
50 +
51 +
    /// Every change reachable from `heads` that is in the store.
52 +
    fn closure_in_store(&self, heads: &[ChangeHash]) -> Result<BTreeSet<ChangeHash>> {
53 +
        let mut seen = BTreeSet::new();
54 +
        let mut stack: Vec<ChangeHash> = heads.to_vec();
55 +
        while let Some(h) = stack.pop() {
56 +
            if seen.contains(&h) || !self.store.has(&h) {
57 +
                continue;
58 +
            }
59 +
            seen.insert(h);
60 +
            stack.extend(self.store.get_change(&h)?.deps);
61 +
        }
62 +
        Ok(seen)
63 +
    }
64 +
65 +
    /// `closure(want) \ closure(have)`, dependencies first (spec §16.2).
66 +
    pub fn negotiate(&self, have: &[ChangeHash], want: &[ChangeHash]) -> Result<Vec<ChangeHash>> {
67 +
        let have = self.closure_in_store(have)?;
68 +
        let mut out = Vec::new();
69 +
        let mut done: BTreeSet<ChangeHash> = have.clone();
70 +
        // Iterative post-order DFS.
71 +
        let mut stack: Vec<(ChangeHash, bool)> = want.iter().rev().map(|h| (*h, false)).collect();
72 +
        while let Some((h, expanded)) = stack.pop() {
73 +
            if done.contains(&h) {
74 +
                continue;
75 +
            }
76 +
            if !self.store.has(&h) {
77 +
                return fail(format!("change {} is not in this repository", h.short(12)));
78 +
            }
79 +
            if expanded {
80 +
                done.insert(h);
81 +
                out.push(h);
82 +
                continue;
83 +
            }
84 +
            stack.push((h, true));
85 +
            for d in self.store.get_change(&h)?.deps {
86 +
                if !done.contains(&d) {
87 +
                    stack.push((d, false));
88 +
                }
89 +
            }
90 +
        }
91 +
        Ok(out)
92 +
    }
93 +
94 +
    /// Bundle changes (in the given dependency order) with their signatures,
95 +
    /// seals, identities, blobs, chunks.
96 +
    pub fn bundle_for(&self, changes: &[ChangeHash]) -> Result<Bundle> {
97 +
        let mut bundle = Bundle::default();
98 +
        let mut objects: BTreeSet<ObjectRef> = BTreeSet::new();
99 +
        let mut seals: BTreeSet<ObjectRef> = BTreeSet::new();
100 +
        let add_object = |r: &ObjectRef, objects: &mut BTreeSet<ObjectRef>| {
101 +
            if !r.is_zero() && self.store.has(r) {
102 +
                objects.insert(*r);
103 +
            }
104 +
        };
105 +
        for h in changes {
106 +
            let c = self.store.get_change(h)?;
107 +
            let sigs = self.store.signatures(h)?;
108 +
            add_object(&c.author, &mut objects);
109 +
            for s in &sigs {
110 +
                add_object(&s.key, &mut objects);
111 +
            }
112 +
            for e in &c.edits {
113 +
                if let Edit::Replace { old, new, .. } = e {
114 +
                    for b in [old, new].into_iter().flatten() {
115 +
                        if self.store.has(b) {
116 +
                            objects.insert(*b);
117 +
                            if let Ok(blob) = Blob::decode(&self.store.get_typed(b, ObjectType::Blob)?) {
118 +
                                objects.extend(blob.chunks.iter().copied());
119 +
                            }
120 +
                        }
121 +
                    }
122 +
                }
123 +
            }
124 +
            for s in self.store.seals_for(h)? {
125 +
                seals.insert(s);
126 +
            }
127 +
            bundle.changes.push((c, sigs));
128 +
        }
129 +
        for r in seals {
130 +
            let attest = self.store.get_attest(&r)?;
131 +
            let sigs = self.store.signatures(&r)?;
132 +
            for s in &sigs {
133 +
                add_object(&s.key, &mut objects);
134 +
            }
135 +
            bundle.attests.push(SignedAttest { attest, sigs });
136 +
        }
137 +
        for r in objects {
138 +
            let (ty, body) = self.store.get(&r)?;
139 +
            bundle.objects.push((ty, body));
140 +
        }
141 +
        Ok(bundle)
142 +
    }
143 +
144 +
    /// Store a bundle's contents. With `strict`, every change must
145 +
    /// authenticate (signature or seal by its author or a delegate).
146 +
    pub fn import_bundle(&self, bundle: &Bundle, strict: bool) -> Result<Vec<ChangeHash>> {
147 +
        for (ty, body) in &bundle.objects {
148 +
            match ty {
149 +
                ObjectType::Chunk | ObjectType::Blob | ObjectType::Identity | ObjectType::State => {
150 +
                    self.store.put(*ty, body)?;
151 +
                }
152 +
                other => return fail(format!("bundle may not carry {} objects", other.name())),
153 +
            }
154 +
        }
155 +
        for sa in &bundle.attests {
156 +
            let r = self.store.put_attest(&sa.attest)?;
157 +
            for sig in &sa.sigs {
158 +
                self.store.put_signature(&r, sig)?;
159 +
            }
160 +
            if let AttestBody::Seal { hashes } = &sa.attest.body {
161 +
                for h in hashes {
162 +
                    self.store.index_seal(h, &r)?;
163 +
                }
164 +
            }
165 +
        }
166 +
        let incoming: BTreeSet<ChangeHash> = bundle.changes.iter().map(|(c, _)| c.hash()).collect();
167 +
        let mut imported = Vec::new();
168 +
        for (c, sigs) in &bundle.changes {
169 +
            let h = c.hash();
170 +
            for d in &c.deps {
171 +
                if !self.store.has(d) && !incoming.contains(d) {
172 +
                    return fail(format!("change {} depends on {}, which is neither stored nor in the bundle", h.short(12), d.short(12)));
173 +
                }
174 +
            }
175 +
            self.store.put_change(c)?;
176 +
            for sig in sigs {
177 +
                self.store.put_signature(&h, sig)?;
178 +
            }
179 +
            imported.push(h);
180 +
        }
181 +
        if strict {
182 +
            for h in &imported {
183 +
                if !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
184 +
                    return fail(format!("change {} is not authenticated by its author", h.short(12)));
185 +
                }
186 +
            }
187 +
        }
188 +
        Ok(imported)
189 +
    }
190 +
191 +
    /// Verify a reference attestation against an update it claims to describe.
192 +
    /// `allow_equal` lets a client re-read the attestation it already has; a
193 +
    /// server receiving a push requires a strictly greater serial.
194 +
    fn check_ref_attest(&self, sa: &SignedAttest, name: &str, old_state: &ObjectRef, new_state: &ObjectRef, last_serial: Option<u64>, allow_equal: bool) -> std::result::Result<u64, String> {
195 +
        let AttestBody::Ref { name: n, old_state: o, new_state: nw, serial } = &sa.attest.body else {
196 +
            return Err("attestation is not a ref attestation".into());
197 +
        };
198 +
        if n != name || o != old_state || nw != new_state {
199 +
            return Err("attestation does not describe this update".into());
200 +
        }
201 +
        if sa.attest.repo != self.id().map_err(|e| e.to_string())? {
202 +
            return Err("attestation is for a different repository".into());
203 +
        }
204 +
        if let Some(last) = last_serial {
205 +
            if *serial < last || (*serial == last && !allow_equal) {
206 +
                return Err(format!("attestation serial {serial} is not greater than the last seen {last}"));
207 +
            }
208 +
        }
209 +
        let r = sa.attest.hash();
210 +
        let sig = sa.sigs.first().ok_or("attestation carries no signature")?;
211 +
        let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
212 +
        verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
213 +
        Ok(*serial)
214 +
    }
215 +
216 +
    /// Accept a push (spec §16.3). Without a trust policy every repository
217 +
    /// is unverified, so unsigned changes and unattested updates are accepted;
218 +
    /// attestations that are present must still be valid.
219 +
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>) -> Result<Vec<RefResult>> {
220 +
        self.import_bundle(&bundle, false)?;
221 +
        let (op_ref, op) = self.head_op()?;
222 +
        let mut view = op.view.clone();
223 +
        let mut results = Vec::new();
224 +
        let mut names = Vec::new();
225 +
        for u in updates {
226 +
            let outcome = self.apply_update(&mut view, &u);
227 +
            let (ok, reason) = match outcome {
228 +
                Ok(()) => (true, String::new()),
229 +
                Err(e) => (false, e),
230 +
            };
231 +
            if ok {
232 +
                names.push(u.name.clone());
233 +
            }
234 +
            results.push(RefResult { name: u.name, ok, reason });
235 +
        }
236 +
        if !names.is_empty() {
237 +
            self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?;
238 +
        }
239 +
        Ok(results)
240 +
    }
241 +
242 +
    fn apply_update(&self, view: &mut View, u: &RefUpdate) -> std::result::Result<(), String> {
243 +
        let empty = self.store.put_state(&StateObject::new(vec![])).map_err(|e| e.to_string())?;
244 +
        let current = view.bookmarks.get(&u.name).copied().unwrap_or(empty);
245 +
        if current != u.old_state {
246 +
            return Err("stale: the remote bookmark has moved; pull first".into());
247 +
        }
248 +
        let new_state = self.store.get_state(&u.new_state).map_err(|_| "new state object is missing".to_string())?;
249 +
        for h in &new_state.heads {
250 +
            if !self.store.has(h) {
251 +
                return Err(format!("head {} is missing", h.short(12)));
252 +
            }
253 +
        }
254 +
        if !u.force {
255 +
            let old_heads = self.store.get_state(&current).map_err(|e| e.to_string())?.heads;
256 +
            let closure = self.closure_in_store(&new_state.heads).map_err(|e| e.to_string())?;
257 +
            if let Some(h) = old_heads.iter().find(|h| !closure.contains(h)) {
258 +
                return Err(format!("not a fast-forward: current head {} would be dropped (use --force)", h.short(12)));
259 +
            }
260 +
        }
261 +
        if let Some(sa) = &u.attestation {
262 +
            let last = self.latest_ref_attest(&u.name).map_err(|e| e.to_string())?.and_then(|(_, a)| match a.attest.body {
263 +
                AttestBody::Ref { serial, .. } => Some(serial),
264 +
                _ => None,
265 +
            });
266 +
            self.check_ref_attest(sa, &u.name, &u.old_state, &u.new_state, last, false)?;
267 +
            let r = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
268 +
            for sig in &sa.sigs {
269 +
                self.store.put_signature(&r, sig).map_err(|e| e.to_string())?;
270 +
            }
271 +
            self.set_ref_attest(&u.name, &r).map_err(|e| e.to_string())?;
272 +
        }
273 +
        view.bookmarks.insert(u.name.clone(), u.new_state);
274 +
        Ok(())
275 +
    }
276 +
277 +
    // ------------------------------------------------------------ client side
278 +
279 +
    fn remote_url(&self, name: &str) -> Result<String> {
280 +
        self.config.remotes.get(name).cloned().ok_or_else(|| RepoError::Msg(format!("no remote named {name:?}; add one with `oxy remote add`")))
281 +
    }
282 +
283 +
    /// Token for a remote: `remote.<name>.token`, else `$OXY_TOKEN`.
284 +
    fn remote_token(&self, name: &str) -> Option<String> {
285 +
        self.config.remote_tokens.get(name).cloned().or_else(|| std::env::var("OXY_TOKEN").ok())
286 +
    }
287 +
288 +
    fn open_named_remote(&self, name: &str) -> Result<Box<dyn crate::protocol::Remote>> {
289 +
        open_remote(&self.remote_url(name)?, self.remote_token(name))
290 +
    }
291 +
292 +
    fn serial_path(&self, remote: &str, bookmark: &str) -> PathBuf {
293 +
        self.oxy.join("remotes").join(remote).join(format!("{bookmark}.serial"))
294 +
    }
295 +
296 +
    fn last_seen_serial(&self, remote: &str, bookmark: &str) -> Option<u64> {
297 +
        fs::read_to_string(self.serial_path(remote, bookmark)).ok().and_then(|s| s.trim().parse().ok())
298 +
    }
299 +
300 +
    fn record_serial(&self, remote: &str, bookmark: &str, serial: u64) -> Result<()> {
301 +
        let p = self.serial_path(remote, bookmark);
302 +
        fs::create_dir_all(p.parent().unwrap())?;
303 +
        write_atomic(&p, serial.to_string().as_bytes())
304 +
    }
305 +
306 +
    /// Every head this repository knows: bookmarks, remote tracking, working copy.
307 +
    fn known_heads(&self, view: &View) -> Result<Vec<ChangeHash>> {
308 +
        let mut have: BTreeSet<ChangeHash> = BTreeSet::new();
309 +
        for st in view.bookmarks.values().chain(view.remotes.values().flat_map(|b| b.values())) {
310 +
            if let Ok(s) = self.store.get_state(st) {
311 +
                have.extend(s.heads);
312 +
            }
313 +
        }
314 +
        have.extend(view.workspaces.values().copied());
315 +
        Ok(have.into_iter().collect())
316 +
    }
317 +
318 +
    /// Fetch every bookmark of a remote into `remotes/<name>/bookmarks/*` (spec §10.2).
319 +
    pub fn fetch(&self, remote_name: &str) -> Result<Vec<(String, Vec<ChangeHash>, usize)>> {
320 +
        let remote = self.open_named_remote(remote_name)?;
321 +
        if remote.repo_id()? != self.id()? {
322 +
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
323 +
        }
324 +
        let (op_ref, op) = self.head_op()?;
325 +
        let mut view = op.view.clone();
326 +
        let have = self.known_heads(&view)?;
327 +
        let mut report = Vec::new();
328 +
        for r in remote.refs()? {
329 +
            let needed = remote.negotiate(&have, &r.heads)?;
330 +
            let bundle = remote.fetch(&needed)?;
331 +
            let imported = self.import_bundle(&bundle, false)?;
332 +
            for h in &r.heads {
333 +
                if !self.store.has(h) {
334 +
                    return fail(format!("remote did not send head {}", h.short(12)));
335 +
                }
336 +
            }
337 +
            let state = self.store.put_state(&StateObject::new(r.heads.clone()))?;
338 +
            if state != r.state {
339 +
                return fail(format!("remote state {} does not match its heads", r.state.short(12)));
340 +
            }
341 +
            if let Some(sa) = &r.attestation {
342 +
                let AttestBody::Ref { old_state, new_state, .. } = &sa.attest.body else {
343 +
                    return fail("remote sent a non-ref attestation for a bookmark");
344 +
                };
345 +
                let (old_state, new_state) = (*old_state, *new_state);
346 +
                let serial = self
347 +
                    .check_ref_attest(sa, &r.name, &old_state, &new_state, self.last_seen_serial(remote_name, &r.name), true)
348 +
                    .map_err(|e| RepoError::Msg(format!("attestation for {}/{}: {e}", remote_name, r.name)))?;
349 +
                if new_state != r.state {
350 +
                    return fail(format!("attestation for {}/{} does not cover its current state", remote_name, r.name));
351 +
                }
352 +
                let ar = self.store.put_attest(&sa.attest)?;
353 +
                for sig in &sa.sigs {
354 +
                    self.store.put_signature(&ar, sig)?;
355 +
                }
356 +
                self.record_serial(remote_name, &r.name, serial)?;
357 +
            }
358 +
            view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
359 +
            report.push((r.name, r.heads, imported.len()));
360 +
        }
361 +
        if view != op.view {
362 +
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
363 +
        }
364 +
        Ok(report)
365 +
    }
366 +
367 +
    /// Fetch, then take the union of the local bookmark's state with the
368 +
    /// remote's: no merge change, conflicts become reified (spec §9.4).
369 +
    pub fn pull(&self, remote_name: &str) -> Result<PullReport> {
370 +
        self.fetch(remote_name)?;
371 +
        let (op_ref, op) = self.head_op()?;
372 +
        let bookmark = self.config.bookmark.clone();
373 +
        let local = self.heads_of(&op.view, &bookmark)?;
374 +
        let Some(remote_state) = op.view.remotes.get(remote_name).and_then(|b| b.get(&bookmark)).copied() else {
375 +
            return Ok(PullReport { heads: local, changed: false, checked_out: false });
376 +
        };
377 +
        let remote_heads = self.store.get_state(&remote_state)?.heads;
378 +
        let mut all = local.clone();
379 +
        all.extend(remote_heads);
380 +
        let st = self.load_state(&all)?;
381 +
        let mut heads = st.maximal(&all);
382 +
        heads.sort();
383 +
        let mut sorted_local = local.clone();
384 +
        sorted_local.sort();
385 +
        if heads == sorted_local {
386 +
            return Ok(PullReport { heads, changed: false, checked_out: false });
387 +
        }
388 +
        let mut view = op.view.clone();
389 +
        view.bookmarks.insert(bookmark.clone(), self.store.put_state(&StateObject::new(heads.clone()))?);
390 +
        self.record_op(&[op_ref], &format!("oxy pull {remote_name}"), view)?;
391 +
        // Follow along if the working copy was clean and on the old heads.
392 +
        let snap = self.snapshot("oxy pull")?;
393 +
        let clean = snap.wc.tree_ops.is_empty() && snap.wc.edits.is_empty();
394 +
        let mut base = snap.base.maximal(&snap.wc.deps);
395 +
        base.sort();
396 +
        let mut checked_out = false;
397 +
        if clean && base == sorted_local {
398 +
            self.checkout(&heads, false, "oxy pull (checkout)")?;
399 +
            checked_out = true;
400 +
        }
401 +
        Ok(PullReport { heads, changed: true, checked_out })
402 +
    }
403 +
404 +
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
405 +
    /// changes and attesting the reference when a key is available.
406 +
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
407 +
        let remote = self.open_named_remote(remote_name)?;
408 +
        if remote.repo_id()? != self.id()? {
409 +
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
410 +
        }
411 +
        let (op_ref, op) = self.head_op()?;
412 +
        let bookmark = self.config.bookmark.clone();
413 +
        let local_heads = self.heads_of(&op.view, &bookmark)?;
414 +
        let refs = remote.refs()?;
415 +
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
416 +
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
417 +
            Some(r) => (r.state, r.heads.clone(), r.last_serial),
418 +
            None => (empty, vec![], None),
419 +
        };
420 +
        let new_state = self.store.put_state(&StateObject::new(local_heads.clone()))?;
421 +
        if new_state == remote_state {
422 +
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
423 +
        }
424 +
        for h in &remote_heads {
425 +
            if !self.store.has(h) {
426 +
                return fail(format!("the remote has change {} which you do not; pull first", h.short(12)));
427 +
            }
428 +
        }
429 +
        if !force {
430 +
            let closure = self.closure_in_store(&local_heads)?;
431 +
            if let Some(h) = remote_heads.iter().find(|h| !closure.contains(h)) {
432 +
                return fail(format!("not a fast-forward: remote head {} is not in your state; pull first or use --force", h.short(12)));
433 +
            }
434 +
        }
435 +
        let to_send = self.negotiate(&remote_heads, &local_heads)?;
436 +
        let mut bundle = self.bundle_for(&to_send)?;
437 +
        let key = self.secret_key()?.map(|(k, _)| k);
438 +
        if let Some(key) = key {
439 +
            // Seal this author's changes that carry no valid authenticator.
440 +
            let mut unsigned = Vec::new();
441 +
            for h in &to_send {
442 +
                let c = self.store.get_change(h)?;
443 +
                if c.author == key && !matches!(self.authenticate_change(h)?, SignatureStatus::Valid { .. }) {
444 +
                    unsigned.push(*h);
445 +
                }
446 +
            }
447 +
            for chunk in unsigned.chunks(crate::attest::MAX_SEAL_HASHES) {
448 +
                let sa = self.sign_attest(&Attest::seal(self.id()?, chunk.to_vec()))?;
449 +
                bundle.attests.push(sa);
450 +
            }
451 +
            bundle = self.bundle_for(&to_send)?;
452 +
        }
453 +
        bundle.objects.push((ObjectType::State, StateObject::new(local_heads.clone()).encode()));
454 +
        let attestation = match key {
455 +
            Some(_) => Some(self.sign_attest(&Attest {
456 +
                repo: self.id()?,
457 +
                hlc: Hlc::now(),
458 +
                body: AttestBody::Ref { name: bookmark.clone(), old_state: remote_state, new_state, serial: last_serial.unwrap_or(0) + 1 },
459 +
            })?),
460 +
            None => None,
461 +
        };
462 +
        let serial = attestation.as_ref().and_then(|a| match a.attest.body {
463 +
            AttestBody::Ref { serial, .. } => Some(serial),
464 +
            _ => None,
465 +
        });
466 +
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }])?;
467 +
        if results.iter().any(|r| r.ok) {
468 +
            let mut view = op.view.clone();
469 +
            view.remotes.entry(remote_name.to_string()).or_default().insert(bookmark.clone(), new_state);
470 +
            self.record_op(&[op_ref], &format!("oxy push {remote_name}"), view)?;
471 +
            if let Some(s) = serial {
472 +
                self.record_serial(remote_name, &bookmark, s)?;
473 +
            }
474 +
        }
475 +
        Ok(results)
476 +
    }
477 +
478 +
    /// Clone a remote into `dest`: same repository ID, `origin` remote,
479 +
    /// bookmark set to the remote's, working copy checked out.
480 +
    pub fn clone(url: &str, dest: &Path) -> Result<Repo> {
481 +
        let remote = open_remote(url, std::env::var("OXY_TOKEN").ok())?;
482 +
        let id = remote.repo_id()?;
483 +
        let mut repo = Repo::init_with_id(dest, id)?;
484 +
        repo.config.set("remote.origin.url", url)?;
485 +
        repo.save_config()?;
486 +
        repo.fetch("origin")?;
487 +
        let (op_ref, op) = repo.head_op()?;
488 +
        let bookmark = repo.config.bookmark.clone();
489 +
        if let Some(state) = op.view.remotes.get("origin").and_then(|b| b.get(&bookmark)).copied() {
490 +
            let heads = repo.store.get_state(&state)?.heads;
491 +
            let mut view = op.view.clone();
492 +
            view.bookmarks.insert(bookmark, state);
493 +
            repo.record_op(&[op_ref], &format!("oxy clone {url}"), view)?;
494 +
            repo.checkout(&heads, false, "oxy clone (checkout)")?;
495 +
        }
496 +
        Ok(repo)
497 +
    }
498 +
}
added crates/oxy-core/src/repo/tests.rs +659 −0
1 +
//! Tests for the repository layer.
2 +
3 +
use super::*;
4 +
5 +
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
6 +
/// test reads or writes the developer's real user configuration or keys.
7 +
fn hermetic() {
8 +
    static ONCE: std::sync::Once = std::sync::Once::new();
9 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
10 +
    ONCE.call_once(|| {
11 +
        let d = tempfile::tempdir().unwrap();
12 +
        std::env::set_var("OXY_HOME", d.path());
13 +
        DIR.set(d).ok();
14 +
    });
15 +
}
16 +
17 +
fn write(root: &Path, rel: &str, content: &[u8]) {
18 +
    let p = root.join(rel);
19 +
    fs::create_dir_all(p.parent().unwrap()).unwrap();
20 +
    fs::write(p, content).unwrap();
21 +
}
22 +
23 +
fn statuses(s: &SnapshotResult) -> Vec<(String, ChangeStatus)> {
24 +
    s.statuses.iter().map(|p| (p.path.clone(), p.status)).collect()
25 +
}
26 +
27 +
#[test]
28 +
fn init_snapshot_commit_log_undo() {
29 +
    hermetic();
30 +
    let dir = tempfile::tempdir().unwrap();
31 +
    let root = dir.path();
32 +
    let repo = Repo::init(root).unwrap();
33 +
    assert!(Repo::init(root).is_err());
34 +
    let ops = repo.op_log().unwrap();
35 +
    assert_eq!(ops.len(), 1);
36 +
    assert_eq!(ops[0].1.command, "oxy init");
37 +
38 +
    // Empty working copy: snapshot records no operation (spec §12.3).
39 +
    let s = repo.snapshot("oxy status").unwrap();
40 +
    assert!(!s.changed);
41 +
    assert_eq!(repo.op_log().unwrap().len(), 1);
42 +
43 +
    write(root, "src/main.rs", b"fn main() {}\n");
44 +
    write(root, "README.md", b"# Hello\n");
45 +
    write(root, ".oxyignore", b"/target\n");
46 +
    write(root, "target/junk.o", b"\0\0\0");
47 +
    let s = repo.snapshot("oxy status").unwrap();
48 +
    assert!(s.changed);
49 +
    assert_eq!(
50 +
        statuses(&s),
51 +
        vec![
52 +
            (".oxyignore".into(), ChangeStatus::Added),
53 +
            ("README.md".into(), ChangeStatus::Added),
54 +
            ("src/main.rs".into(), ChangeStatus::Added),
55 +
        ]
56 +
    );
57 +
    assert_eq!(repo.op_log().unwrap().len(), 2);
58 +
    // Snapshotting again with nothing changed records no op.
59 +
    let s2 = repo.snapshot("oxy status").unwrap();
60 +
    assert!(!s2.changed);
61 +
    assert_eq!(s2.wc_hash, s.wc_hash);
62 +
    assert_eq!(repo.op_log().unwrap().len(), 2);
63 +
64 +
    let h1 = repo.commit("Initial import", &[]).unwrap();
65 +
    let c1 = repo.store().get_change(&h1).unwrap();
66 +
    assert_eq!(c1.kind, ChangeKind::Normal);
67 +
    assert!(c1.deps.is_empty(), "first change has nothing to depend on");
68 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
69 +
    let (wc_hash, wc) = repo.working_copy().unwrap();
70 +
    assert_eq!(wc.deps, vec![h1]);
71 +
    assert!(wc.edits.is_empty());
72 +
    assert!(repo.commit("nothing", &[]).is_err());
73 +
74 +
    // Modify one file, remove another, add a binary file.
75 +
    write(root, "src/main.rs", b"fn main() {\n    println!(\"hi\");\n}\n");
76 +
    fs::remove_file(root.join("README.md")).unwrap();
77 +
    write(root, "logo.png", b"\x89PNG\0\0\x01");
78 +
    let s = repo.snapshot("oxy status").unwrap();
79 +
    assert_eq!(
80 +
        statuses(&s),
81 +
        vec![
82 +
            ("README.md".into(), ChangeStatus::Removed),
83 +
            ("logo.png".into(), ChangeStatus::Added),
84 +
            ("src/main.rs".into(), ChangeStatus::Modified),
85 +
        ]
86 +
    );
87 +
    assert_ne!(s.wc_hash, wc_hash);
88 +
    // The rewritten working copy keeps its ChangeId and depends on h1.
89 +
    assert_eq!(s.wc.change_id, wc.change_id);
90 +
    assert!(s.wc.deps.contains(&h1));
91 +
92 +
    let h2 = repo.commit("Second", &[]).unwrap();
93 +
    let c2 = repo.store().get_change(&h2).unwrap();
94 +
    assert_eq!(c2.deps, vec![h1], "everything it touches was introduced by h1");
95 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
96 +
97 +
    // The materialized head state matches the working copy.
98 +
    let st = repo.load_state(&[h2]).unwrap();
99 +
    let snap = st.materialize();
100 +
    assert!(!snap.is_conflicted());
101 +
    assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), [".oxyignore", "logo.png", "src", "src/main.rs"]);
102 +
    assert!(matches!(&snap.files["src/main.rs"].content, FileContent::Text { bytes, .. } if bytes == b"fn main() {\n    println!(\"hi\");\n}\n"));
103 +
    assert!(snap.files["logo.png"].mode.opaque);
104 +
    if let FileContent::Opaque { blobs } = &snap.files["logo.png"].content {
105 +
        assert_eq!(repo.read_blob(&blobs[0]).unwrap(), b"\x89PNG\0\0\x01");
106 +
    } else {
107 +
        panic!("logo should be opaque");
108 +
    }
109 +
110 +
    // Log: newest first, working copy included.
111 +
    let log = repo.log().unwrap();
112 +
    assert_eq!(log.len(), 3);
113 +
    assert!(log[0].is_working_copy);
114 +
    assert_eq!(log[1].hash, h2);
115 +
    assert!(log[1].is_head);
116 +
    assert_eq!(log[2].hash, h1);
117 +
    let d = repo.describe_change(&h2).unwrap();
118 +
    assert!(d.contains("D README.md"), "{d}");
119 +
    assert!(d.contains("+    println!(\"hi\");"), "{d}");
120 +
    assert!(d.contains("-fn main() {}"), "{d}");
121 +
122 +
    // Resolution by prefix.
123 +
    assert_eq!(repo.resolve_change(&c2.change_id.short(6)).unwrap(), h2);
124 +
    assert_eq!(repo.resolve_change(&format!("#{}", h1.short(8))).unwrap(), h1);
125 +
    assert!(repo.resolve_change("zzzz").is_err());
126 +
127 +
    // Undo the commit: bookmark and working copy go back.
128 +
    repo.undo().unwrap();
129 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
130 +
    let s = repo.snapshot("oxy status").unwrap();
131 +
    assert!(!s.changed, "files on disk still match the pre-commit working copy");
132 +
    assert_eq!(s.statuses.len(), 3);
133 +
}
134 +
135 +
/// Make `main` conflicted: two commits on the same base that edit the same line.
136 +
fn conflicted_repo() -> (tempfile::TempDir, Repo, ChangeHash, ChangeHash, ChangeHash) {
137 +
    let dir = tempfile::tempdir().unwrap();
138 +
    let root = dir.path();
139 +
    let repo = Repo::init(root).unwrap();
140 +
    write(root, "f.txt", b"a\nb\nc\n");
141 +
    let h1 = repo.commit("base", &[]).unwrap();
142 +
    write(root, "f.txt", b"a\nB\nc\n");
143 +
    let h2 = repo.commit("upper", &[]).unwrap();
144 +
    repo.checkout(&[h1], false, "checkout").unwrap();
145 +
    write(root, "f.txt", b"a\nX\nc\n");
146 +
    let h3 = repo.commit("ex", &[]).unwrap();
147 +
    (dir, repo, h1, h2, h3)
148 +
}
149 +
150 +
#[test]
151 +
fn concurrent_edits_conflict_and_can_be_resolved() {
152 +
    hermetic();
153 +
    let (dir, repo, h1, h2, h3) = conflicted_repo();
154 +
    let root = dir.path();
155 +
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
156 +
    assert_eq!(heads, [h2, h3].into_iter().collect());
157 +
    let st = repo.load_state(&[h2, h3]).unwrap();
158 +
    assert!(st.materialize().is_conflicted());
159 +
160 +
    // Checking out main writes markers.
161 +
    let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
162 +
    assert!(r.conflicted);
163 +
    let marked = fs::read_to_string(root.join("f.txt")).unwrap();
164 +
    assert!(marked.starts_with("a\n<<<<<<< oxy conflict 1 of 1"), "{marked}");
165 +
    assert!(marked.contains("B\n") && marked.contains("X\n") && marked.ends_with("end\nc\n"), "{marked}");
166 +
    assert!(has_conflict_markers(marked.as_bytes()));
167 +
    // Untouched markers: clean working copy; commit has nothing to do.
168 +
    let s = repo.snapshot("status").unwrap();
169 +
    assert!(s.statuses.is_empty());
170 +
    assert!(!s.resolves);
171 +
172 +
    // Partially edited file that still has markers cannot be committed.
173 +
    write(root, "f.txt", marked.replace("B\n", "BB\n").as_bytes());
174 +
    let s = repo.snapshot("status").unwrap();
175 +
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::ConflictEdited)]);
176 +
    assert!(repo.commit("no", &[]).is_err());
177 +
178 +
    // Resolve by keeping one side and adding a line elsewhere.
179 +
    write(root, "f.txt", b"a\nX\nc\nd\n");
180 +
    let s = repo.snapshot("status").unwrap();
181 +
    assert_eq!(statuses(&s), vec![("f.txt".into(), ChangeStatus::Resolved)]);
182 +
    assert!(s.resolves);
183 +
    let h4 = repo.commit("resolve", &[]).unwrap();
184 +
    let c4 = repo.store().get_change(&h4).unwrap();
185 +
    assert_eq!(c4.kind, ChangeKind::Resolution);
186 +
    let mut want = vec![h1, h2, h3];
187 +
    want.sort();
188 +
    assert_eq!(c4.deps, want, "a resolution depends on exactly the contributors and what it references");
189 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h4]);
190 +
    let snap = repo.load_state(&[h4]).unwrap().materialize();
191 +
    assert!(!snap.is_conflicted());
192 +
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
193 +
    assert!(!repo.snapshot("status").unwrap().changed);
194 +
    // A peer holding only h2 and h3 and receiving h4 sees the same result,
195 +
    // whatever order the changes arrive in.
196 +
    let mut st = State::new();
197 +
    for h in [h4, h3, h1, h2] {
198 +
        st.add_unchecked(repo.store().get_change(&h).unwrap());
199 +
    }
200 +
    st.validate().unwrap();
201 +
    assert!(matches!(&st.materialize().files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nc\nd\n"));
202 +
}
203 +
204 +
#[test]
205 +
fn resolution_may_keep_both_sides_or_neither() {
206 +
    hermetic();
207 +
    let (dir, repo, _h1, _h2, _h3) = conflicted_repo();
208 +
    let root = dir.path();
209 +
    repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
210 +
    // Keep both sides in a chosen order.
211 +
    write(root, "f.txt", b"a\nX\nB\nc\n");
212 +
    let h = repo.commit("both", &[]).unwrap();
213 +
    let snap = repo.load_state(&[h]).unwrap().materialize();
214 +
    assert!(!snap.is_conflicted());
215 +
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nX\nB\nc\n"));
216 +
    // Undo and instead drop the whole region.
217 +
    repo.undo().unwrap();
218 +
    write(root, "f.txt", b"a\nc\n");
219 +
    let h = repo.commit("neither", &[]).unwrap();
220 +
    let snap = repo.load_state(&[h]).unwrap().materialize();
221 +
    assert!(!snap.is_conflicted());
222 +
    assert!(matches!(&snap.files["f.txt"].content, FileContent::Text { bytes, .. } if bytes == b"a\nc\n"));
223 +
}
224 +
225 +
#[test]
226 +
fn name_conflict_resolved_by_removing_a_candidate() {
227 +
    hermetic();
228 +
    let dir = tempfile::tempdir().unwrap();
229 +
    let root = dir.path();
230 +
    let repo = Repo::init(root).unwrap();
231 +
    write(root, "keep.txt", b"k\n");
232 +
    let h1 = repo.commit("base", &[]).unwrap();
233 +
    write(root, "new.txt", b"one\n");
234 +
    repo.commit("one", &[]).unwrap();
235 +
    repo.checkout(&[h1], false, "checkout").unwrap();
236 +
    write(root, "new.txt", b"two\n");
237 +
    repo.commit("two", &[]).unwrap();
238 +
    let r = repo.checkout(&repo.bookmark_heads().unwrap(), false, "checkout main").unwrap();
239 +
    assert!(r.conflicted);
240 +
    assert!(root.join("new.txt.oxy-conflict-1").exists() && root.join("new.txt.oxy-conflict-2").exists());
241 +
    assert!(!root.join("new.txt").exists());
242 +
    // Candidate numbering follows change-hash order, so pick by content:
243 +
    // keep the "two" candidate under the real name and drop the other.
244 +
    let (keep, drop) = if fs::read(root.join("new.txt.oxy-conflict-1")).unwrap() == b"two\n" { (1, 2) } else { (2, 1) };
245 +
    fs::remove_file(root.join(format!("new.txt.oxy-conflict-{drop}"))).unwrap();
246 +
    fs::rename(root.join(format!("new.txt.oxy-conflict-{keep}")), root.join("new.txt")).unwrap();
247 +
    let h = repo.commit("pick two", &[]).unwrap();
248 +
    assert_eq!(repo.store().get_change(&h).unwrap().kind, ChangeKind::Resolution);
249 +
    // The resolution does not touch keep.txt, so it does not depend on h1;
250 +
    // the bookmark keeps both as heads and their union is the state.
251 +
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
252 +
    assert_eq!(heads, [h1, h].into_iter().collect());
253 +
    let snap = repo.load_state(&repo.bookmark_heads().unwrap()).unwrap().materialize();
254 +
    assert!(!snap.is_conflicted(), "{:?}", snap.tree.conflicts);
255 +
    assert_eq!(snap.files.keys().cloned().collect::<Vec<_>>(), ["keep.txt", "new.txt"]);
256 +
    assert!(matches!(&snap.files["new.txt"].content, FileContent::Text { bytes, .. } if bytes == b"two\n"));
257 +
}
258 +
259 +
#[test]
260 +
fn identities_and_signed_commits() {
261 +
    hermetic();
262 +
    let dir = tempfile::tempdir().unwrap();
263 +
    let root = dir.path();
264 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
265 +
    write(root, "a.txt", b"a\n");
266 +
    let h0 = repo.commit("unsigned", &[]).unwrap();
267 +
    assert_eq!(repo.verify_change(&h0).unwrap(), SignatureStatus::Unsigned);
268 +
    assert_eq!(repo.store().get_change(&h0).unwrap().author, ObjectRef::ZERO);
269 +
270 +
    let (key, id) = repo.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
271 +
    assert_eq!(id.hash(), key);
272 +
    assert_eq!(repo.config.author_key, Some(key));
273 +
    // Config survives reopen; the secret key is found again.
274 +
    let reopened = Repo::open(root).unwrap().with_home(root.join("home"));
275 +
    assert_eq!(reopened.config.author_key, Some(key));
276 +
    assert_eq!(reopened.secret_key().unwrap().unwrap().0, key);
277 +
278 +
    // Default core.sign = seal: commits carry the author but no signature.
279 +
    write(root, "a.txt", b"a\nb\n");
280 +
    let h1 = repo.commit("authored", &[]).unwrap();
281 +
    assert_eq!(repo.store().get_change(&h1).unwrap().author, key);
282 +
    assert_eq!(repo.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
283 +
    // Signing later works too.
284 +
    repo.sign_change(&h1).unwrap();
285 +
    assert!(matches!(repo.verify_change(&h1).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
286 +
287 +
    repo.config.set("core.sign", "change").unwrap();
288 +
    repo.save_config().unwrap();
289 +
    write(root, "a.txt", b"a\nb\nc\n");
290 +
    let h2 = repo.commit("signed", &[]).unwrap();
291 +
    assert_eq!(repo.verify_change(&h2).unwrap(), SignatureStatus::Valid { key, name: "Ada".into(), email: "[email protected]".into(), sealed: false });
292 +
293 +
    // A signature by a key that is not the author or a delegate is rejected.
294 +
    let other = SecretKey::generate();
295 +
    let other_id = other.identity("Mallory", "[email protected]", 0);
296 +
    let other_key = repo.store().put_identity(&other_id).unwrap();
297 +
    let forged = other.sign(Domain::Change, &other_key, &h0);
298 +
    repo.store().put_signature(&h0, &forged).unwrap();
299 +
    assert!(matches!(repo.verify_change(&h0).unwrap(), SignatureStatus::Invalid(m) if m.contains("neither the author")));
300 +
301 +
    // Tampering with the stored signature invalidates it.
302 +
    let mut sig = repo.store().signatures(&h2).unwrap().remove(0);
303 +
    sig.ed[0] ^= 1;
304 +
    repo.store().put_signature(&h2, &sig).unwrap();
305 +
    assert!(matches!(repo.verify_change(&h2).unwrap(), SignatureStatus::Invalid(_)));
306 +
307 +
    // Without the secret key, signing at commit fails cleanly.
308 +
    let no_keys = Repo::open(root).unwrap().with_home(root.join("nowhere"));
309 +
    write(root, "a.txt", b"z\n");
310 +
    assert!(no_keys.commit("cannot sign", &[]).is_err());
311 +
}
312 +
313 +
#[test]
314 +
fn checkout_moves_the_working_copy() {
315 +
    hermetic();
316 +
    let dir = tempfile::tempdir().unwrap();
317 +
    let root = dir.path();
318 +
    let repo = Repo::init(root).unwrap();
319 +
    write(root, "a.txt", b"one\n");
320 +
    write(root, "sub/deep/b.txt", b"b\n");
321 +
    let h1 = repo.commit("first", &[]).unwrap();
322 +
    write(root, "a.txt", b"two\n");
323 +
    fs::remove_file(root.join("sub/deep/b.txt")).unwrap();
324 +
    write(root, "bin.dat", &[0, 1, 2, 3]);
325 +
    let h2 = repo.commit("second", &[]).unwrap();
326 +
327 +
    let r = repo.checkout(&[h1], false, "oxy checkout").unwrap();
328 +
    assert_eq!(r, CheckoutReport { written: 2, removed: 1, conflicted: false });
329 +
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"one\n");
330 +
    assert_eq!(fs::read(root.join("sub/deep/b.txt")).unwrap(), b"b\n");
331 +
    assert!(!root.join("bin.dat").exists());
332 +
    let (_, wc) = repo.working_copy().unwrap();
333 +
    assert_eq!(wc.deps, vec![h1]);
334 +
    assert!(!repo.snapshot("status").unwrap().changed, "checkout leaves a clean working copy");
335 +
    // The bookmark still points at h2.
336 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h2]);
337 +
338 +
    // Dirty working copy refuses without --discard.
339 +
    write(root, "a.txt", b"dirty\n");
340 +
    assert!(repo.checkout(&[h2], false, "oxy checkout").is_err());
341 +
    let r = repo.checkout(&[h2], true, "oxy checkout --discard").unwrap();
342 +
    assert_eq!(fs::read(root.join("a.txt")).unwrap(), b"two\n");
343 +
    assert_eq!(fs::read(root.join("bin.dat")).unwrap(), [0, 1, 2, 3]);
344 +
    assert!(!root.join("sub").exists(), "empty directories are pruned");
345 +
    assert_eq!(r.removed, 1);
346 +
    assert!(!repo.snapshot("status").unwrap().changed);
347 +
348 +
    // Refuses to write through a symlinked directory.
349 +
    #[cfg(unix)]
350 +
    {
351 +
        fs::remove_dir_all(root.join("sub")).ok();
352 +
        std::os::unix::fs::symlink("/tmp", root.join("sub")).unwrap();
353 +
        assert!(repo.checkout(&[h1], true, "oxy checkout").is_err());
354 +
    }
355 +
}
356 +
357 +
#[test]
358 +
fn large_files_are_chunked_and_read_back() {
359 +
    hermetic();
360 +
    let dir = tempfile::tempdir().unwrap();
361 +
    let repo = Repo::init(dir.path()).unwrap();
362 +
    let mut big = vec![0u8; 700 * 1024];
363 +
    for (i, b) in big.iter_mut().enumerate() {
364 +
        *b = (i as u32).wrapping_mul(2654435761).to_le_bytes()[0];
365 +
    }
366 +
    let r = repo.store_blob(&big).unwrap();
367 +
    let blob = Blob::decode(&repo.store().get_typed(&r, ObjectType::Blob).unwrap()).unwrap();
368 +
    assert!(blob.chunks.len() >= 3, "{}", blob.chunks.len());
369 +
    assert_eq!(repo.read_blob(&r).unwrap(), big);
370 +
    assert_eq!(Repo::blob_ref(&big), r);
371 +
}
372 +
373 +
#[test]
374 +
fn clone_push_pull_with_seals_and_attestations() {
375 +
    hermetic();
376 +
    let dir = tempfile::tempdir().unwrap();
377 +
    let root = dir.path();
378 +
    let origin_path = root.join("origin");
379 +
    let origin = Repo::init(&origin_path).unwrap();
380 +
    let origin_url = origin_path.to_string_lossy().to_string();
381 +
382 +
    // A clones, creates an identity (default core.sign = seal), commits, pushes.
383 +
    let mut a = Repo::clone(&origin_url, &root.join("a")).unwrap().with_home(root.join("home-a"));
384 +
    assert_eq!(a.id().unwrap(), origin.id().unwrap());
385 +
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::Repo).unwrap();
386 +
    write(&root.join("a"), "f.txt", b"a\n");
387 +
    let h1 = a.commit("first", &[]).unwrap();
388 +
    let results = a.push("origin", false).unwrap();
389 +
    assert!(results[0].ok, "{}", results[0].reason);
390 +
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h1]);
391 +
    // The change was sealed at push and the reference attested.
392 +
    assert!(matches!(origin.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { key, sealed: true, .. } if key == key_a));
393 +
    assert_eq!(origin.verify_change(&h1).unwrap(), SignatureStatus::Unsigned);
394 +
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
395 +
    assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }));
396 +
    let again = a.push("origin", false).unwrap();
397 +
    assert!(again[0].ok && again[0].reason.contains("up to date"));
398 +
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
399 +
    assert!(matches!(att.attest.body, AttestBody::Ref { serial: 1, .. }), "a no-op push does not attest again");
400 +
401 +
    // B clones and sees the file and the seal.
402 +
    let b = Repo::clone(&origin_url, &root.join("b")).unwrap();
403 +
    assert_eq!(fs::read(root.join("b/f.txt")).unwrap(), b"a\n");
404 +
    assert_eq!(b.bookmark_heads().unwrap(), vec![h1]);
405 +
    assert!(matches!(b.authenticate_change(&h1).unwrap(), SignatureStatus::Valid { sealed: true, .. }));
406 +
407 +
    // B (no identity) commits and pushes unsigned; the unverified origin accepts.
408 +
    write(&root.join("b"), "f.txt", b"a\nb\n");
409 +
    let h2 = b.commit("second", &[]).unwrap();
410 +
    assert!(b.push("origin", false).unwrap()[0].ok);
411 +
    assert_eq!(origin.bookmark_heads().unwrap(), vec![h2]);
412 +
413 +
    // A commits an independent change; push is refused until A pulls.
414 +
    write(&root.join("a"), "g.txt", b"g\n");
415 +
    let h3 = a.commit("third", &[]).unwrap();
416 +
    assert!(a.push("origin", false).is_err());
417 +
    let pr = a.pull("origin").unwrap();
418 +
    assert!(pr.changed && pr.checked_out);
419 +
    let mut want = vec![h2, h3];
420 +
    want.sort();
421 +
    assert_eq!(pr.heads, want);
422 +
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nb\n");
423 +
    assert_eq!(fs::read(root.join("a/g.txt")).unwrap(), b"g\n");
424 +
    let results = a.push("origin", false).unwrap();
425 +
    assert!(results[0].ok, "{}", results[0].reason);
426 +
    let mut origin_heads = origin.bookmark_heads().unwrap();
427 +
    origin_heads.sort();
428 +
    assert_eq!(origin_heads, want);
429 +
    let (_, att) = origin.latest_ref_attest("main").unwrap().unwrap();
430 +
    assert!(matches!(att.attest.body, AttestBody::Ref { serial: 2, .. }));
431 +
432 +
    // B pulls and ends up identical.
433 +
    let pr = b.pull("origin").unwrap();
434 +
    assert_eq!(pr.heads, want);
435 +
    assert_eq!(fs::read(root.join("b/g.txt")).unwrap(), b"g\n");
436 +
    assert!(!b.snapshot("status").unwrap().changed);
437 +
438 +
    // Concurrent edits of the same line: the union is a reified conflict.
439 +
    write(&root.join("a"), "f.txt", b"a\nA\n");
440 +
    a.commit("a-edit", &[]).unwrap();
441 +
    a.push("origin", false).unwrap();
442 +
    write(&root.join("b"), "f.txt", b"a\nB\n");
443 +
    b.commit("b-edit", &[]).unwrap();
444 +
    let pr = b.pull("origin").unwrap();
445 +
    assert!(pr.changed && pr.checked_out);
446 +
    let text = fs::read_to_string(root.join("b/f.txt")).unwrap();
447 +
    assert!(text.contains("<<<<<<< oxy conflict"), "{text}");
448 +
    write(&root.join("b"), "f.txt", b"a\nAB\n");
449 +
    let hr = b.commit("resolve", &[]).unwrap();
450 +
    assert_eq!(b.store().get_change(&hr).unwrap().kind, ChangeKind::Resolution);
451 +
    assert!(b.push("origin", false).unwrap()[0].ok);
452 +
    a.pull("origin").unwrap();
453 +
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"a\nAB\n");
454 +
    // The resolution depends on the conflicting edits, but nothing depends
455 +
    // on "third" (it only added g.txt), so it stays a head beside it.
456 +
    let mut want = vec![hr, h3];
457 +
    want.sort();
458 +
    assert_eq!(a.bookmark_heads().unwrap(), want);
459 +
    assert!(!a.load_state(&want).unwrap().materialize().is_conflicted());
460 +
}
461 +
462 +
#[test]
463 +
fn receive_push_rejects_bad_updates() {
464 +
    hermetic();
465 +
    let dir = tempfile::tempdir().unwrap();
466 +
    let root = dir.path();
467 +
    let origin = Repo::init(&root.join("origin")).unwrap();
468 +
    let url = root.join("origin").to_string_lossy().to_string();
469 +
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home"));
470 +
    let (key, _) = a.create_identity("Ada", "a@b", IdentityScope::Repo).unwrap();
471 +
    write(&root.join("a"), "f.txt", b"x\n");
472 +
    let h1 = a.commit("one", &[]).unwrap();
473 +
    assert!(a.push("origin", false).unwrap()[0].ok);
474 +
    let empty = origin.store().put_state(&StateObject::new(vec![])).unwrap();
475 +
    let s1 = origin.store().put_state(&StateObject::new(vec![h1])).unwrap();
476 +
477 +
    // Missing head.
478 +
    let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
479 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }]).unwrap();
480 +
    assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
481 +
482 +
    // Stale old_state.
483 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }]).unwrap();
484 +
    assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
485 +
486 +
    // Non-fast-forward without force is refused; with force it passes.
487 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }]).unwrap();
488 +
    assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
489 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }]).unwrap();
490 +
    assert!(r[0].ok);
491 +
    assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
492 +
493 +
    // Attestation with a stale serial is a rollback and is refused.
494 +
    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();
495 +
    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();
496 +
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
497 +
    // An attestation for a different update is refused too.
498 +
    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();
499 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }]).unwrap();
500 +
    assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
501 +
    let _ = key;
502 +
}
503 +
504 +
#[test]
505 +
fn user_config_supplies_defaults_and_clones_sign() {
506 +
    hermetic();
507 +
    let dir = tempfile::tempdir().unwrap();
508 +
    let root = dir.path();
509 +
    let home = root.join("home");
510 +
    let origin = Repo::init(&root.join("origin")).unwrap();
511 +
    let url = root.join("origin").to_string_lossy().to_string();
512 +
513 +
    // Identity at user scope: the repository file stays free of author keys.
514 +
    let mut a = Repo::init(&root.join("a")).unwrap().with_home(&home);
515 +
    let (key, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
516 +
    assert!(!a.config.repo_layer().contains_key("author.key"));
517 +
    assert_eq!(a.config.author_key, Some(key));
518 +
    assert!(home.join("config.toml").exists());
519 +
    a.set_user_config("core.sign", "change").unwrap();
520 +
    assert!(a.config.sign_changes);
521 +
522 +
    // Repository settings override user settings and are the only ones saved.
523 +
    a.config.set("core.sign", "seal").unwrap();
524 +
    a.save_config().unwrap();
525 +
    let again = Repo::open(&root.join("a")).unwrap().with_home(&home);
526 +
    assert!(!again.config.sign_changes);
527 +
    assert_eq!(again.config.author_key, Some(key));
528 +
    assert_eq!(read_config_file(&root.join("a/.oxy/config")).unwrap().get("core.sign").map(String::as_str), Some("seal"));
529 +
530 +
    // A fresh clone made by this user signs with the user-level identity,
531 +
    // even though the clone's store has never seen the identity object.
532 +
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(&home);
533 +
    assert!(b.config.sign_changes);
534 +
    write(&root.join("b"), "f.txt", b"f\n");
535 +
    let h = b.commit("signed from a clone", &[]).unwrap();
536 +
    assert!(matches!(b.verify_change(&h).unwrap(), SignatureStatus::Valid { key: k, .. } if k == key));
537 +
    assert!(b.push("origin", false).unwrap()[0].ok);
538 +
    assert!(matches!(origin.verify_change(&h).unwrap(), SignatureStatus::Valid { .. }));
539 +
}
540 +
541 +
#[test]
542 +
fn format1_repositories_are_migrated_on_open() {
543 +
    hermetic();
544 +
    use crate::oplog::{Format1Op, Format1View};
545 +
    let dir = tempfile::tempdir().unwrap();
546 +
    let root = dir.path();
547 +
    let repo = Repo::init(root).unwrap();
548 +
    write(root, "a.txt", b"a\n");
549 +
    let h1 = repo.commit("one", &[]).unwrap();
550 +
    let (wc_hash, _) = repo.working_copy().unwrap();
551 +
    // Rewrite the op log the way format 1 stored it: two ops, head lists.
552 +
    let mut v1 = Format1View::default();
553 +
    v1.bookmarks.insert("main".into(), vec![]);
554 +
    v1.workspaces.insert(repo.workspace(), wc_hash);
555 +
    let init = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1.clone() };
556 +
    let init_ref = repo.store().put(ObjectType::Op, &init.encode()).unwrap();
557 +
    v1.bookmarks.insert("main".into(), vec![h1]);
558 +
    let commit = Format1Op { parents: vec![init_ref], hlc: Hlc::default(), command: "oxy commit".into(), view: v1 };
559 +
    let commit_ref = repo.store().put(ObjectType::Op, &commit.encode()).unwrap();
560 +
    write_atomic(&root.join(".oxy/ops/head"), commit_ref.to_hex().as_bytes()).unwrap();
561 +
    write_atomic(&root.join(".oxy/format"), FORMAT1_LINE.as_bytes()).unwrap();
562 +
    drop(repo);
563 +
564 +
    let repo = Repo::open(root).unwrap();
565 +
    assert_eq!(fs::read_to_string(root.join(".oxy/format")).unwrap(), FORMAT_LINE);
566 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![h1]);
567 +
    let ops = repo.op_log().unwrap();
568 +
    assert_eq!(ops.iter().map(|(_, o)| o.command.as_str()).collect::<Vec<_>>(), ["oxy commit", "oxy init"]);
569 +
    assert_eq!(repo.heads_of(&ops[1].1.view, "main").unwrap(), vec![]);
570 +
    assert!(!repo.snapshot("status").unwrap().changed);
571 +
    // Reopening again is a no-op.
572 +
    assert_eq!(Repo::open(root).unwrap().op_log().unwrap().len(), 2);
573 +
}
574 +
575 +
#[test]
576 +
fn undo_skips_snapshot_operations() {
577 +
    hermetic();
578 +
    let dir = tempfile::tempdir().unwrap();
579 +
    let root = dir.path();
580 +
    let repo = Repo::init(root).unwrap();
581 +
    write(root, "a.txt", b"a\n");
582 +
    let h1 = repo.commit("a", &[]).unwrap();
583 +
    // A later command snapshots new edits on top of the commit.
584 +
    write(root, "b.txt", b"b\n");
585 +
    let s = repo.snapshot("oxy status").unwrap();
586 +
    assert!(s.changed);
587 +
    assert!(repo.head_op().unwrap().1.is_snapshot());
588 +
    assert_eq!(repo.last_command().unwrap().command, "oxy commit -m \"a\"");
589 +
    // Undo must undo the commit, not the snapshot.
590 +
    repo.undo().unwrap();
591 +
    assert_eq!(repo.bookmark_heads().unwrap(), vec![]);
592 +
    let s = repo.snapshot("oxy status").unwrap();
593 +
    let paths: Vec<_> = s.statuses.iter().map(|p| p.path.as_str()).collect();
594 +
    assert_eq!(paths, ["a.txt", "b.txt"], "both files are back in the working copy");
595 +
    assert!(repo.store().get_change(&h1).unwrap().deps.is_empty());
596 +
    // Only snapshots remain above `init`, so there is nothing left to undo.
597 +
    assert_eq!(repo.last_command().unwrap().command, "oxy init");
598 +
    assert!(repo.undo().is_err());
599 +
}
600 +
601 +
#[test]
602 +
fn independent_changes_commute_and_make_multiple_heads() {
603 +
    hermetic();
604 +
    let dir = tempfile::tempdir().unwrap();
605 +
    let root = dir.path();
606 +
    let repo = Repo::init(root).unwrap();
607 +
    write(root, "a.txt", b"a\n");
608 +
    let h1 = repo.commit("a", &[]).unwrap();
609 +
    write(root, "b.txt", b"b\n");
610 +
    let h2 = repo.commit("b", &[]).unwrap();
611 +
    let c2 = repo.store().get_change(&h2).unwrap();
612 +
    assert!(c2.deps.is_empty(), "b.txt touches nothing from h1");
613 +
    let mut heads = repo.bookmark_heads().unwrap();
614 +
    heads.sort();
615 +
    let mut want = vec![h1, h2];
616 +
    want.sort();
617 +
    assert_eq!(heads, want);
618 +
    // Editing a.txt depends on h1 only.
619 +
    write(root, "a.txt", b"a\na2\n");
620 +
    let h3 = repo.commit("a2", &[]).unwrap();
621 +
    assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
622 +
    let heads: BTreeSet<_> = repo.bookmark_heads().unwrap().into_iter().collect();
623 +
    assert_eq!(heads, [h2, h3].into_iter().collect());
624 +
    // Explicit dependency.
625 +
    write(root, "c.txt", b"c\n");
626 +
    let h4 = repo.commit("c", &[h2]).unwrap();
627 +
    assert_eq!(repo.store().get_change(&h4).unwrap().deps, vec![h2]);
628 +
}
629 +
630 +
#[test]
631 +
fn mode_change_and_text_binary_switch() {
632 +
    hermetic();
633 +
    let dir = tempfile::tempdir().unwrap();
634 +
    let root = dir.path();
635 +
    let repo = Repo::init(root).unwrap();
636 +
    write(root, "run.sh", b"#!/bin/sh\n");
637 +
    repo.commit("add script", &[]).unwrap();
638 +
    #[cfg(unix)]
639 +
    {
640 +
        use std::os::unix::fs::PermissionsExt;
641 +
        fs::set_permissions(root.join("run.sh"), fs::Permissions::from_mode(0o755)).unwrap();
642 +
        let s = repo.snapshot("status").unwrap();
643 +
        assert_eq!(statuses(&s), vec![("run.sh".into(), ChangeStatus::ModeChanged)]);
644 +
        let h = repo.commit("exec", &[]).unwrap();
645 +
        let snap = repo.load_state(&[h]).unwrap().materialize();
646 +
        assert!(snap.files["run.sh"].mode.exec);
647 +
    }
648 +
    // Text → binary → text.
649 +
    write(root, "run.sh", b"\0binary");
650 +
    let h = repo.commit("binary", &[]).unwrap();
651 +
    let snap = repo.load_state(&[h]).unwrap().materialize();
652 +
    assert!(snap.files["run.sh"].mode.opaque);
653 +
    write(root, "run.sh", b"text again\n");
654 +
    let h = repo.commit("text", &[]).unwrap();
655 +
    let snap = repo.load_state(&[h]).unwrap().materialize();
656 +
    assert!(!snap.files["run.sh"].mode.opaque);
657 +
    assert!(matches!(&snap.files["run.sh"].content, FileContent::Text { bytes, .. } if bytes == b"text again\n"));
658 +
    assert!(!snap.is_conflicted());
659 +
}
removed crates/oxy-core/src/repo.rs +0 −0
…
2539 unchanged lines
modified crates/oxy-server/tests/http.rs +14 −0
…
10 unchanged lines
11 11
    fs::write(p, content).unwrap();
12 12
}
13 13
14 +
/// Point `$OXY_HOME` at a throwaway directory once per test process so no
15 +
/// test reads or writes the developer's real user configuration or keys.
16 +
fn hermetic() {
17 +
    static ONCE: std::sync::Once = std::sync::Once::new();
18 +
    static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
19 +
    ONCE.call_once(|| {
20 +
        let d = tempfile::tempdir().unwrap();
21 +
        std::env::set_var("OXY_HOME", d.path());
22 +
        DIR.set(d).ok();
23 +
    });
24 +
}
25 +
14 26
fn start(root: &Path, tokens: Vec<String>) -> String {
15 27
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16 28
    let port = listener.local_addr().unwrap().port();
…
4 unchanged lines
21 33
22 34
#[test]
23 35
fn clone_push_pull_over_http() {
36 +
    hermetic();
24 37
    let dir = tempfile::tempdir().unwrap();
25 38
    let root = dir.path();
26 39
    let served = root.join("served");
…
36 unchanged lines
63 76
64 77
#[test]
65 78
fn push_requires_a_token_when_configured() {
79 +
    hermetic();
66 80
    let dir = tempfile::tempdir().unwrap();
67 81
    let root = dir.path();
68 82
    let served = root.join("served");
…
21 unchanged lines