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 |