OXYGENHUB

Hooks: pre-commit, post-commit, pre-push, post-fetch, post-checkout with JSON payloads; semantic-deps hook behind hooks.semantic-deps (spec 0.6k) 6d3eefbce2f0

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:53 UTC
Signature
✔ signed by Andy McCall
Hash
6d3eefbce2f0337e9519167768ce61c6037288658944904e8e83bd0da13ada92
Depends on
132b884b9a8d 135212de5370 614541c3cebd 7421309f5372 982864068a59 b3c5a59a875a ba160f60219e cdb5beba7f05 d02e545dc265 ff0af1ee299d
Depended on by
4afe288b7946 d6e0d5b8b339 42ccf504aa50 d069701a4997 47ce8b8e2d4b 192fad1e46a2 84b9e9ac0ab4 75167672ebcd 957fee30826e 7b9809e67c3d 694805cca585 23b217d75848
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
21 atoms, 1 tree operation, 8 files

Browse the tree at this change

added crates/oxy-core/src/repo/hooks.rs +105 −0
1 +
//! Client hooks (spec §20): executables in `.oxy/hooks/<event>`, never
2 +
//! cloned. Each receives one JSON object on stdin; `pre-*` hooks block the
3 +
//! operation by exiting non-zero, and their stderr becomes the error. The
4 +
//! `semantic-deps` hook runs only when `hooks.semantic-deps = true` and
5 +
//! answers `{"deps": ["<hash hex>", ...]}`.
6 +
7 +
use super::*;
8 +
use std::process::{Command, Stdio};
9 +
10 +
/// Minimal JSON string escaping.
11 +
pub fn json_str(s: &str) -> String {
12 +
    let mut out = String::with_capacity(s.len() + 2);
13 +
    out.push('"');
14 +
    for c in s.chars() {
15 +
        match c {
16 +
            '"' => out.push_str("\\\""),
17 +
            '\\' => out.push_str("\\\\"),
18 +
            '\n' => out.push_str("\\n"),
19 +
            '\r' => out.push_str("\\r"),
20 +
            '\t' => out.push_str("\\t"),
21 +
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
22 +
            c => out.push(c),
23 +
        }
24 +
    }
25 +
    out.push('"');
26 +
    out
27 +
}
28 +
29 +
pub fn json_array<I: IntoIterator<Item = String>>(items: I) -> String {
30 +
    format!("[{}]", items.into_iter().collect::<Vec<_>>().join(","))
31 +
}
32 +
33 +
/// What running a hook produced.
34 +
#[derive(Debug, Clone, PartialEq, Eq)]
35 +
pub struct HookOutcome {
36 +
    pub ran: bool,
37 +
    pub stdout: String,
38 +
}
39 +
40 +
impl Repo {
41 +
    fn hook_path(&self, event: &str) -> PathBuf {
42 +
        self.oxy.join("hooks").join(event)
43 +
    }
44 +
45 +
    /// Run the hook for `event` with `input` on stdin, if one exists and is
46 +
    /// executable. A non-zero exit is an error carrying the hook's stderr.
47 +
    pub fn run_hook(&self, event: &str, input: &str) -> Result<HookOutcome> {
48 +
        let path = self.hook_path(event);
49 +
        if !path.is_file() {
50 +
            return Ok(HookOutcome { ran: false, stdout: String::new() });
51 +
        }
52 +
        let mut child = Command::new(&path)
53 +
            .current_dir(&self.root)
54 +
            .env("OXY_EVENT", event)
55 +
            .env("OXY_ROOT", &self.root)
56 +
            .stdin(Stdio::piped())
57 +
            .stdout(Stdio::piped())
58 +
            .stderr(Stdio::piped())
59 +
            .spawn()
60 +
            .map_err(|e| RepoError::Msg(format!("running hook {event}: {e}")))?;
61 +
        if let Some(mut stdin) = child.stdin.take() {
62 +
            let _ = stdin.write_all(input.as_bytes());
63 +
        }
64 +
        let out = child.wait_with_output()?;
65 +
        let stdout = String::from_utf8_lossy(&out.stdout).to_string();
66 +
        if !out.status.success() {
67 +
            let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
68 +
            return fail(format!("hook {event} refused: {}", if stderr.is_empty() { "(no message)".to_string() } else { stderr }));
69 +
        }
70 +
        Ok(HookOutcome { ran: true, stdout })
71 +
    }
72 +
73 +
    /// Extra dependencies from the `semantic-deps` hook (spec §8.1, §20).
74 +
    pub(crate) fn semantic_deps(&self, change_id: &ChangeId, touched: &[String], candidates: &[(ChangeId, ChangeHash, Vec<String>)]) -> Result<Vec<ChangeHash>> {
75 +
        if !self.config.semantic_deps {
76 +
            return Ok(vec![]);
77 +
        }
78 +
        let input = format!(
79 +
            "{{\"change_id\":{},\"touched_paths\":{},\"candidate_deps\":{}}}",
80 +
            json_str(&change_id.to_hex()),
81 +
            json_array(touched.iter().map(|p| json_str(p))),
82 +
            json_array(candidates.iter().map(|(id, h, paths)| format!("{{\"change_id\":{},\"hash\":{},\"paths\":{}}}", json_str(&id.to_hex()), json_str(&h.to_hex()), json_array(paths.iter().map(|p| json_str(p))))))
83 +
        );
84 +
        let out = self.run_hook("semantic-deps", &input)?;
85 +
        if !out.ran {
86 +
            return Ok(vec![]);
87 +
        }
88 +
        // Accept every 64-hex token after "deps"; any other shape is ignored.
89 +
        let body = out.stdout.split_once("\"deps\"").map(|(_, rest)| rest.to_string()).unwrap_or_default();
90 +
        let mut deps = Vec::new();
91 +
        let bytes = body.as_bytes();
92 +
        let mut i = 0;
93 +
        while i + 64 <= bytes.len() {
94 +
            if bytes[i..i + 64].iter().all(|b| b.is_ascii_hexdigit()) && (i == 0 || !bytes[i - 1].is_ascii_hexdigit()) && (i + 64 == bytes.len() || !bytes[i + 64].is_ascii_hexdigit()) {
95 +
                if let Some(h) = ObjectRef::from_hex(&body[i..i + 64]) {
96 +
                    deps.push(h);
97 +
                }
98 +
                i += 64;
99 +
            } else {
100 +
                i += 1;
101 +
            }
102 +
        }
103 +
        Ok(deps)
104 +
    }
105 +
}
modified crates/oxy-core/src/repo/checkout.rs +5 −1
…
93 unchanged lines
94 94
        }
