#!/usr/bin/env nu # reflection/bin/jjw-ncl-merge.nu — 3-way NCL merge driver for jj resolve. # # jj calls this as: # nu jjw-ncl-merge.nu # # Register as a jj merge tool in ~/.config/jj/config.toml: # [merge-tools.ncl] # program = "nu" # args = ["/path/to/reflection/bin/jjw-ncl-merge.nu", "$left", "$right", "$base", "$output"] # # Usage from jjw: # jj resolve --tool ncl -- .ontology/core.ncl # # Strategy (two-layer): # 1. git merge-file — 3-way text merge. Handles disjoint record additions # without parsing NCL: side A adds node_x, side B adds node_y, both # added to the same record → clean text merge. # 2. nickel export — validates the text-merged result is legal NCL. # Catches pathological cases where text merge succeeded but produced # structurally invalid NCL (e.g., duplicate field names in different # positions, mismatched let-bindings). # # Exit 0: $output written with merged content. # Exit 1: auto-merge not possible — jj will fall back to manual resolution. # # Requires: git, nickel in PATH. # ONTOREF_ROOT or ONTOREF_PROJECT_ROOT must be set (provides --import-path # so nickel can locate schema.ncl and other imports). const ONLY_ONTOLOGY_FILES = [ "core.ncl", "state.ncl", "gate.ncl", "manifest.ncl", ] def main [ left: path, # jj "ours" side (current workspace change) right: path, # jj "theirs" side (the other side being merged) base: path, # jj common ancestor output: path, # jj expects the resolved content here ]: nothing -> nothing { let left_name = ($left | path basename) let right_name = ($right | path basename) # ── Verify both sides are valid NCL before attempting merge ────────────── let import_path = resolve-import-path $left let check_left = (validate-ncl $left $import_path) let check_right = (validate-ncl $right $import_path) if not $check_left.ok { error make { msg: $"left side is not valid NCL:\n($check_left.err)" } } if not $check_right.ok { error make { msg: $"right side is not valid NCL:\n($check_right.err)" } } # ── Layer 1: git merge-file (3-way text merge) ─────────────────────────── # --stdout writes result to stdout; none of the input files are modified. # Exits 0 = clean, 1 = conflict markers present, >1 = error. let merge_r = (do { ^git merge-file --stdout ($left) ($base) ($right) } | complete) if $merge_r.exit_code > 1 { error make { msg: $"git merge-file error (exit ($merge_r.exit_code)): ($merge_r.stderr | str trim)" } } if $merge_r.exit_code == 1 { let conflicts = (count-conflict-markers $merge_r.stdout) error make { msg: $"text-level merge has ($conflicts) conflict(s) — manual resolution required.\nUse: jj resolve -r @ -- " } } let merged_text = $merge_r.stdout # ── Layer 2: validate merged NCL ───────────────────────────────────────── # Write to a temp file so nickel can read it; defer ensures cleanup even # if nickel export throws or the process is interrupted. let tmp_merged = (mktemp --suffix ".ncl") defer { rm -f $tmp_merged } $merged_text | save --force $tmp_merged let validate = (validate-ncl $tmp_merged $import_path) if not $validate.ok { error make { msg: $"text merge succeeded but result is invalid NCL:\n($validate.err)\nManual resolution required." } } # ── Write output ───────────────────────────────────────────────────────── $merged_text | save --force $output print $" NCL auto-merge: ($left_name) ← ($right_name) [text+NCL clean]" } # Resolve the nickel --import-path from the file location or env. # # jj places its temp merge files somewhere under .jj/rewrite/ — they retain # the original filename but the directory is different. We resolve the import # path from env vars (set by ontoref sessions) or fall back to cwd. def resolve-import-path [file: path]: nothing -> string { let from_env = ( $env.ONTOREF_PROJECT_ROOT? | default ( $env.ONTOREF_ROOT? | default "" ) ) if ($from_env | is-not-empty) { return $from_env } # Best-effort: walk up from the file's real path looking for .ontology/ let mut dir = ($file | path expand | path dirname) for _ in 0..8 { if ($dir | path join ".ontology" | path exists) { return $dir } let parent = ($dir | path dirname) if $parent == $dir { break } # reached filesystem root $dir = $parent } pwd | path expand } # Run nickel export on a file, return {ok: bool, err: string}. def validate-ncl [file: path, import_path: string]: nothing -> record { let r = (do { ^nickel export --import-path $import_path $file } | complete) if $r.exit_code == 0 { { ok: true, err: "" } } else { { ok: false, err: ($r.stderr | str trim) } } } # Count jj/git conflict markers in merged text. def count-conflict-markers [text: string]: nothing -> int { $text | lines | where { |l| ($l | str starts-with "<<<<<<<") } | length } #[test] def test-conflict-marker-count [] { let text = "<<<<<<< left\nfoo\n=======\nbar\n>>>>>>> right\n" assert equal (count-conflict-markers $text) 1 } #[test] def test-conflict-marker-count-zero [] { let text = "let s = import \"schema.ncl\" in\n{ node_a = {} }\n" assert equal (count-conflict-markers $text) 0 }