165 lines
5.7 KiB
Text
165 lines
5.7 KiB
Text
|
|
#!/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 <left> <right> <base> <output>
|
||
|
|
#
|
||
|
|
# 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 @ -- <file>"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|