95 95
96 96
        let (op_ref, op) = self.head_op()?;
97 -
        let wc = self.new_working_copy(heads, "");
97 +
        let wc = self.new_working_copy(heads.clone(), "");
98 98
        let wc_hash = self.store.put_change(&wc)?;
99 99
        let mut view = op.view.clone();
100 100
        view.workspaces.insert(self.workspace, wc_hash);
101 101
        self.record_op(&[op_ref], command, view)?;
102 102
        report.conflicted = target.is_conflicted();
103 103
        self.write_manifest(report.conflicted, false)?;
104 +
        let _ = self.run_hook(
105 +
            "post-checkout",
106 +
            &format!("{{\"event\":\"post-checkout\",\"heads\":{},\"written\":{},\"removed\":{}}}", json_array(heads.iter().map(|h| json_str(&h.to_hex()))), report.written, report.removed),
107 +
        );
104 108
        Ok(report)
105 109
    }
106 110
…
63 unchanged lines
modified crates/oxy-core/src/repo/config.rs +4 −0
…
32 unchanged lines
33 33
    pub max_blob: Option<u64>,
34 34
    /// `filter.paths`: only these subtrees are materialized (spec §17); empty means everything.
35 35
    pub sparse_paths: Vec<String>,
36 +
    /// `hooks.semantic-deps`: run the semantic-deps hook at commit (spec §20).
37 +
    pub semantic_deps: bool,
36 38
}
37 39
38 40
impl Config {
…
14 unchanged lines
53 55
            checkpoint_interval: 256,
54 56
            max_blob: None,
55 57
            sparse_paths: Vec::new(),
58 +
            semantic_deps: false,
56 59
        }
57 60
    }
58 61
…
46 unchanged lines
105 108
            "core.op-retention" => self.op_retention_days = parse_days(value)?,
106 109
            "core.gc-grace" => self.gc_grace_days = parse_days(value)?,
107 110
            "filter.max-blob" => self.max_blob = if value.is_empty() { None } else { Some(parse_size(value)?) },
111 +
            "hooks.semantic-deps" => self.semantic_deps = matches!(value, "true" | "1" | "yes"),
108 112
            "filter.paths" => {
109 113
                self.sparse_paths = value
110 114
                    .split(',')
…
104 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
56 unchanged lines
57 57
mod gc;
58 58
mod git;
59 59
mod history;
60 +
mod hooks;
60 61
mod identity;
61 62
mod policy;
62 63
mod prompt;
…
7 unchanged lines
70 71
pub use checkout::CheckoutReport;
71 72
pub use config::{parse_size, read_config_file, Config};
72 73
pub use gc::GcReport;
74 +
pub use hooks::{json_array, json_str, HookOutcome};
73 75
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT};
74 76
pub use git::{GitExportReport, GitImportReport};
75 77
pub use identity::SignatureStatus;
…
631 unchanged lines
modified crates/oxy-core/src/repo/snapshot.rs +35 −0
…
168 unchanged lines
169 169
        if message.trim().is_empty() {
170 170
            return fail("a commit message is required");
171 171
        }
172 +
        // pre-commit hook: may veto.
173 +
        self.run_hook(
174 +
            "pre-commit",
175 +
            &format!(
176 +
                "{{\"event\":\"pre-commit\",\"message\":{},\"paths\":{}}}",
177 +
                json_str(message),
178 +
                json_array(snap.statuses.iter().map(|s| format!("{{\"path\":{},\"status\":{}}}", json_str(&s.path), json_str(&format!("{:?}", s.status)))))
179 +
            ),
180 +
        )?;
172 181
        let (op_ref, op) = self.head_op()?;
173 182
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&snap.wc, &snap.base);
183 +
        // semantic-deps hook: may add explicit dependencies (spec §8.1).
184 +
        if self.config.semantic_deps {
185 +
            let touched: Vec<String> = snap.statuses.iter().map(|s| s.path.clone()).collect();
186 +
            let by_node = snap.base_snapshot.tree.by_node();
187 +
            let mut candidates: Vec<(ChangeId, ChangeHash, Vec<String>)> = Vec::new();
188 +
            let mut seen: BTreeSet<ChangeHash> = BTreeSet::new();
189 +
            for h in deps.iter().chain(snap.base.maximal(&snap.wc.deps).iter()) {
190 +
                if !seen.insert(*h) {
191 +
                    continue;
192 +
                }
193 +
                let c = self.store.get_change(h)?;
194 +
                let mut paths: BTreeSet<String> = BTreeSet::new();
195 +
                for n in c.tree_ops.iter().map(|o| o.node()).chain(c.edits.iter().map(|e| e.node())) {
196 +
                    if let Some(e) = by_node.get(&n) {
197 +
                        paths.insert(e.path.clone());
198 +
                    }
199 +
                }
200 +
                candidates.push((c.change_id, *h, paths.into_iter().collect()));
201 +
            }
202 +
            for d in self.semantic_deps(&snap.wc.change_id, &touched, &candidates)? {
203 +
                if snap.base.contains(&d) {
204 +
                    deps.insert(d);
205 +
                }
206 +
            }
207 +
        }
174 208
        // A resolution depends on every change that contributed to the
175 209
        // conflict (spec §8.1). Tree and opaque conflicts are resolved through
176 210
        // ordinary operations whose references already cover the contributors.
…
49 unchanged lines
226 260
        self.record_op(&[op_ref], &format!("oxy commit -m {message:?}"), view)?;
227 261
        self.write_manifest(snap.base_snapshot.is_conflicted() && !snap.resolves, false)?;
228 262
        self.maybe_autopack()?;
263 +
        let _ = self.run_hook("post-commit", &format!("{{\"event\":\"post-commit\",\"change_id\":{},\"hash\":{},\"message\":{}}}", json_str(&snap.wc.change_id.to_hex()), json_str(&hash.to_hex()), json_str(message)));
229 264
        Ok(hash)
230 265
    }
231 266
}
…
402 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +16 −3
…
685 unchanged lines
686 686
        if view != op.view {
687 687
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
688 688
        }
689 +
        let _ = self.run_hook(
690 +
            "post-fetch",
691 +
            &format!(
692 +
                "{{\"event\":\"post-fetch\",\"remote\":{},\"bookmarks\":{}}}",
693 +
                json_str(remote_name),
694 +
                json_array(report.iter().map(|(n, heads, _)| format!("{{\"name\":{},\"heads\":{}}}", json_str(n), json_array(heads.iter().map(|h| json_str(&h.to_hex()))))))
695 +
            ),
696 +
        );
689 697
        Ok(FetchReport { refs: report, unverified, new_tags })
690 698
    }
691 699
…
94 unchanged lines
786 794
    /// Push the current bookmark (spec §16.3), sealing this author's unsigned
787 795
    /// changes and attesting the reference when a key is available.
788 796
    pub fn push(&self, remote_name: &str, force: bool) -> Result<Vec<RefResult>> {
797 +
        let (op_ref, op) = self.head_op()?;
798 +
        let bookmark = self.config.bookmark.clone();
799 +
        let local_heads = self.heads_of(&op.view, &bookmark)?;
800 +
        // pre-push hook: may veto before the remote is contacted.
801 +
        self.run_hook(
802 +
            "pre-push",
803 +
            &format!("{{\"event\":\"pre-push\",\"remote\":{},\"bookmark\":{},\"heads\":{}}}", json_str(remote_name), json_str(&bookmark), json_array(local_heads.iter().map(|h| json_str(&h.to_hex())))),
804 +
        )?;
789 805
        let remote = self.open_named_remote(remote_name)?;
790 806
        if remote.repo_id()? != self.id()? {
791 807
            return fail(format!("remote {remote_name:?} is a different repository (repository IDs differ)"));
792 808
        }
793 -
        let (op_ref, op) = self.head_op()?;
794 -
        let bookmark = self.config.bookmark.clone();
795 -
        let local_heads = self.heads_of(&op.view, &bookmark)?;
796 809
        let resp = remote.refs()?;
797 810
        let local_policy = self.current_policy()?.map(|(_, sp)| sp);
798 811
        let policy_to_send = match (&local_policy, &resp.policy) {
…
151 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +50 −0
…
1473 unchanged lines
1474 1474
    repo.checkout(&[h], true, "co").unwrap();
1475 1475
    assert_eq!(fs::read(root.join("assets/model.bin")).unwrap(), b"\0model");
1476 1476
}
1477 +
1478 +
#[cfg(unix)]
1479 +
#[test]
1480 +
fn hooks_can_veto_observe_and_add_dependencies() {
1481 +
    use std::os::unix::fs::PermissionsExt;
1482 +
    hermetic();
1483 +
    let dir = tempfile::tempdir().unwrap();
1484 +
    let root = dir.path();
1485 +
    let mut repo = Repo::init(root).unwrap().with_home(root.join("home"));
1486 +
    let hooks = root.join(".oxy/hooks");
1487 +
    fs::create_dir_all(&hooks).unwrap();
1488 +
    let hook = |name: &str, body: &str| {
1489 +
        let p = hooks.join(name);
1490 +
        fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
1491 +
        fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
1492 +
    };
1493 +
    hook("pre-commit", "cat > \"$OXY_ROOT/.last-pre-commit\"; grep -q WIP \"$OXY_ROOT/.last-pre-commit\" && { echo 'no WIP commits' >&2; exit 1; }; exit 0");
1494 +
    hook("post-commit", "cat > \"$OXY_ROOT/.last-post-commit\"");
1495 +
    hook("post-checkout", "cat > \"$OXY_ROOT/.last-post-checkout\"");
1496 +
    hook("pre-push", "echo pushing blocked >&2; exit 3");
1497 +
    write(root, ".oxyignore", b".last-*\n");
1498 +
    write(root, "a.txt", b"a\n");
1499 +
    let err = repo.commit("WIP: nope", &[]).unwrap_err().to_string();
1500 +
    assert!(err.contains("pre-commit refused: no WIP commits"), "{err}");
1501 +
    let h1 = repo.commit("first", &[]).unwrap();
1502 +
    let pre = fs::read_to_string(root.join(".last-pre-commit")).unwrap();
1503 +
    assert!(pre.contains("\"event\":\"pre-commit\"") && pre.contains("\"path\":\"a.txt\"") && pre.contains("\"message\":\"first\""), "{pre}");
1504 +
    let post = fs::read_to_string(root.join(".last-post-commit")).unwrap();
1505 +
    assert!(post.contains(&h1.to_hex()), "{post}");
1506 +
    repo.checkout(&[h1], false, "co").unwrap();
1507 +
    assert!(fs::read_to_string(root.join(".last-post-checkout")).unwrap().contains("post-checkout"));
1508 +
    repo.config.set("remote.origin.url", "/nowhere").unwrap();
1509 +
    repo.save_config().unwrap();
1510 +
    let err = repo.push("origin", false).unwrap_err().to_string();
1511 +
    assert!(err.contains("pre-push refused: pushing blocked"), "{err}");
1512 +
1513 +
    // semantic-deps: only when enabled; adds an explicit dependency.
1514 +
    write(root, "b.txt", b"b\n");
1515 +
    hook("semantic-deps", &format!("cat > \"$OXY_ROOT/.last-semantic\"; echo '{{\"deps\": [\"{}\"]}}'", h1.to_hex()));
1516 +
    let h2 = repo.commit("b without hook", &[]).unwrap();
1517 +
    assert!(repo.store().get_change(&h2).unwrap().deps.is_empty(), "hook disabled by default");
1518 +
    repo.config.set("hooks.semantic-deps", "true").unwrap();
1519 +
    repo.save_config().unwrap();
1520 +
    write(root, "c.txt", b"c\n");
1521 +
    let h3 = repo.commit("c with hook", &[]).unwrap();
1522 +
    assert_eq!(repo.store().get_change(&h3).unwrap().deps, vec![h1]);
1523 +
    let sem = fs::read_to_string(root.join(".last-semantic")).unwrap();
1524 +
    assert!(sem.contains("\"touched_paths\":[\"c.txt\"]") && sem.contains("candidate_deps"), "{sem}");
1525 +
    assert_eq!(json_str("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
1526 +
}
modified docs/spec.md +6 −4
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6j |
5 +
| **Status** | Draft 0.6k |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
735 unchanged lines
744 744
745 745
## 20. Hooks and server policy
746 746
747 -
Client hooks live in `.oxy/hooks/` and are never cloned. Events: `pre-commit`, `semantic-deps`, `post-commit`, `pre-push`, `post-fetch`, `post-checkout`. A hook receives its event as JSON on stdin and blocks the operation by non-zero exit for `pre-*` events.
747 +
Client hooks live in `.oxy/hooks/` and are never cloned. Events: `pre-commit`, `semantic-deps`, `post-commit`, `pre-push`, `post-fetch`, `post-checkout`. A hook is an executable named after its event, run in the workspace root with `OXY_EVENT` and `OXY_ROOT` set; it receives one JSON object on stdin and blocks the operation by non-zero exit for `pre-*` events, whose stderr becomes the error message. Payloads: `pre-commit` `{event, message, paths: [{path, status}]}`; `post-commit` `{event, change_id, hash, message}`; `pre-push` `{event, remote, bookmark, heads}`; `post-fetch` `{event, remote, bookmarks: [{name, heads}]}`; `post-checkout` `{event, heads, written, removed}`. Hashes and ids are hex strings.
748 748
749 -
The `semantic-deps` hook runs during `oxy commit` after the working copy is snapshotted and before the change is finalized. It receives `{ change_id, touched_paths, candidate_deps: [ { change_id, hash, paths } ] }`, where `candidate_deps` are the changes in the working-copy state that touch any file the new change touches or that it references by symbol if the hook has such information, and it returns `{ deps: [ChangeHash] }` to add to `deps(C)`. It runs only when `hooks.semantic-deps = true` is set in `.oxy/config`, which is never set by clone.
749 +
The `semantic-deps` hook runs during `oxy commit` after the working copy is snapshotted and before the change is finalized. It receives `{ change_id, touched_paths, candidate_deps: [ { change_id, hash, paths } ] }`, where `candidate_deps` are the changes the new change already references plus the heads it sits on, each with the paths it touched, and it returns `{ deps: [hash hex] }` to add to `deps(C)`; hashes not in the base state are ignored. It runs only when `hooks.semantic-deps = true` is set in `.oxy/config`, which is never set by clone.
750 750
751 751
Server policy is the trust policy (§14.5) plus server configuration for per-reference rules: protected patterns that additionally require CI status, forbid conflicted heads, forbid multiple heads, or forbid force (non-fast-forward) updates. A force update is a `ref` attestation whose `old_state` heads are not all in the closure of `new_state`.
752 752
…
89 unchanged lines
842 842
843 843
---
844 844
845 -
*Changelog.* Draft 0.6j (2026-09-27): `.oxyattributes` semantics (§19).
845 +
*Changelog.* Draft 0.6k (2026-09-27): hook execution model and JSON payloads (§20).
846 +
847 +
Draft 0.6j (2026-09-27): `.oxyattributes` semantics (§19).
846 848
847 849
Draft 0.6i (2026-09-27): secondary workspaces and their `.oxy` file (§12.2).
848 850
…
33 unchanged lines